Interview guides · ACT
ACT Software Engineer Interview Questions & Guide 2026
Debugging practice for ACT software engineer candidates: reproduce it, narrow it, then change one thing per run.
The 60-second brief
As a Software Engineer at ACT, you serve as a foundational contributor to the organization’s mission of helping people achieve education and workplace success. This role is critical in building, maintaining, and scaling the technological infrastructure that powers large-scale assessment platforms and data-driven learning tools. You will work within teams that prioritize reliability and impact, ensuring that the software you build supports millions of users globally.
The behavioural round is a technical round in narrative form. Prepare it by collecting specifics you actually owned, such as a design you argued against, an incident you diagnosed, or a decision you later reversed, rather than by rehearsing phrasing.
Focus areas
- Make outbound grade publication idempotent and ordered
- Scope every query, cache and job by tenant
- Validate a signed launch without trusting the client
Overview
The work is characterized by a mix of legacy system integration and the development of modern, automated solutions. You will engage with complex problem spaces, from optimizing data processing pipelines to enhancing user-facing web applications. Success in this role requires a balance of technical proficiency and the ability to navigate a collaborative, mission-driven environment where your contributions directly influence the efficacy of educational products.
The ACT interview process
ACT candidates report 5 rounds · ≈ 4-6 weeks. The stages below are what candidates describe, not a published process.
Initial Screening
The person on this call usually cannot evaluate your code and does not need to. They write a short paragraph, and that paragraph is what a hiring manager skims when deciding who to put on your loop. So the test is not whether your work was hard, it is whether a non-engineer can repeat it correctly. Name systems by what they did rather than by their internal codename, give each project a shape (what was breaking, what you changed, what happened after), and keep the whole walkthrough near ninety seconds. Depth that cannot survive a paraphrase reads as vagueness.
What to demonstrate
- Whether a non-engineer can restate your projects without distorting them, since their paraphrase is what travels to the hiring manager, not your sentences
- Whether each project has a shape rather than a stack list: the failure or constraint, the change you made, the result and how it was measured
- Whether you can say what was yours inside a team project without either inflating it or disappearing into the plural
How to prepare
- Rewrite each headline project as two sentences with no internal system names and no acronyms outside your company, then say them to someone outside engineering and have them repeat them back. Fix whatever came back wrong
- Attach one measured number to each project: the baseline, the change, and the window it was measured over. Where nothing was ever measured, say that plainly rather than reaching for a plausible percentage
- Time the background walkthrough against a clock. If it runs past two minutes, compress the earliest role to a single clause and spend the recovered time on the most recent one
Technical Evaluation
The same problem is scored by two different mechanisms depending on the format, and preparing for one does not cover the other. With a person watching, partial progress is visible and a hint is a correction you can absorb; silence is the expensive failure, because nobody can read a half-written function. With an automated grader there is no partial credit for what you were about to do, nobody to ask, and the worked examples in the prompt are the entire specification. Read them as a contract, down to whether an empty result should be an empty list or no output at all.
What to demonstrate
- In a live session, whether your commentary tracks what your hands are doing, and whether a hint redirects you or gets defended against
- In an automated one, whether you cover the cases the examples do not show, since the hidden cases are where the score moves
- Whether you manage the clock on purpose: abandoning an approach that is not converging while there is still time to write something simpler that finishes
How to prepare
- Have someone hand you a problem and feed you one deliberately wrong hint. Practise testing it against a concrete case instead of accepting or rejecting it on authority.
- Do one timed run a week in a plain browser editor with autocomplete, linting and your own snippets switched off, which is closer to what these environments give you
- For the automated format, write the harness before the solution: a main that feeds the worked examples plus an empty and a single-element case and prints expected against actual, so a wrong submission is caught by you first
HR Interaction
An unlabelled round is first an information problem, and the cheapest information is free. Whoever schedules it can usually tell you how long it runs, who will be in the room and what they work on, whether you will be writing code and in what environment, and whether anything is being sent beforehand. Ask in writing so the answer is on record, then prepare for the two or three formats those answers still leave open instead of betting on one. What separates a strong candidate is not guessing right; it is having an opening that works whichever one it turns out to be.
What to demonstrate
- Whether you can start work from an ambiguous brief, since tolerating a vague scope without stalling is the same thing the job asks for
- Whether the questions you asked beforehand were ones that change your preparation, such as duration, medium and who is joining, rather than ones whose answers you could not have acted on
- Whether you adapt when the round turns out to be something other than what you were told, instead of spending the first ten minutes visibly recalibrating
How to prepare
- Send one short scheduling message asking four things: how long, who is joining and what they work on, whether you will be writing code and where, and whether to prepare anything in advance. Treat a vague reply as real information, since it means the round is loosely structured and you will be shaping it yourself.
- Write one opening that works in any of the formats still open: restate in your own words what you have been asked to do, then ask which of two directions is more useful to them. Say it aloud until it stops sounding recited.
- Set up for the two most likely formats before the call starts, with a blank editor in the language you would choose and a shared document you can type into, so a format surprise costs you nothing in the first minutes
Hiring Manager Interview
Part of what is being decided is what your manager's week looks like once you are on the team: how they find out that something you shipped is broken, and whether they hear it from you or from a customer. The material that moves this round is therefore not the launch, it is the week after. Describe how you knew the change was working, which number you watched and for how long, and what you would have reverted to. A project that ends at the word shipped leaves the manager guessing about the part they care about most.
What to demonstrate
- How a change of yours was verified in production, and whether that check existed before the deploy or was assembled afterwards once something looked wrong
- Whether the rollback story is specific, including the case where a revert is not enough: once a migration has dropped a column or rewritten data, going back is its own change with its own risk
- Whether you can describe an incident you caused, how it was found, and how much time passed between it starting and anyone noticing
- Whether bad news in your account travels early and from you, or consistently arrives via somebody else
How to prepare
- For your last significant change, write down what you watched after the deploy, for how long, and the number that would have made you revert. If nothing was watched, say that plainly instead of inventing a dashboard.
- Work out the rollback answer for a change that touched stored data, and be able to say what made it reversible or what you would have had to do instead. That distinction is a level signal on its own.
- Write the two-minute version of an incident you owned: what broke, how it surfaced, what you did in the first ten minutes, and the change that stopped it recurring. Get the timeline straight, because this is the story most likely to be interrupted with questions.
Final Decision
Nobody in the room with you decides this. Interviewers typically write their rounds up separately, often before seeing anyone else's, and the outcome is settled later from those write-ups. A split panel gets resolved by whichever note carries specific evidence, so what you want out of each room is one concrete thing that person could write down: a bug you caught yourself, a trade-off you named, a decision you owned. The rest is arithmetic. The project you describe in a behavioural conversation is often the same system you sketched an hour earlier, and the two accounts have to agree.
What to demonstrate
- Whether the scale, team size and timeline you attach to a project hold steady when that project resurfaces in a different round
- Whether each interviewer leaves with a specific thing to cite rather than a general impression of competence
- Whether a trade-off you defended in one round survives a challenge in another, instead of being quietly swapped for the answer the new interviewer seemed to want
- Whether a question you have already answered earlier in the day gets the same answer at the same depth, without visible impatience
How to prepare
- Write a one-page sheet per project fixing the figures you will quote — request volume, data size, team size, elapsed time, what broke — and say them aloud from the sheet until they come out identical every time
- For each round on the schedule, decide in advance the one sentence you want in that person's notes, then check in a mock that you said it outright instead of leaving it to be inferred
- Have someone ask you the same project question twice, an hour apart, and diff the two answers for numbers that moved or a trade-off that reversed
Where candidates lose points
Treating the launch subject identifier as a global user id, or merging identities on email.
The subject claim is stable only within its issuer, so the same human arriving from a second platform is a different subject and must be a second external identity bound to the same person. Email is worse than useless as a merge key here: privacy settings frequently suppress it from the launch entirely, districts recycle addresses between graduating and incoming students, and younger learners often have none. A merge on email eventually joins two children's work into one account, which is both a grade bug and a privacy incident.
Running learner submissions without hard, enforced resource ceilings.
Submitted code is untrusted input written by people who are actively learning and sometimes actively probing. Without a wall-clock limit alongside the CPU limit, a sleeping or blocked process pins a worker; without a process/thread cap, a three-line fork bomb takes the host; without an output cap, a stray print loop fills the disk and the log pipeline; without network isolation, a submission can reach internal services or exfiltrate the hidden tests. Each ceiling has to be enforced by the kernel or the runtime, since a limit checked by the harness after the fact is not a limit.
Never running a concrete value through the code
Trace one small input and one edge input by hand, index by index, out loud. Re-reading your own code catches design mistakes; walking a real value through it catches the off-by-one, the uninitialised accumulator and the loop that never advances.
Trusting input because it came from your own front end
Anything crossing a trust boundary is hostile: parameterise queries instead of building SQL by concatenation, validate against an allow-list rather than a deny-list, and bound the size of anything you allocate from a request. Raising this unprompted in an API or design question is a cheap and unusually strong signal.
Questions & practice
Rank the longest-stuck grade publications in bounded memory
Medium · top-k · heap · streaming · backoff
You are streaming score_publication rows to build an operator view: tenant_id, attempt_id, target, state, attempt_count, created_at, next_attempt_at, last_error_code. Up to 20,000,000 rows are in states pending and failed_retryable, spread across up to 5,000 tenants, and they arrive as an unsorted export you may not re-read. Return the 50 longest-stuck publications overall, the 5 longest-stuck per tenant, and a stuck count per tenant. Memory must be bounded independently of row count. State your bounds and the field you rank on.
Approach
- Pick the ranking field before the data structure, because this is where the answer is usually lost. Stuck duration is
now - created_at, not anything derived fromnext_attempt_at. Exponential backoff pushesnext_attempt_atfurther out with every failure, so ordering ascending by it surfaces the publications that just failed once and buries the row that has been retrying for six days — the ranking inverts exactly on the rows the operator opened the page for. - For the global top 50 — the 50 smallest
created_at— keep a max-heap of size 50 keyed oncreated_at. Push each row; when the heap exceeds 50, pop the maximum. The heap head is the youngest survivor, so a row older than the head displaces it and everything else is discarded in O(1) after one comparison. That is O(N log K) time and O(K) space, with the log K term only paid on the shrinking fraction of rows that beat the head. - For per-tenant results keep a map from
tenant_idto a size-5 max-heap plus an integer count. Memory is O(T * K') = 5,000 * 5 entries plus 5,000 counters, a few megabytes, and it is bounded by tenant cardinality rather than row count. Say that out loud: if tenants were unbounded this structure is not, and you would need a sketch or a two-pass job. - Do not sort. A full sort is O(N log N) over 20,000,000 rows and must materialise all of them; the heap never holds more than K. Note that a database
ORDER BY ... LIMIT 50reaches for the same bounded heap internally, so the interesting case is precisely the one stated — a stream you cannot re-read and cannot fit. - Carry the diagnostic fields through the heap entry rather than re-joining afterward. An operator needs
attempt_countandlast_error_codenext to the age to tell a receiver that is rate limiting from one that is rejecting the payload, and a second pass to fetch them defeats the single-pass constraint you just paid for. - Exclude
deadrows from these structures and count them separately. They are terminal by definition, so mixing them in lets a permanently dead publication occupy a slot in the top 50 forever while genuinely stuck rows rotate beneath it.
Worked solution (25 min)
- Write the comparator explicitly: order by
created_atascending, tiebreak onpublication_idascending, and build a max-heap under that order so the head is the weakest survivor. - Stream rows once, skipping
dead, updating the global heap, the tenant heap and the tenant counter for each surviving row. - Drain both heap levels at the end and sort only the K and K' survivors for display, which is at most 25,050 items.
- Build a fixture where one tenant has a publication created six days ago with
attempt_count14 and anext_attempt_atfar in the future, a second tenant holds eight of the fifty globally oldest rows so the containment check exercises the k-above-5 case, and twenty further publications created minutes ago carry imminent retry times. - Assert the six-day-old row is first in both the global and the tenant list, then re-rank by
next_attempt_atand record that it lands last.
Expected result: The six-day-old publication ranks first under created_at and last under next_attempt_at; the global list holds exactly 50 entries and every tenant list at most 5; the eight-row tenant has all eight of its rows in the global list but only its five oldest in its own; and peak memory is flat as the fixture is replicated from 200,000 to 20,000,000 rows.
Verify it yourself
- Shuffling the input changes nothing about the output, including the order of tied entries.
- The sum of per-tenant stuck counts equals the number of non-dead rows consumed.
- Containment runs from the global list to the tenant lists and never the other way. A tenant contributing k rows to the global top 50 has exactly its k oldest rows there, because any row older than one of them also ranks globally above it, so all k appear in that tenant's top 5 when k is 5 or less, and a tenant with k of 6 or more has its sixth-oldest row in the global list and outside its own top 5. Asserting the reverse — a tenant's top 5 inside the global list — fails on correct code, since 5,000 tenants compete for 50 global slots and most have no row in it at all.
- Resident memory measured at 200,000 and 20,000,000 rows differs by less than the process baseline.
Follow-up
- Two publications share the same
created_atto the microsecond. What is your tiebreak, and why does a deterministic one matter for an operator page that refreshes? - The operator wants to replay the top 50 safely. What must be true of the idempotency key for that to be a no-op when the original attempt actually succeeded?
- Tenant cardinality rises to 5,000,000. Which part of your structure breaks first and what replaces it?
Stream a quoted roster CSV without loading the file
Easy · parsing · state machine · streaming
You are handed a 2 GB roster export as a byte stream. It follows RFC 4180: any field may be quoted, a quoted field may contain commas and CRLF, a literal quote inside a quoted field is written as two quotes, and the file may open with a UTF-8 byte order mark. There are up to 10,000,000 records and a single field may reach 64 KB. Write a reader that yields one record at a time without buffering the whole file and that fails loudly on a malformed file rather than emitting a short record. State your bounds.
Approach
- Refuse the shape that fails first: splitting the stream on newlines and then splitting each line on commas. A quoted field may contain a CRLF, so line splitting cuts records in half, and the damage is silent because both halves parse into plausible short records.
- Write a byte-level state machine with four states — field start, unquoted field, quoted field, and quote-seen-inside-quoted. In the last state a second quote emits one literal quote and returns to quoted; a comma or newline ends the field; anything else is a malformed file. That table is the whole parser and it is the part to get right on paper before typing.
- Strip the BOM only at offset zero, comparing the first three bytes against EF BB BF. Left in place it becomes part of the first header name, so the header lookup for that column misses and the column reads as absent for every record in the file.
- Cap field length at the stated 64 KB and record length at a sane multiple of it. Without a cap, one unbalanced quote makes the parser treat the remaining 2 GB as a single field and the process dies of memory exhaustion rather than telling you the file is broken at byte 12,004.
- Treat end of stream inside a quoted field as a hard error, not an implicit close. A truncated upload is the most common malformed input here and it is byte-for-byte indistinguishable from a complete file if you close the field silently — the missing rows then present downstream as a mass withdrawal.
- Complexity: O(n) time in bytes with one pass and no backtracking, and O(longest field + longest record) space, which is bounded by the caps rather than by the file. Read in fixed blocks and keep the partial field across block boundaries instead of reading line-wise.
Follow-up
- The file arrives gzipped and you must report progress as a percentage. What can you actually report, and what does that do to your memory bound?
- Two exporters disagree on line endings and one emits a bare LF inside a quoted field. Does your state machine care, and should it?
- How do you distinguish a truncated file from a complete one when the byte stream itself gives you no signal?
Backfill frozen lateness without converting a hundred million times
Hard · cardinality reduction · time zones · batched backfill
A backfill must set due_at_utc on 1,000,000 assignment rows where it is NULL, from due_at_local plus policy_time_zone, and then set is_late on the 100,000,000 attempt rows beneath them whose is_late is NULL, by comparing submitted_at to that instant. Converting one local time per attempt row is correct and far too slow. Give a plan that is both faster and reproducible when re-run months later, state the speed-up you expect, and define the result for a local due time that is ambiguous or nonexistent across a daylight-saving transition.
Approach
- Name why the naive plan is slow rather than asserting it. A local-to-UTC conversion is a binary search over that zone's transition table plus an object construction, on the order of one to three microseconds in a managed runtime; 100,000,000 of them is a few minutes of pure conversion and, once per-row fetch and allocation are included, hours. Nothing about it is wrong — it is the row count multiplied by a cost that did not have to be paid per row.
- Collapse the cardinality. The conversion depends only on
(policy_time_zone, due_at_local), and due dates cluster hard on end-of-day instants for school days, so the distinct pair count is on the order of 10,000 against 100,000,000 attempts. Convert the distinct pairs once into a small mapping, then resolve each attempt with a hash probe. That is roughly a 10,000-fold reduction in conversions, and the per-row cost drops from a conversion to a lookup. - Do it in two stages so the second one is a join, not a computation: materialise
due_at_utcon the 1,000,000 assignment rows from the mapping, then setis_lateon attempts by joining onassignment_id. The attempt stage then performs no time-zone work at all, which is also what makes it re-runnable without the tz database on the path. - Pin reproducibility explicitly. The local-to-UTC mapping is a function of the zone, the local time, and the time-zone database version, because releases correct historical transitions. Record the tzdata version on the backfill run, or a re-run months later can produce a different instant for the same row and a grade will move with no grade event behind it.
- Define the two degenerate cases instead of letting a library default decide. An ambiguous local time in a repeated hour maps to two instants; a nonexistent one in a skipped hour maps to none. Pick the rule — the later instant when ambiguous, the first instant after the gap when nonexistent — encode it as an explicit flag rather than relying on a fold default, and assert both in tests. An 11:59 PM due time is outside the affected hour for most zones, so this is rare, which is exactly why it will not be noticed when it is wrong.
- Respect the freeze. Update only rows where
is_late IS NULL; a row that already carries the flag was decided at submit and must not be recomputed, or the backfill becomes a retroactive regrade. Then chunk the update by primary-key range with a bounded transaction size, because one 100,000,000-row statement holds locks and generates dead tuples for the whole run and cannot be resumed after a failure.
Follow-up
- A district is moved to a different school with a different time zone mid-term. Which rows, if any, change, and what does the freeze rule say?
- The tzdata version on the backfill host differs from the one in the application image. How would you have found that out before the run rather than after?
late_policyisblockfor some assignments, so a late submission should not exist. What do you write for an attempt that submitted after the due instant under that policy?
Model external identities so a second platform is not a merge
Easy · identity resolution · normalisation · unique constraints
You have person(person_id, tenant_id, display_name, email_lower NULL). Learners arrive three ways: a signed launch carrying (issuer, audience, subject, deployment_id), a roster feed carrying a sourcedId, and SSO carrying a nameid. The same human can arrive from two platforms. Email is frequently absent, recycled between graduating and incoming learners, and never guaranteed unique. Give the DDL for the identity tables with every uniqueness constraint, the statement a launch runs to resolve a person, and the rule that decides when two external identities may be bound to one person.
Approach
- Split the human from its identifiers: person holds the internal id, external_identity holds one row per issued identifier with a FK to person. The relationship is many-to-one by construction, so a second platform is an INSERT, not a reconciliation, and nothing in the schema tempts you to key on a mutable attribute.
- Scope the subject correctly. A subject claim is unique only within its issuer, and a platform that issues pairwise subject identifiers emits a different subject per relying party, so the natural key is (issuer, audience, subject); dropping audience collides the first time the same platform is registered twice. Tenancy comes from the deployment mapping you store, never from anything the client sends.
- Give roster identifiers their own namespace: UNIQUE (tenant_id, source_system, sourced_id), either as a discriminated row in the same table or its own table. Cross-namespace binding is where duplicate humans are born, so require a corroborating signal — a sourcedId released inside the launch, or an explicit admin action — and never a name or email string match.
- Write the resolve as an atomic claim rather than SELECT-then-INSERT: INSERT INTO external_identity (...) VALUES (...) ON CONFLICT (issuer, audience, subject) DO NOTHING RETURNING person_id, and on zero rows re-SELECT. Allocate the person inside the same transaction so losing the race rolls the speculative person back; two simultaneous first launches must leave exactly one person.
- Make merges explicit and reversible-by-audit: person_merge(loser_person_id PRIMARY KEY, winner_person_id, merged_at, actor, reason) plus a redirect on lookup, because stored attempts, scores and outbound publications still carry the loser's id and must keep resolving. A merge is one-way; an unmerge is a restore from the audit, not an UPDATE.
- Keep email as a searchable attribute, case-folded, with a non-unique index for support lookup only, and state the rule out loud: a support tool may surface candidates, it may not auto-bind them.
Worked solution (20 min)
- Write external_identity with UNIQUE (issuer, audience, subject) and a separate UNIQUE (tenant_id, source_system, sourced_id) for feed identifiers, both FK to person.
- Write the ON CONFLICT DO NOTHING RETURNING claim plus the zero-row fallback SELECT, inside one transaction with the person insert.
- Run two concurrent first launches with the same tuple and assert one person row and two identical resolutions.
- Insert a second identity for the same human from a different issuer, bind it to the existing person, and show that no constraint had to be dropped to allow it.
Expected result: One person per human, one external_identity row per issued identifier, uniqueness enforced on (issuer, audience, subject) and on (tenant_id, source_system, sourced_id), and no unique constraint anywhere on email.
Verify it yourself
- Two identities differing only in issuer both insert successfully and resolve to the same person_id.
- Two concurrent first launches produce exactly one person row, with the loser returning the winner's person_id rather than an error.
- Inserting two people with the same email_lower succeeds, and the resolve path never reads that column.
- After a merge, a lookup by the loser's person_id still resolves, through the redirect, to the winner.
Follow-up
- A district changes platforms. Every learner arrives with a new issuer and subject but the same sourcedId. What does the first launch after the change do, and how many person rows exist afterwards?
- Two identities were bound to one person in error and both have graded attempts. What does the unmerge do, and which stored ids can you not change?
- The launch releases no name and no email at all. What does the instructor see in the roster, and where does that display name come from?
Answer a point-in-time roster question against soft-deleted enrollments
Easy · soft deletes · audit history · exclusion constraints
enrollment(enrollment_id, tenant_id, section_id, person_id, role CHECK IN ('learner','instructor','aide','observer'), source, begin_date, end_date NULL, status, created_at, updated_at, deleted_at NULL) carries partial UNIQUE (tenant_id, section_id, person_id, role) WHERE deleted_at IS NULL. A learner withdraws in week 3 and re-enrols in week 6; one person is both aide and observer in the same section. Write the query returning learners enrolled in one section on a given date, name the index that serves it, and say what the deleted rows can and cannot tell you about a past roster.
Approach
- Separate the two time axes before writing anything. begin_date and end_date are valid time — when the enrollment was true in the world. deleted_at is transaction time — when the system stopped asserting the row. An 'as of date D' roster is a valid-time question, so the date predicate does the work and deleted_at only filters what you currently believe.
- Write it as: SELECT p.person_id, p.display_name FROM enrollment e JOIN person p ON p.person_id = e.person_id AND p.tenant_id = e.tenant_id WHERE e.tenant_id = $1 AND e.section_id = $2 AND e.role = 'learner' AND e.deleted_at IS NULL AND e.begin_date <= $3 AND (e.end_date IS NULL OR e.end_date >= $3) ORDER BY p.display_name. The role predicate, not DISTINCT, is what stops the dual-role person appearing twice.
- Notice what the partial unique index forbids: two live rows for the same (tenant, section, person, role) even when their date ranges are disjoint, so a genuine withdraw-and-return cannot be modelled as two live intervals. Either the first row is soft-deleted, which destroys the interval, or the uniqueness moves to EXCLUDE USING gist (tenant_id WITH =, section_id WITH =, person_id WITH =, role WITH =, daterange(begin_date, end_date, '[]') WITH &&) WHERE (deleted_at IS NULL), which needs the btree_gist extension and permits disjoint intervals while still rejecting overlaps.
- Index for the equality prefix: (tenant_id, section_id, role, begin_date) WHERE deleted_at IS NULL. Only the leading equality columns plus the first inequality are scan keys, so end_date belongs in the index for the visibility of the filter, not as a second range key.
- State the limit of a soft delete: it stores one bit and one timestamp, so it answers 'is this row still asserted', not 'what did we believe on D'. Reconstructing a past belief needs an append-only history row per change, or the recorded diff from the ingestion run; if support has to answer why a learner vanished, the run record is the artefact, not deleted_at.
Follow-up
- The feed sends two overlapping intervals for the same learner in the same section. Which one wins, and what does the exclusion constraint do to that apply?
- A learner withdrew but has a graded attempt in week 2. What does the gradebook show, and which predicate in your query decides it?
- How do you answer 'which learners did we deprovision last Tuesday' with this schema?
Knowledge of Python, JavaScript, and other relevant web technologies.
Medium
Approach
- Work from the requirement backwards to the design.
- Clarify what is being asked and what a complete answer contains.
- Say what you would check first and why it is the highest-information step.
Follow-up
- What assumption would you test first?
- How would you know your answer was wrong?
Can you explain OOP (Object-Oriented Programming) concepts and how you implement them?
Medium
Approach
- State your assumptions explicitly before working the problem.
- Clarify what is being asked and what a complete answer contains.
- Work from the requirement backwards to the design.
Follow-up
- What assumption would you test first?
- How would you know your answer was wrong?
Accept autosave writes that arrive twice and out of order
Easy · idempotency · ordering · write-heavy · concurrency
attempt holds attempt_id, tenant_id, assignment_id, person_id, attempt_no, state, started_at, server_deadline_at, last_autosave_seq BIGINT NOT NULL DEFAULT 0 and submitted_at. A learner's browser autosaves every 20 to 30 seconds carrying a per-attempt sequence number; the same learner has the assessment open in two tabs, and a flaky network retries a save whose response was lost. Design the write path: the exact conditional statement, what happens to a save whose sequence is below the stored one, and what the endpoint returns so a retry is always safe. State the isolation level you assume.
Approach
- State the two requirements separately: a retry of the same sequence must be a no-op that still reports success, and a lower sequence must be rejected rather than merged. Merging is the tempting move and it is wrong, because the two tab bodies are different answers to the same question and a merge produces a third answer no learner wrote.
- Express it as one statement: UPDATE attempt SET body = :body, last_autosave_seq = :seq, updated_at = now() WHERE attempt_id = :id AND tenant_id = :tenant AND state = 'in_progress' AND last_autosave_seq < :seq. A row count of 1 means applied; 0 means either stale or no longer in progress, and those two need different answers to the client.
- Justify the concurrency claim instead of asserting it. Under PostgreSQL READ COMMITTED, a second UPDATE of the same row blocks on the row lock and then re-evaluates its WHERE clause against the newly committed version, so the lower sequence loses deterministically and no SELECT-then-UPDATE or explicit lock is needed. Under REPEATABLE READ the same pair raises a serialization failure the client must retry, which is worse ergonomics for the identical outcome.
- Define the response contract. Applied and stale both return success carrying the stored sequence, because a client that receives an error will retry harder and a client that receives the stored sequence can resynchronise after a crash instead of restarting at 0 and being rejected forever. Only 'this attempt is already submitted' is a distinct terminal answer.
- Name what the sequence does not solve. It orders one attempt's writes; it does not decide which tab owns the session. If the policy is one active session, that needs a session token on the attempt, and without it a learner can silently overwrite good work from an abandoned tab that happens to hold a higher counter.
Worked solution (20 min)
- Write four interleavings (a retry of the same sequence, 7 then 6, two tabs alternating, a save after submit) with the required end state for each.
- Write the single conditional UPDATE and mark which predicate handles which interleaving.
- State the isolation level and explain the re-check that makes the lower sequence lose without an explicit lock.
- Define the three responses and the field that lets a restarted client resynchronise its counter.
- Compute the sustained write rate and say where the body belongs if it is large enough to hurt the row.
Expected result: One conditional UPDATE guarded by last_autosave_seq < :seq and state = 'in_progress', a row count that distinguishes applied from rejected, success returned for both applied and stale along with the stored sequence, and an explicit statement that a lower sequence is dropped rather than merged.
Verify it yourself
- Replay all four interleavings against your statement and confirm the stored body is always the one carrying the highest sequence.
- Confirm a retry of an already-applied sequence changes nothing and still returns success.
- Confirm nothing on the path reads the client's clock or trusts a client-supplied timestamp.
Follow-up
- The client restarts with no memory of its sequence number. Where does it obtain the next one, and what stops two tabs from choosing the same value?
- Autosave bodies are 50 KB arriving every 25 seconds for 40,000 concurrent learners. Does the attempt row remain the right place for the body, and what does that update rate do to table bloat and write-ahead log volume?
- A save arrives after submitted_at is set. What does the endpoint return, and is that learner's work lost or recoverable?
Roster apply deadlocks against launches on term-start mornings
Hard · deadlock · lock contention · transaction chunking · lock ordering
In the first week of term, between 07:45 and 08:15, deadlock errors (SQLSTATE 40P01) appear at 20-60 per minute, two roster runs abort with roster_sync_run.error set, some launches return 500, and read p99 doubles even in minutes with no deadlock. A 10^6-row roster apply started at 02:00 and is still running inside one transaction. The apply updates section metadata first and then that section's enrollment rows in file order; the launch path upserts an lti_launch enrollment and then bumps a counter on section. Give an ordered diagnostic checklist, the cycle, and the fix.
Approach
- Order the checklist to separate the two symptoms, because they have different causes and only one is the deadlock. First, pull the deadlock detail from the server log, which names both transactions, both statements and the locks each held and wanted; that is the cycle, printed for you. Second, explain the p99 rise in the minutes with no deadlock at all, which the cycle does not cover. Third, look at transaction duration and lock hold time. Fourth, look at scheduling.
- Read the cycle off the two orders: the apply takes section then enrollment; the launch takes enrollment then section. That is a two-resource cycle in opposite order, so any overlap can deadlock. Postgres detects it after a lock wait exceeds deadlock_timeout, default 1 s, and cancels one transaction with 40P01, which is why the errors are capped at a few dozen a minute rather than unbounded.
- Explain the second symptom separately: a single transaction over 10^6 enrollment rows holds every row lock it has taken until it commits, because row locks are never released early. Any launch touching those sections waits, and the wait is real even when no cycle forms. That is the doubled p99, and it would exist with perfect lock ordering.
- Fix the cycle by removing an edge rather than by reordering both sides. The launch path bumps a counter on section, which is derived data on a latency-critical path; move it to an async aggregate or a cache. With the launch path no longer writing section, the cycle cannot form regardless of the apply's order. Where two writers genuinely must touch both, fix a canonical acquisition order, and have the apply sort its batch by (section_id, person_id) before writing so file order cannot dictate lock order.
- Fix the contention by chunking the apply into bounded transactions, a few thousand rows each, committing between chunks. Lock hold time drops from hours to well under a second, an abort loses one chunk instead of a night, and the run stays restartable from the snapshot digest with per-chunk progress recorded. Retry 40P01 at chunk granularity with jittered backoff, which is safe precisely because the apply is idempotent on the digest. Chunked commits must not weaken the destructive-change gate: it is decided on the full diff before the first chunk commits, never per chunk, so a partially applied snapshot cannot deprovision a bounded fraction of a tenant ahead of the decision.
- Fix the schedule last, as defence rather than as the remedy: cap per-tenant apply concurrency and hold the apply out of the tenant's bell windows from the term calendar. Scheduling alone would hide the bug until a large tenant overran its window again, which is exactly what happened here.
Follow-up
- A chunked apply is no longer atomic. What can a reader observe mid-run, and which of those intermediate states are acceptable?
- Chunk 412 of 800 fails and the run restarts. Prove the restart neither double-applies nor skips rows.
- Raising deadlock_timeout would reduce the error count. Explain precisely what it would make worse.
Suggested practice checklist
Roughly ninety minutes on weeknights with one longer weekend block. The plan cuts scope rather than compressing everything, on the assumption that one thing finished per night beats four half-started.
Day 1 — Fix the scope and take a cold baseline
- Read the role description and write the three things the loop will almost certainly test, then write an explicit not-doing list and keep it visible all week.
- Take one twenty-five-minute coding problem and one fifteen-minute design prompt cold, and write the single sentence naming what blocked each, because those two sentences decide where the remaining evenings go.
- Set the week's rule: one thing finished every night, including the night you only have forty minutes.
Deliverable: A one-page scope with a not-doing list and two cold attempts, each carrying one sentence on what blocked it.
Day 2 — One pattern, written three times from blank
- Choose the single pattern most likely to appear in your loop and write it three times from an empty file rather than editing the previous attempt.
- On the third pass, write the invariant as a comment before the loop body and the complexity before the first line of code.
- Stop at ninety minutes even if the third version is imperfect, and write the one thing you would fix given another hour.
Deliverable: Three independent implementations of the same pattern plus a note on what changed between them.
Day 3 — One design, only to the depth you can defend
- Take one system shape and go only as far as requirements, interface and data model, refusing to draw a box you could not survive a follow-up about.
- Attach one number to each non-functional requirement, deriving it rather than asserting it, and write the assumption the number rests on.
- Write the one tradeoff you are choosing against and the observation that would make you reverse it.
Deliverable: One design at interface-and-schema depth with derived numbers and one written reversible tradeoff.
Day 4 — Only the fundamentals you will have to defend
- Write, in under two hundred words each, the answers to the two questions that follow almost any implementation: why this structure and not the obvious alternative, and what happens to this code at a hundred times the input.
- Write what an index actually costs: faster lookups on the indexed columns against a write that now maintains a second structure, plus the cases where the planner declines to use it anyway, low selectivity, or a predicate wrapping the column in a function.
- Delete any answer you cannot deliver aloud in under a minute, since an answer that needs reading is not an answer you have.
Deliverable: Three written answers, each under two hundred words and each timed aloud.
Day 5 — Your own work, timed
- Write a ninety-second and a four-minute version of your main project and time both aloud rather than reading them.
- Prepare the two follow-ups that always come: what you would do differently, and how you knew it worked.
- Put one number in the first sentence and be ready to say exactly where it came from and what it excludes.
Deliverable: Two timed narratives with one defensible number in the opening line.
Day 6 — The one full rehearsal, in the weekend block
- Run a sixty-minute mock covering a coding round and a design round in one sitting with no break, because sustained attention is the thing evenings have not trained.
- Immediately afterwards, and before hearing any feedback, write the three moments you lost the thread.
- Spend the rest of the block only on those three moments, and on nothing you merely feel shaky about.
Deliverable: Mock notes naming three failure moments with a specific fix written under each.
Day 7 — Taper
- Write the twenty-minute warm-up you will actually do on the morning: one problem you can already solve from a blank file, one design you can narrate, and nothing you have never seen.
- Re-read only your own notes from this week and open no new material.
- Write the logistics down: the editor or shared document you will be working in, whether execution and lookups are permitted, and the sentence you will use when you do not know something.
Deliverable: A one-page card holding the design structure, the project numbers, and the logistics.
Know where your numbers came from
Bring the two or three numbers the story rests on and know how they were collected. A p99 whose timer starts inside your handler excludes the time a request spent queued, so it can sit flat while users wait longer. Give the window, the percentile and what the measurement left out, or drop the number.
Describe your experience with Java in a professional or academic environment.
Medium · behavioural and engineering judgement
Approach
- State the situation in two sentences and spend the rest on the reasoning.
- Close with what you would do differently, concretely.
- Pick a story where you made the decision, not one where you watched it.
Follow-up
- How did you know your change caused the improvement?
- What would you do differently if you ran that again?
Tell me about your resume and the key projects you have contributed to.
Medium · behavioural and engineering judgement
Approach
- State the situation in two sentences and spend the rest on the reasoning.
- Pick a story where you made the decision, not one where you watched it.
- Name the disagreement and how you resolved it with evidence.
Follow-up
- What would you do differently if you ran that again?
- How did you know your change caused the improvement?
How do you balance the need for hard work versus smart work in a team setting?
Medium · behavioural and engineering judgement
Approach
- Give the blast radius: what could have broken, and what you measured.
- State the situation in two sentences and spend the rest on the reasoning.
- Pick a story where you made the decision, not one where you watched it.
Follow-up
- How did you know your change caused the improvement?
- What would you do differently if you ran that again?
Why do you want to work for ACT specifically?
Medium · behavioural and engineering judgement
Approach
- Give the blast radius: what could have broken, and what you measured.
- Name the disagreement and how you resolved it with evidence.
- Pick a story where you made the decision, not one where you watched it.
Follow-up
- How did you know your change caused the improvement?
- What did you decide not to do, and why?