Interview guides · Augment Professional Services
Augment Professional Services Software Engineer Interview Questions & Guide 2026
System design practice for Augment Professional Services software engineer candidates: reach a data model and an API contract, not a box diagram.
The 60-second brief
As a Software Engineer at Augment Professional Services, you will operate at the intersection of complex technical systems and high-impact professional services delivery. This position plays a pivotal role in driving technical excellence, ensuring system reliability, and supporting multidisciplinary engineering disciplines across various high-value project spaces. You will collaborate closely with specialists in mechanical, structural, process, controls systems, and electrical engineering to build robust solutions that meet rigorous industry standards.
For a design discussion, a catalogue of architectures is worth less than the ability to turn a vague requirement into a data model and an API contract. A box diagram with no schema under it collapses at the first follow-up question.
Focus areas
- Make integration retries idempotent without target-side idempotency keys
- Evolve schemas expand-contract across un-upgradable client deployments
- Version an API you cannot force clients to upgrade
Overview
The impact of this role directly influences how Augment Professional Services executes client deliverables, optimizes infrastructure, and scales its technological capabilities. Whether you are developing automation pipelines, integrating control systems, or architecting software tools for deployment engineers, your work will directly affect operational efficiency and project accuracy. The challenges you face will test your ability to bridge software engineering principles with specialized domain requirements, making this role both intellectually demanding and deeply rewarding.
You can expect an environment that values precision, cross-functional collaboration, and technical agility. Success in this position requires a balance of core programming proficiency and a willingness to understand complex physical and industrial systems. If you thrive in dynamic settings where your code directly impacts large-scale engineering operations, this role offers an exceptional platform for professional growth.
The Augment Professional Services interview process
Augment Professional Services candidates report 3 rounds · ≈ 3-5 weeks. The stages below are what candidates describe, not a published process.
Recruiter Alignment
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 Evaluations
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
Final Discussions
Where the day includes a partner from product, design or data, that conversation is weighted like the technical ones and prepared for least. They are deciding one thing: whether having you in the room makes their decisions cheaper. That means options with costs attached, not implementation detail and not "it depends". An estimate someone can plan against — a range, the assumption that would push it to the high end, and what you would drop to hit the low one — is worth more than a confident single number, which everyone present already knows is wrong.
What to demonstrate
- Whether an estimate comes as a range with the assumption most likely to break it, and states what a specific scope cut would actually buy
- Whether a technical constraint is handed over as a choice with consequences on their side, rather than as a verdict they have no standing to argue with
- Whether you establish what decision is on the table before proposing anything
- Whether risk is raised while it can still change the plan, with the trigger that would confirm it, instead of reported afterwards as a slip
How to prepare
- Take a project that shipped late and write the two-sentence warning you could have given three weeks earlier, naming what you would have needed decided at that point
- Rehearse one estimate out loud until it arrives in three parts: the range, the single assumption that would blow it, and the smallest thing you would cut to protect the date
- Rewrite an objection you have actually made — the "we can't do that" version — as two options with their costs, so the choice ends up with the person who owns it
Where candidates lose points
Treating an ambiguous failure as a definite one
A timeout, a 502 from an intermediary, or a connection reset after the request bytes were sent all leave the target's state unknown. Classifying those as failures and retrying duplicates the effect; classifying them as successes and advancing the watermark loses data silently. Both wrong answers are common because the ambiguous case is rare in a sandbox and routine in production. The run needs a distinct ambiguous state, a dedupe key that makes the retry safe, and a reconciliation read against the target when the key alone cannot settle it.
Depending on a client field because it is always populated
It is populated by their operational process, not by a constraint in their database, and their process changes without telling you. When it stops being populated, a permissive pipeline writes nulls or defaults into derived data and the damage spreads for weeks before anyone notices. Validate at the boundary and fail the run with failure_class='schema_mismatch' plus a redacted sample, so the failure is loud, attributable to the client's change, and cheap to diagnose. A run that fails clearly is far cheaper than a backfill that has to reconstruct which rows were silently wrong.
Assuming the input fits in memory
Ask how large the input is in bytes before committing to an in-memory algorithm; beyond that point the options are a single streaming pass, an external sort with bounded buffers, or a sketch that trades exactness for constant memory. An algorithm that assumes random access to the whole input is a different algorithm from one that sees each element once.
Assuming fixed-width integer arithmetic cannot overflow
In languages with fixed-width integers, including C, C++, Java, Go and Rust, computing a midpoint as (lo + hi) / 2 overflows once the sum passes the type's maximum, so write lo + (hi - lo) / 2 instead. Say which language you are in: arbitrary-precision integers, as in Python or Ruby, remove this specific hazard and none of the others.
Questions & practice
Assign connector bindings to rollout waves and name the cycle
Medium · topological sort · cycle detection · dependency graph
Connector bindings declare ordering dependencies: an edge u -> v means v must not run in a wave earlier than one after u has succeeded. You have up to 50,000 bindings and 200,000 edges, and a misconfigured dependency may have created a cycle. Assign each binding the earliest wave number such that every predecessor sits in a strictly earlier wave, return the total wave count, and if a cycle exists return the bindings on one concrete cycle rather than a boolean. The implementation must be iterative: a dependency chain can be 50,000 deep.
Approach
- Build an adjacency list and an indegree array, then run Kahn's algorithm from every indegree-zero node. Bindings with no predecessors get wave 0. O(V+E) time, O(V+E) space.
- Relax waves as you decrement: when popping u, set wave[v] = max(wave[v], wave[u]+1) for each successor before enqueueing v at indegree zero. Because v is only enqueued after its last predecessor is popped, wave[v] is 1 + max over predecessors, which is the earliest legal wave. Total wave count is max(wave)+1, the longest path in nodes.
- If Kahn emits fewer than V nodes, the residual subgraph is exactly the nodes lying on or downstream of a cycle. Every residual node still has an in-edge inside the residual, otherwise it would have been popped, so walking backwards along in-edges cannot dead-end and must revisit a node within |residual| steps; the segment between the two visits is a concrete cycle. O(V) with a visited-position map.
- Use an explicit stack or a queue rather than recursion. Recursive DFS on a 50,000-node chain exceeds the default stack in most runtimes, and the failure looks like a crash rather than a cycle.
- At this size, recompute the whole plan whenever an edge changes. Incremental topological order maintenance costs far more code than the O(V+E) rebuild it saves.
Follow-up
- Two bindings land in the same wave but hit the same client endpoint, whose rate budget is shared. How does the wave plan have to change, and what problem class does that turn into?
- One dependency holds only for overlapping watermark ranges rather than globally. Is the dependency graph still a DAG over bindings, and what is the right vertex if not?
- Report the critical path so an operator can see which chain sets the wave count, not just that it is five.
Find peak window utilisation and the first rejected request
Medium · sliding window · two pointers · rate limiting
One connector binding's requests against a client endpoint arrive as timestamps t[0..n-1] in milliseconds, sorted non-decreasing, n up to 1,000,000. The endpoint's budget is R requests per rolling window of W milliseconds, and a request at time t is admitted only if strictly fewer than R already-admitted requests fall in the half-open window (t-W, t]. Return (a) the maximum number of requests falling in any W-millisecond window, counting every request regardless of admission, and (b) the index of the first request the limiter would reject. Both parts in O(n) time.
Approach
- Part (a) is a two-pointer scan: for each right index, advance left while t[right] - t[left] >= W, which leaves the window exactly (t[right]-W, t[right]], and track max(right-left+1). Left is monotone non-decreasing, so total work is O(n) amortised with O(1) extra space. The maximum over all windows of width W is attained at a window whose right edge is a request timestamp, so scanning right edges is sufficient.
- Part (b) is not the same scan. A rejected request consumes no budget, so the window must count admitted requests only. Keep a FIFO deque of admitted timestamps: pop from the front while front <= t - W, admit if the deque holds fewer than R, and return the first index where it does not. O(n) total pushes and pops, O(R) memory.
- Pin the boundary convention before writing either loop. With a half-open (t-W, t] window a request exactly W milliseconds after an earlier one does not see it; switching to a closed window changes the answer at every boundary and is where most disagreements with the client's own limiter come from.
- Note what fixed buckets of width W buy and cost: O(1) memory, but they admit up to 2R inside a span shorter than W, R at the end of one bucket and R at the start of the next. If buckets are unavoidable, halve the bucket width or use a weighted estimate across two adjacent buckets.
- If part (a) already exceeds R, the binding cannot run at this concurrency under any limiter shape. That is a scheduling problem, not a retry-policy problem, and no backoff tuning fixes it.
Follow-up
- Three bindings and four workers share this one endpoint's quota. Where does the counter live, and what is the correct behaviour when that store is unreachable?
- Add full-jitter backoff to the retry path and state precisely what it prevents that plain exponential backoff does not.
- The client documents the limit as 'about 100 per minute'. How would you measure the real one without tripping it in their production?
Collapse connector retry attempts into logical jobs and failure counts
Easy · hash aggregation · idempotency · retries
You are given one UTC day of connector_run rows: (connector_id, idempotency_key, attempt_no, status, failure_class, records_read, records_applied), unique on (connector_id, idempotency_key, attempt_no), up to 5,000,000 rows in arbitrary order. Retries of one logical job share the idempotency_key; the job's outcome is the status of its highest attempt_no. Return, per connector_id: the number of logical jobs, a count of terminally failed jobs by failure_class, and the dedupe hit total, meaning sum(records_read - records_applied) over terminal-succeeded attempts only. One pass over the input; state your memory bound.
Approach
- Key a hash map on (connector_id, idempotency_key) and keep only the highest attempt_no seen so far plus that row's status, failure_class and record counts. One pass, O(n) time, O(d) space where d is the number of distinct logical jobs, not O(n).
- Treat a repeated (key, attempt_no) as corrupt input and raise, since the table's uniqueness constraint says it cannot happen; silently overwriting hides a double-insert in the runtime.
- Fold the d surviving entries into per-connector counters in a second pass over the map, not over the rows. The failure_class histogram is a small fixed-width map per connector because failure_class is an enum.
- Compute dedupe hits only from terminal-succeeded attempts. A failed attempt's records_read is work attempted, not work deduplicated, and adding it counts the same source records once per retry.
- If d does not fit in memory, partition the input by hash(connector_id, idempotency_key) into p files and aggregate each partition independently. Same O(n) total work, p sequential passes, memory traded for I/O; the partition function must use the full key or a job's attempts split across files.
Worked solution (20 min)
- Take six rows: (c=7,K=A,1,failed,client_429,0,0), (c=7,K=A,2,succeeded,null,500,480), (c=7,K=B,1,ambiguous,client_timeout,300,300), (c=7,K=B,2,succeeded,null,300,0), (c=9,K=C,1,failed,schema_mismatch,0,0), (c=9,K=C,2,failed,schema_mismatch,0,0).
- Build the map: A -> attempt 2 succeeded, B -> attempt 2 succeeded, C -> attempt 2 failed/schema_mismatch. Three entries from six rows.
- Fold per connector: connector 7 has 2 logical jobs, 0 terminal failures; connector 9 has 1 logical job, 1 terminal failure classed schema_mismatch.
- Dedupe hits from terminal-succeeded attempts only: (500-480) + (300-0) = 320 for connector 7, 0 for connector 9.
- Compute the wrong answer deliberately by counting attempt rows: 6 jobs and 3 failures, which is what a naive GROUP BY on run rows returns.
Expected result: connector 7: 2 logical jobs, no terminal failures, 320 dedupe hits. connector 9: 1 logical job, 1 terminal failure with failure_class='schema_mismatch', 0 dedupe hits. Attempt-level counting would have reported 6 jobs and 3 failures.
Verify it yourself
- The sum of terminal statuses across all connectors equals the number of distinct (connector_id, idempotency_key) pairs, here 3.
- records_applied <= records_read holds on every row, so the dedupe total is non-negative.
- Rerunning the aggregation over a shuffled copy of the input gives identical output, since the highest attempt_no wins regardless of arrival order.
- Map size equals 3, strictly less than the 6 input rows, confirming collapse actually happened.
Follow-up
- A job's highest attempt is 'cancelled' but an earlier attempt succeeded. What is the job's outcome, and what does that say about who writes the cancel?
- Produce this incrementally as runs land instead of as a daily batch: what state do you keep per key, and what happens when an attempt arrives out of order?
- records_applied is written by your runtime, not by the client. What would you reconcile it against before anyone trusts the dedupe-hit number?
Return the latest attempt per connector idempotency key
Easy · distinct-on · indexing · idempotency
connector_run stores one row per attempt: run_id, connector_id, idempotency_key, attempt_no SMALLINT, status (queued, running, succeeded, failed, ambiguous, cancelled, abandoned), failure_class, watermark_from, watermark_to, started_at, finished_at, unique on (connector_id, idempotency_key, attempt_no). For one connector_id, write the query returning the highest-numbered attempt per idempotency_key, keeping only those keys whose latest attempt is not succeeded, ordered by watermark_from. Give the index that serves it, and say what the plan does without that index.
Approach
- Use DISTINCT ON (idempotency_key) with ORDER BY idempotency_key, attempt_no DESC. PostgreSQL requires the ORDER BY to lead with exactly the DISTINCT ON expressions, and that requirement is what makes the row picked deterministic rather than whichever the scan happened to reach first.
- Apply the not-succeeded filter outside, in a wrapping SELECT. Putting it in the inner WHERE changes the question from 'keys whose latest attempt failed' to 'keys that have any non-succeeded attempt', which reports a key that failed twice and then succeeded, and is the single most common wrong answer here.
- Know the portable alternative: row_number() OVER (PARTITION BY idempotency_key ORDER BY attempt_no DESC) in a subquery, filtered = 1 outside. A window function cannot be filtered in WHERE because WHERE is evaluated before the window, and PostgreSQL has no QUALIFY clause, so the subquery is mandatory rather than stylistic.
- Index (connector_id, idempotency_key, attempt_no DESC). With connector_id fixed by equality, the index supplies the exact order DISTINCT ON needs and the Sort node disappears. It does not reduce rows read: through PostgreSQL 17 there is no loose index scan, so the executor still walks every attempt in range rather than skipping to each next distinct key.
- Give the complexity honestly: without the index the plan is a scan plus an O(n log n) sort over the connector's attempts; with it, O(n) ordered reads and constant working memory, where n is attempts in range, not distinct keys. If the distinct keys are a tiny fraction of attempts, a recursive skip-scan is the next step.
Worked solution (15 min)
- Create the table with a handful of rows across three idempotency keys, including one key with attempts 1 failed, 2 ambiguous, 3 succeeded.
- Write the inner DISTINCT ON query alone and confirm it returns one row per key.
- Wrap it and add WHERE status <> 'succeeded' plus ORDER BY watermark_from in the outer query.
- Create the index and compare EXPLAIN output before and after.
Expected result: SELECT * FROM (SELECT DISTINCT ON (idempotency_key) idempotency_key, attempt_no, status, failure_class, watermark_from, watermark_to FROM connector_run WHERE connector_id = $1 ORDER BY idempotency_key, attempt_no DESC) latest WHERE status <> 'succeeded' ORDER BY watermark_from;
Verify it yourself
- The key whose attempts run failed, ambiguous, succeeded is absent from the result, proving the filter runs after the pick.
- Move the filter into the inner WHERE and watch that key reappear as attempt 2 — the wrong answer, visible on a four-row fixture.
- EXPLAIN shows an Index Scan with no Sort node once (connector_id, idempotency_key, attempt_no DESC) exists.
Follow-up
- attempt_no is not monotonic in time because a retry was queued out of order. Does your query still pick the right row, and which column should it actually order by?
- How would you return the latest attempt per key across all connectors in one environment without the query degrading to a full sort?
- An ambiguous latest attempt means the target may or may not have been written. What does this query feed, and should ambiguous be treated as failed here?
Explain why the failed-run backlog query ignores three indexes
Medium · indexing · query planning · partial indexes
connector_run holds roughly six million rows over three years. Its indexes are (connector_id, started_at DESC); (started_at) WHERE status = 'failed'; and ((finished_at AT TIME ZONE 'UTC')::date). The operator backlog query is SELECT run_id, connector_id, failure_class, started_at FROM connector_run WHERE environment_id = $1 AND status = ANY($2) AND started_at >= now() - interval '7 days' ORDER BY started_at DESC LIMIT 50, with $2 bound to {failed, ambiguous}. It sequentially scans. Explain index by index why each is unusable, then give the one index you would add.
Approach
- Index one leads on connector_id, which this query never constrains. A B-tree can only be probed from a prefix of its key columns, so with no equality on connector_id it degenerates into a full index scan plus heap fetches, which the planner will rightly cost above a sequential scan. PostgreSQL 18 added B-tree skip scan for an unconstrained prefix, so state your server version before assuming this is fatal; it helps only when that prefix has few distinct values, and hundreds of connectors is already too many to rely on it.
- Index two is partial on status = 'failed', and the rows the query wants are partly not in it at all. Predicate implication runs one way: the query's predicate must imply the index's. status IN ('failed','ambiguous') is weaker than status = 'failed' and implies nothing, so no amount of inlining the parameter helps. It would serve only the failed branch of a UNION ALL.
- Index three is an expression index on a function of finished_at, while the query filters started_at as a plain range. Even on the right column, a range predicate on the bare column cannot use an index on a function of it. Note also why the AT TIME ZONE 'UTC' wrapper is present: finished_at::date on a timestamptz is STABLE rather than IMMUTABLE because it depends on the session TimeZone, so PostgreSQL refuses to index it without a fixed zone.
- Add (environment_id, started_at DESC) WHERE status IN ('failed','ambiguous'): equality on the scoping column first so the scan starts in the right place, the ordering column second so ORDER BY started_at DESC LIMIT 50 stops after fifty index entries instead of sorting the week, and partial on the status set so the index covers the small failing minority rather than six million rows.
- Confirm the partial predicate is provable for the query's parameterised form. Under a custom plan the array parameter is substituted as a constant and the prover handles the ScalarArrayOpExpr; under a generic plan it stays a Param and the proof fails, so check plan_cache_mode or the sixth execution of a prepared statement before declaring victory.
- Say what you deliberately did not do. Adding INCLUDE (run_id, connector_id, failure_class) to chase an index-only scan pays only while the visibility map is current, which ties the query's latency to whether autovacuum is keeping up with the write rate — a dependency worth accepting consciously, not by accident.
Follow-up
- The same query is also run without the environment filter, for an estate-wide backlog. Does your index still help, and what would you add instead?
- How does row-level security on this table interact with your index, given that policy quals are applied before non-leakproof user quals?
- Six months from now the partial index covers 30 percent of the table. What signal tells you to change it?
Walk through your approach to troubleshooting a critical production failure when logs are incomplete.
Medium · system design and architecture
Approach
- Fix the scope first: who calls this, how often, and what they do when it fails.
- State the consistency you need, and where you are willing to be stale.
- Choose a partition key and say what query it makes expensive.
Follow-up
- What would you drop to keep the system up under load?
- What breaks first when traffic grows ten times?
What factors do you consider when choosing between a microservices architecture and a monolithic approach for a new tool?
Medium · system design and architecture
Approach
- Name the read and write paths separately; they rarely have the same bottleneck.
- Name the failure you are designing for, then the recovery path.
- State the consistency you need, and where you are willing to be stale.
Follow-up
- What breaks first when traffic grows ten times?
- How does this behave when that dependency is down for an hour?
How do you balance rapid feature delivery with long-term code maintainability and scalability?
Medium · system design and architecture
Approach
- Name the failure you are designing for, then the recovery path.
- Choose a partition key and say what query it makes expensive.
- State the consistency you need, and where you are willing to be stale.
Follow-up
- What would you drop to keep the system up under load?
- What breaks first when traffic grows ten times?
Describe a scenario where requirements changed mid-project. How did you adapt your architecture?
Medium · system design and architecture
Approach
- Name the read and write paths separately; they rarely have the same bottleneck.
- Fix the scope first: who calls this, how often, and what they do when it fails.
- Name the failure you are designing for, then the recovery path.
Follow-up
- What would you drop to keep the system up under load?
- What breaks first when traffic grows ten times?
What strategies do you use for debugging complex distributed systems or asynchronous workflows?
Medium
Approach
- State your assumptions explicitly before working the problem.
- Work from the requirement backwards to the design.
- 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?
How do you ensure data integrity and real-time performance in high-stakes operational environments?
Medium
Approach
- Say what you would check first and why it is the highest-information step.
- State your assumptions explicitly before working the problem.
- Clarify what is being asked and what a complete answer contains.
Follow-up
- What assumption would you test first?
- How would you know your answer was wrong?
Make timesheet submission safe under a client's automatic retries
Medium · idempotency · retries · unique-constraints · http-semantics
work_record holds work_record_id, engagement_id, principal_id, work_date, entered_at, minutes, is_billable, status, approved_at, locked_at and invoice_line_id, and entered_at is assigned by the server at insert. A delivery-team app submits entries over mobile networks and retries any request that has not answered within ten seconds. Today POST /engagements/{id}/work-records inserts a row and returns 201, and month-end shows duplicated minutes. Design the write contract so a retried submission cannot double-book: start from whether those columns give you a natural key, then specify the key you actually use, its scope and lifetime, the response on replay, the behaviour when the same key arrives with a different body, and what the app does on each outcome.
Approach
- Name the actual fault: POST is not idempotent, and a ten-second timeout leaves the outcome unknown, because the row may have committed and only the response was lost. Retrying is correct client behaviour; making the retry safe is the server's job. Two mechanisms exist and they are not interchangeable — a natural key derived from the row's own grain, or an opaque client-supplied key — so the first question is which one this grain can support.
- Test the natural key against the columns you were given, and reject it. The candidate is (engagement_id, principal_id, work_date), and nothing left over separates two legitimate entries: minutes and is_billable repeat honestly, because one person can book two distinct ninety-minute billable blocks on one day, and entered_at is server-assigned so it differs on every retry — it changes at exactly the moment a dedupe key must stay fixed. A unique index on that triple therefore rejects a real second entry, trading a visible bug for an invisible one: duplicated minutes surface at month-end, while silently refused minutes surface as an under-billed engagement nobody reconciles. Prefer a natural key wherever the grain genuinely has one; this grain does not, so the key has to come from the client.
- Require an Idempotency-Key header and say so in the contract. With no natural key available, a request that arrives without one cannot be made retry-safe by anything the server knows, so refuse it with its own stable code rather than accepting it and hoping. The IETF header field is still a draft, so document your own semantics rather than pointing at a spec. Scope the key to (authenticated principal, endpoint, key) so one tenant cannot collide with another's, store a fingerprint of the request body beside it, and retain it at least as long as the client's maximum retry horizon; a key pruned before the last retry re-opens the duplicate window it existed to close.
- Pin the client's half of the contract, because that is where this design usually fails in practice: the key is generated once per logical entry and reused byte-for-byte on every retry of it. An app that mints a fresh UUID per attempt satisfies the header and still double-books, and no server-side check can tell that apart from two genuine entries. Specify a deterministic construction the app can rebuild after a restart rather than one it holds in memory, or a crash between attempts reintroduces the duplicate.
- Same key with a different body is a distinct 4xx with its own code, never a silent replay of the first response. These are two different bugs: a genuine retry must replay, and a key reused for new content is a client-side key-generation defect that stays invisible for months if you answer it with the original body.
- Handle the concurrent in-flight duplicate, which is the case a single-threaded test never reaches: two copies arrive forty milliseconds apart, both miss the key lookup, both insert. Resolve it in the database rather than by check-then-act. Put a unique index on (principal_id, endpoint, idempotency_key), insert that row in the same transaction as the work record, and let the loser take the violation. Under READ COMMITTED in PostgreSQL the second insert blocks on the winner's uncommitted key row and then raises 23505 when the winner commits; that error aborts the transaction, so take a SAVEPOINT before the insert if you intend to read the winner back on the same connection. Note also that INSERT ... ON CONFLICT DO NOTHING returns no row on conflict, so the handler needs an explicit SELECT afterwards, or ON CONFLICT DO UPDATE ... RETURNING, to obtain the winning row.
- Answer the loser that arrives before the winner has stored its response with a 409 carrying its own code and a Retry-After, not with a guessed body. Store each response with its status code so a later replay is byte-identical, and write the app's branch table: 201 and 200 both mean submitted; the different-body 4xx and the missing-key 4xx stop and surface to the user; a 409 against locked_at means re-read; the in-progress 409 retries after Retry-After; timeouts and 5xx retry with the same key and the same body. No branch may retry a terminal 4xx and no branch may drop a timeout.
Worked solution (30 min)
- List the columns that could separate two entries on the same engagement, principal and date, strike out each with the reason it cannot, and write down the legitimate row pair a unique index on that triple would reject.
- Write the handler as one transaction containing both the key row and the work record, and mark the commit point explicitly.
- Enumerate four arrivals of the same request — first, retry after commit, retry after a crash before commit, and a concurrent duplicate whose winner has not yet stored a response — and give each a status code and body.
- Write the different-body case, including how the body fingerprint is computed and which fields are excluded from it.
- State the key's scope and retention, and tie the retention number to the client's retry horizon rather than to a round figure.
- Write the app's branch table and check that no branch retries a terminal 4xx and none drops a timeout.
Expected result: A written finding that work_record has no natural key at this grain, so the endpoint requires a client-supplied Idempotency-Key scoped to (principal, endpoint, key), unique-indexed and stored with a body fingerprint and the response; replays return the original status and body; same-key-different-body returns a distinct 4xx; a request with no key is refused rather than accepted; the concurrent duplicate is settled by the unique violation rather than a pre-check, with a loser whose winner has not yet responded answered 409 plus Retry-After; and only timeouts, 5xx and that 409 retry.
Verify it yourself
- Firing the same request fifty times concurrently leaves exactly one work_record row, one 201 and forty-nine 200s, all naming the same work_record_id.
- Killing the process between the insert and the response leaves the retry returning the committed row rather than creating a second one.
- Two genuinely distinct entries by the same principal on the same engagement and work_date, submitted under different keys, both persist.
Follow-up
- The period has been billed and locked_at is set when a retry finally lands. What status does that retry get, and what does the app show the user?
- What would have to be true of work_record for a natural key to be the better answer here, and what would you still keep the header for?
- How long do you keep idempotency keys, and what exactly breaks on the day you prune one too early?
Environment list page slows as the estate grows
Easy · n+1 query · latency · batching
An estate console lists environments for an operator: environment_id, client name, isolation_tier, the desired release version string, the reported release version string, and the engagement's current status. A 10-row filter returns in 25 ms; the unfiltered 200-row page takes 640 ms. No single statement exceeds 0.5 ms, and the database records 553 statements for that request. Rows where reported_release_id is NULL are measurably cheaper than the rest. Diagnose the cause and give the fix, stating the statement count you expect afterwards.
Approach
- Get the shape before theorising about plans. Divide the per-request statement delta in pg_stat_statements by the rows returned: 553 statements for 200 rows, none over 0.5 ms, is round trips rather than work, and rules out a bad plan immediately.
- Account for 553 exactly, because the arithmetic is the diagnosis and not a detail. One driver query, one desired-release lookup per row, one current-engagement lookup per row, and one reported-release lookup only for the rows where reported_release_id is non-null: 1 + 200 + 200 + 152. The 152 is why client-managed rows are cheaper, and if the sum did not land on the observed number something else is firing too.
- Sanity-check the cost model: 553 round trips at roughly 1 ms each is about 0.55 s on top of a 25 ms baseline, which accounts for the 640 ms. A hypothesis that does not reproduce the measured number is not finished.
- Batch it. Collect the ids from the driver query, then fetch releases with WHERE release_id = ANY($1) and the open engagement versions with WHERE engagement_id = ANY($1) AND effective_to IS NULL, and assemble in memory. Three statements per page, independent of page size.
- Check index usage before declaring victory. The engagement fetch must use the partial unique index on (engagement_id) WHERE effective_to IS NULL; without it, ANY($1) scans every amendment version of every engagement and the fix regresses harder than the bug did.
- Pin the behaviour with a test asserting a constant statement count per request, since no individual query here is slow enough to ever trip a slow-query alarm.
Follow-up
- The operator wants to sort the page by reported release version. Where does that sort have to happen now, and what does it cost?
- Page size goes to 1,000. Which breaks first, the driver query or the ANY($1) parameter list?
- reported_release_id NULL means unknown, not converged. How should the list render that, and what does the count at the top of the page then mean?
Suggested practice checklist
For someone who has spent the last few years shipping features and reading other people's code, and who has not solved a timed problem from a blank file in a long time. Five days rebuild the primitives and the patterns that sit on them, working from invariants rather than remembered solutions, and the last two attach that back to the rest of the loop.
Day 1 — Rebuild the primitives by implementing them
- Implement a dynamic array with doubling growth and an operation counter, then change the growth rule to add a fixed sixteen slots instead, and time both for n of ten thousand, a hundred thousand and a million. The fixed-increment version resizes n/16 times at O(n) each, so its total work is quadratic; doubling is what makes append amortised constant.
- Implement a hash map with separate chaining and a load-factor resize, then insert ten thousand keys engineered to land in one bucket and record what happens to lookup time, so that average-case O(1) becomes a claim with a stated precondition rather than a reflex.
- For dynamic-array append and hash-map insert, write down which cost is amortised rather than worst-case, which single operation pays the whole bill, and what a system with a hard per-operation deadline would have to do instead.
Deliverable: Two working implementations plus a timing table showing the input at which each structure's advertised complexity stops holding.
Day 2 — Arrays under an invariant: two pointers, sliding window, binary search
- Solve longest-subarray-with-sum-at-most-K using a sliding window, then run it on an input containing negative numbers and watch it return the wrong answer: extending the window only moves the sum monotonically when every element is non-negative, and that precondition is the whole reason the technique works.
- Write the binary search that finds the first index satisfying a predicate rather than an exact value, put the loop invariant above the loop in a comment, and verify termination on the two inputs that break careless versions: the empty range, and a range where every element satisfies the predicate.
- Compute the midpoint as lo + (hi - lo) / 2 and write one line on why the obvious (lo + hi) / 2 is a genuine defect in a fixed-width integer type and a non-issue in a language with arbitrary-precision integers.
Deliverable: Three solved problems, each with its invariant written above the loop, plus one recorded input on which the sliding window is provably wrong.
Day 3 — Sorting, heaps, and the greedy argument that has to be proved
- Solve one top-k problem three ways, by full sort, by a size-k heap, and by quickselect, then write the values of n and k at which each becomes the right choice, along with quickselect's quadratic worst case and why a randomised pivot makes that unlikely rather than impossible.
- Implement bottom-up heapify and count sift-down steps to confirm it does linear work rather than n log n, because most nodes sit near the bottom of the tree and therefore move only a short distance.
- Take interval scheduling by earliest finishing time and write the exchange argument out in full: given any optimal schedule, swapping in the earliest-finishing interval keeps it feasible and no smaller. Then construct the weighted variant where that same greedy fails and name what has to replace it.
Deliverable: A three-way top-k comparison with measured crossover points, one written exchange argument, and one counterexample to a greedy rule that looks almost identical.
Day 4 — Recursion, memoisation, and the step to a table
- Take one problem with overlapping subproblems, such as edit distance or coin change, instrument the plain recursion with a call counter to show the blow-up, then add memoisation and re-count.
- Convert the memoised version to a bottom-up table and state the two properties you relied on: each subproblem's result depends only on its arguments, and the dependencies form a DAG you can enumerate in order.
- Rewrite one deep recursion with an explicit stack, then find the input length at which the original hits the interpreter's frame limit, which defaults to about a thousand frames in CPython, so you know when the rewrite is required rather than decorative.
Deliverable: One problem in three forms, naive, memoised and tabulated, with call counts for each and the input length at which recursion depth becomes the binding constraint.
Day 5 — Graphs, where most of the work is choosing the traversal
- Implement BFS and DFS over one adjacency list, then answer for each which finds a shortest path in an unweighted graph and which you would use to detect a cycle in a directed graph, including why the in-progress versus finished distinction matters for the second.
- Implement topological sort by in-degree, feed it a graph containing a cycle, and confirm the failure signature is that fewer than V nodes come out rather than an exception, then note that the order it produces is one of several valid ones.
- Run a shortest-path search on a graph with a single negative edge weight and show the wrong answer, then write the precondition Dijkstra actually needs, non-negative weights, because it finalises a node's distance the first time that node is popped, and name the algorithm you would switch to and its own limit.
Deliverable: A small graph library with BFS, DFS and topological sort, plus two inputs that produce documented wrong answers under the wrong algorithm choice.
Day 6 — One day for everything that is not an algorithm
- Sketch one system only to the depth a coding-heavy loop tends to reach: the endpoints, what the service stores, and the single query pattern that decides the schema. Stop at twenty-five minutes.
- Prepare the project answer for an interviewer who codes, which means rehearsing the two levels they push to: the specific thing you built, and why you chose that approach over the alternative they will name. Open with a number and be ready to say what it excludes.
- Prepare the answer to what you would do differently, choosing a real technical mistake with a specific fix rather than a complaint about process or staffing.
Deliverable: One design sketch at endpoint-and-schema depth, plus a project answer rehearsed to two levels of follow-up.
Day 7 — Solve out loud, under time
- Do three timed problems at twenty-five minutes each in a plain editor with no autocomplete and no execution until the end, then tally separately the failures that were syntax and the ones that were approach, because those two numbers call for different fixes.
- Narrate one solution from the first sentence, stating the approach and its complexity before writing any code, and rehearse the sentence you will use when you realise mid-solution that the approach is wrong.
- Re-solve from blank the two problems you were slowest on this week and compare the times against the day they first appeared.
Deliverable: A recording of one fully narrated solution and a tally that separates syntax failures from approach failures.
Risky changes and the reversal path
For anything that touched live traffic, be ready to say how you would have undone it: a flag, a staged rollout, dual writes with the old path still authoritative. Once the old column is dropped or the source rows are overwritten there is no reverse, so name what you kept a copy of and for how long.
Tell me about a time you had to explain a complex technical concept to a non-technical stakeholder, such as a mechanical or electrical engineer.
Medium · behavioural and engineering judgement
Approach
- Give the blast radius: what could have broken, and what you measured.
- Pick a story where you made the decision, not one where you watched it.
- State the situation in two sentences and spend the rest on the reasoning.
Follow-up
- What did you decide not to do, and why?
- What would you do differently if you ran that again?
How do you handle feedback during code reviews or project retrospectives?
Medium · behavioural and engineering judgement
Approach
- Give the blast radius: what could have broken, and what you measured.
- 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?
Argue against a per-client fork of the product
Medium · maintenance cost · extension points · technical strategy
Describe a time you argued against a design that was cheaper for the request in front of you and worse for the estate: forking the product for one client, a bespoke schema, a one-off pipeline. State the alternative you proposed, the cost model you used to compare the two, what the decision actually was, and what happened afterwards. The account must include a number you computed at the time, not one you inferred later. If you lost the argument, say what the outcome taught you about the argument.
Approach
- State both options as costs over time rather than as good and bad. A fork is O(1) today and O(clients x changes) forever: one security patch becomes one port per fork, each with its own conflicts, its own test run and its own release window, and the forks drift further apart with every port.
- Name the number you put in front of the decision-maker (ports per year times hours per port, or release windows consumed) and say where each input came from.
- Give the alternative concretely: a named extension point with a stable interface and a stated compatibility commitment, or an explicitly time-boxed, separately priced artefact that is never expected to receive upstream fixes. 'Do it properly' is not an alternative.
- Say what you conceded. An answer with no concession reads as someone who has not shipped against a delivery date; the defensible concession is usually scope or a sunset date, not the interface.
- Report the outcome honestly, including the case where you were wrong, and name the signal that would have told you sooner.
Follow-up
- The client is the largest on the book and the fork is a renewal condition. What changes in your recommendation, and what stays fixed?
- You won, and the extension point shipped. A year later, how do you tell whether it is used, worked around, or quietly forked anyway?