Interview guides · Argain Consulting Innovation
Argain Consulting Innovation Software Engineer Interview Questions & Guide 2026
Timed coding practice for Argain Consulting Innovation software engineer candidates: full attempts under a clock, then scored against a rubric.
The 60-second brief
As a Software Engineer at Argain Consulting Innovation, you sit at the intersection of technical architecture and business transformation. You are not merely writing code; you are solving complex problems for clients who rely on the firm to modernize their legacy environments or build cutting-edge artificial intelligence solutions. Your work directly impacts how Argain Consulting Innovation's clients operate, scale, and innovate in an increasingly digital landscape.
Most rounds test how you handle an under-specified problem more than what you recall. A wrong first approach you correct out loud is recoverable; a constraint you assumed silently and were never given is the expensive mistake.
Focus areas
- Diagnose failures inside environments you cannot instrument
- Make integration retries idempotent without target-side idempotency keys
- Bound credential lifetime by the engagement end date
Overview
This role is both critical and intellectually demanding. Whether you are serving as a Business Analyst, an IA Engineer, or a Technical Product Owner, you are expected to bridge the gap between abstract business needs and robust technical implementation. You will work in environments where your ability to navigate both cloud-native infrastructures and legacy systems determines the success of the firm's client partnerships.
The Argain Consulting Innovation interview process
Argain Consulting Innovation candidates report 4 rounds · ≈ 3-5 weeks. The stages below are what candidates describe, not a published process.
Initial Assessment
What this round decides is narrow: whether you can produce code that runs and is correct on inputs nobody showed you. An elegant solution that does not compile scores below a plain one that does, so write a correct brute force first, say out loud that you know its cost, and improve it with the working version still on screen. What separates strong answers is who finds the broken case. Trace your own code against an empty input, a single element, and duplicate keys before you say you are finished, because being told is far more expensive than noticing.
What to demonstrate
- Whether degenerate inputs get checked without being asked for: an empty collection, one element, every element equal, and the extreme value the input type allows
- Whether the complexity you state matches the code you actually wrote, including a sort or a copy sitting inside a loop
- Whether the finished answer is verified against the worked examples before you call it done, rather than assumed correct because the code reads correctly
How to prepare
- Take five problems you have already solved and, without running anything, write down what each returns for empty input, a single element, and all-duplicates. Then run them and count how many you predicted wrong.
- Drill the brute force as its own skill: on ten problems, write only the obviously-correct slow version and time how long it takes to get it passing. If that is more than a few minutes, that is what to practise, not the optimal version.
- Add a fixed last step before you submit anything, reading only the loop bounds and the initial value of each accumulator, which is where most off-by-one errors live
Technical Discussions
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
Behavioral Discussions
What you say here is written down by each interviewer and compared afterwards, so the unit of evaluation is a claim someone else could check, not a well-told narrative. Two things make a story checkable: detail only a participant would hold, and a clean line around which part was yours. Vague ownership is the usual failure and it is usually accidental, because engineers say we about the team's work and we about their own, so the thing they personally built disappears into the plural. Name the part you wrote, and name who did the rest.
What to demonstrate
- Whether your details are ones a participant would hold and an observer would not: the constraint that ruled out the obvious approach, the first attempt that failed, the person who objected and on what grounds
- Whether ownership survives a direct question, since a follow-up to we decided is routinely who decided, and an answer that stays plural at that point is read as the work belonging to someone else
- Whether the numbers you quote are ones you would say identically to a former colleague with the dashboard open
How to prepare
- Go through each story replacing every we with either I or a named role (the on-call engineer, the reviewer, the other team) and check the story still holds together. Wherever it stops making sense you have found a part you cannot actually speak to
- Open the artefacts for two of your stories, the pull request, the design doc, the incident notes, and read them for dates and figures you have been rounding in the retelling. Correct your version to match
- For each story write the single sentence you would least want repeated to a former teammate, then either make it accurate or take it out
Final Team Assessments
Coding rounds mostly set a floor. They decide whether you clear the bar, not where you land on the ladder. Level tends to come out of the design discussion and the ownership stories, so the question worth auditing beforehand is whether the scope you describe matches the scope of the job. Work that stops at your own service, or a story whose hard part was writing the code rather than getting several people to agree on an interface, reads a level below where you think you are interviewing, and that gap is usually resolved downwards.
What to demonstrate
- Whether the largest thing you describe owning ran end to end — the decision, the migration path, the rollout, and what you did when it went wrong — or stopped at the change you merged
- Whether design answers include what you would not build, what you would defer, and what you would measure before committing, rather than only what the boxes are
- Whether a disagreement in a story was settled with something checkable — a benchmark, a prototype, a written proposal — instead of by seniority or by waiting it out
- Whether you can say which calls you made alone and which you escalated, and why the line sat where it did
How to prepare
- Write your largest piece of owned work as a timeline of decisions — who decided what, when, and what you did when the plan broke — then delete every sentence whose subject is "we" and see how much survives
- Take one system you know well and drill the migration answer: how old and new paths run side by side under live traffic, how you compare their outputs, what the rollback is once writes are going to both, and which step you would not automate
- Map each line of the ladder in the job posting to a specific thing you have done, find the line you cannot support, and prepare the closest evidence you have plus an honest account of the gap
Where candidates lose points
Validating against a client sandbox and assuming production parity
Sandboxes typically carry smaller data, looser or absent rate limits, a schema version behind production, and sometimes synchronous behaviour where production is asynchronous. Code that passes there fails first in the client's production, during a change window you do not control and often cannot get a second one of. The mitigations are specific: assert the observed schema version at the boundary of every run, measure the production rate limit empirically rather than reading it from a document, and design the first production run to be a bounded, reversible slice rather than a full backfill.
Long-lived shared credentials for client systems
One static key used by the connector runtime, a debugging script and three engineers cannot be attributed to a person in an audit, cannot be expired at engagement close, and cannot be rotated without breaking an unknown number of consumers at once. The expensive part of the eventual incident is not the leak; it is that rotation requires finding every holder under time pressure, and nothing recorded who they were. Issue per-principal, per-target, short-lived grants from a broker so rotation is a no-op, attribution is automatic, and closure is a TTL rather than a search.
Sharing mutable state with no stated owner
Say which thread, request or task owns each mutable structure, and what protects it when the answer is more than one: a lock, a queue that hands ownership across, or an immutable copy per reader. A structure documented as safe for concurrent reads is usually not safe for a concurrent write alongside those reads.
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.
Questions & practice
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?
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?
Measure credential exposure past engagement close without double counting
Medium · interval merge · sweep line · credential ttl
For one engagement you have credential_grant rows: (grant_id, principal_id, target_system_id, issued_at, not_after, revoked_at which may be NULL). The cutoff instant T is the engagement's ends_on plus access_grace_days. A grant is live over the half-open interval [issued_at, min(not_after, revoked_at)). There are up to 200,000 grants across target systems. For each target_system_id, return the total wall-clock time after T during which at least one grant was live, plus the disjoint segments that make it up. Grants that overlap must be counted once.
Approach
- Clip each grant to [max(issued_at, T), min(not_after, coalesce(revoked_at, +inf))) and discard any interval whose start is not strictly before its end. O(n), and it removes every grant that already expired inside the engagement window.
- Bucket the survivors by target_system_id and sort each bucket by start. O(n log n) overall, and sorting dominates the whole algorithm.
- Sweep each bucket once, holding one open segment [s,e): if the next start is greater than e, emit [s,e) and open a new segment, otherwise set e = max(e, next_end). O(n) after the sort, O(1) working state, O(number of emitted segments) output.
- Total exposure is the sum of emitted segment lengths, which is the measure of the union. Summing per-grant durations instead answers a different question and is unbounded above by the elapsed wall clock.
- Produce a second, conservative figure that ignores revoked_at and uses not_after alone. revoked_at records that you asked for revocation; whether the client's identity provider honoured it is not something this table knows, and a self-contained token validated offline stays valid to its own expiry regardless.
- If a bucket does not fit in memory, sort externally and sweep the stream, or push end timestamps into a min-heap keyed by end and pop those below the current start. Same O(n log n), memory proportional to the maximum number of concurrently live grants.
Follow-up
- Three grants overlap and one of them belongs to an offboarded contractor. What must the sweep emit so exposure can be attributed per principal?
- Which of your two numbers is the real bound on access if the client system validates tokens offline, and what does that imply about issuing TTLs in the first place?
- Run this across 20,000 engagements as a nightly job with a fixed memory budget. What changes in the shape of the computation?
Denormalise the entitlement snapshot without freezing the clock
Medium · denormalisation · read patterns · caching
Authorisation currently joins engagement (effective-dated, with status, ends_on, access_grace_days, effective_from, effective_to), entitlement and principal_assignment on every request, at the platform's full read rate, against tables taking a few hundred writes a day. You are asked to publish a denormalised snapshot that callers cache with a few seconds of staleness and invalidate on change events. Specify the snapshot's key and shape, say which columns you denormalise, name the one thing that must not be precomputed, and say what a cache miss does.
Approach
- Justify the shape from the ratio rather than from taste: a few hundred writes a day against the whole estate's read rate is the case for a published read model. The normalised tables stay the system of record; the snapshot is derived, versioned and disposable, and can be rebuilt from source at any time.
- Key it on (client_id, principal_id) with a monotonic snapshot_version and a generated_at. Consumers cache by version, refresh on a change event, and reject anything older than the staleness bound, which turns the bound into an enforced property instead of an assumption about how fast events usually arrive.
- Denormalise only what changes on a write: the open engagement version's engagement_id, status, isolation_tier, data_region, ends_on, access_grace_days, and the resolved scope set. Resolving the effective-dated row once at publish time — selecting the version WHERE effective_to IS NULL — is the entire saving, because that resolution is what the per-request join was paying for.
- Do not precompute is_active or any boolean that folds now() into the stored value. A write event invalidates the snapshot; the passage of time does not, so an engagement that ended an hour ago keeps authorising until something unrelated triggers a republish. Ship ends_on and access_grace_days as data and compare them to the current time at read, so time-based expiry needs no invalidation at all.
- Fail closed on a miss or on over-staleness: deny, and do not fall through to the source tables. A fallback read path means the first mass cache miss converts an availability incident into either an authorisation bypass or a stampede onto the one service whose availability target already exceeds everyone else's.
- State the cost you accepted: a revoked entitlement is still honoured for up to the staleness bound. That is defensible for widening access and not for narrowing it, so pair it with an out-of-band deny signal for revocation and closure, and write the residual window into the runbook instead of leaving it for an auditor to discover.
Follow-up
- How do you detect the snapshot quietly diverging from the source tables, given that a wrong snapshot is silently permissive?
- An amendment creates a new engagement version mid-request. Which version does the in-flight request use, and can you defend that?
- The snapshot carries data_region. What stops the snapshot itself from being replicated somewhere its own residency rule forbids?
Fix a billing report that double-counts through a join
Medium · join fan-out · aggregation · reporting
A monthly report joins engagement to work_record (engagement_id, principal_id, work_date, minutes, is_billable, status) and to invoice_line (engagement_id, period_start, period_end, amount_minor, line_type, status) in a single FROM clause, then aggregates sum(minutes), count(DISTINCT principal_id) and sum(amount_minor) grouped by engagement. Finance reports the minutes are roughly triple the timesheets while the headcount column looks correct. Explain the arithmetic, then write the corrected query for one client and one period, returning zero rather than NULL for engagements with no work.
Approach
- Name the arithmetic before touching SQL: joining two independent one-to-many children of the same parent produces the Cartesian product per engagement, |W| x |L| rows. sum(minutes) is therefore multiplied by the invoice-line count and sum(amount_minor) by the work-record count. Three lines per engagement — fees, expense, credit note — is exactly the factor of three finance reported.
- Explain why the headcount column looked fine: count(DISTINCT principal_id) collapses the duplication, so it is correct by accident. That is precisely why the bug survived review, and it is the reason a column agreeing with expectations is not evidence that a join is at the right grain.
- Pre-aggregate each branch to the join grain first: one subquery per fact table, grouped by engagement_id, each producing at most one row per engagement. Join those results, which are now one-to-one, so no multiplication is possible by construction rather than by care.
- Drive the outer query from engagement with LEFT JOINs and wrap each aggregate in COALESCE, because sum over zero rows is NULL rather than 0. For counts, count the key column and not count(*), which returns 1 for a non-matching LEFT JOIN row.
- Keep credit notes inside the amount sum deliberately: amount_minor is negative for line_type = 'credit_note', so filtering them out overstates what was billed. Exclude status = 'void' instead, and state that choice in the query as a comment because the next reader will assume the opposite.
- Compare cost: the fan-out plan materialises |W| x |L| intermediate rows before aggregating, while pre-aggregation reads each table once under its own index and hash-joins two small results. The base-table I/O is identical; the intermediate work differs by orders of magnitude on a large engagement.
Worked solution (25 min)
- Build a fixture: one engagement with two work records of 60 and 90 minutes and three invoice lines.
- Run the broken join and confirm sum(minutes) returns 450 rather than 150, and that count(DISTINCT principal_id) is unaffected.
- Rewrite with one aggregating CTE per fact table and LEFT JOIN both onto engagement.
- Add an engagement with invoice lines but no work records and confirm it returns 0 rather than NULL or a missing row.
Expected result: WITH w AS (SELECT engagement_id, sum(minutes) AS billable_minutes, count(DISTINCT principal_id) AS people FROM work_record WHERE work_date >= $2 AND work_date < $3 AND is_billable AND status IN ('approved','invoiced') GROUP BY engagement_id), l AS (SELECT engagement_id, sum(amount_minor) AS billed_minor FROM invoice_line WHERE period_start >= $2 AND period_end <= $3 AND status <> 'void' GROUP BY engagement_id) SELECT e.engagement_id, COALESCE(w.billable_minutes, 0) AS billable_minutes, COALESCE(w.people, 0) AS people, COALESCE(l.billed_minor, 0) AS billed_minor FROM engagement e LEFT JOIN w USING (engagement_id) LEFT JOIN l USING (engagement_id) WHERE e.client_id = $1 AND e.effective_to IS NULL ORDER BY e.engagement_id;
Verify it yourself
- On the fixture the corrected query returns 150 minutes where the broken one returned 450.
- Totals reconcile against SELECT sum(minutes) FROM work_record with the same predicates run on its own.
- An engagement with no work records appears once with zeros, not absent and not NULL.
- Adding a fourth invoice line changes billed_minor and leaves billable_minutes untouched.
Follow-up
- The report now needs a per-principal breakdown alongside the per-engagement totals. Does your shape survive, or do you need a different grain entirely?
- How do you stop the next analyst reintroducing this — a view, a naming convention, or a test that fails on a fixture with two children?
- Why does sum(DISTINCT minutes) not fix it, and what does it actually compute?
Describe a situation where you had to debug a critical issue in a production environment.
Medium
Approach
- Clarify what is being asked and what a complete answer contains.
- State your assumptions explicitly before working the problem.
- 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 manage the risks associated with replacing a legacy system?
Medium
Approach
- State your assumptions explicitly before working the problem.
- Work from the requirement backwards to the design.
- Clarify what is being asked and what a complete answer contains.
Follow-up
- How would you know your answer was wrong?
- What assumption would you test first?
How do you approach the migration of a legacy application to a cloud-native environment?
Medium
Approach
- Work from the requirement backwards to the design.
- Say what you would check first and why it is the highest-information step.
- State your assumptions explicitly before working the problem.
Follow-up
- What assumption would you test first?
- How would you know your answer was wrong?
Design a run API where the outcome is genuinely unknown
Hard · idempotency · state-machines · ambiguity · at-least-once
connector_run holds run_id, connector_id, environment_id, idempotency_key, attempt_no, status, failure_class, watermark_from, watermark_to, records_read, records_applied, source_schema_version, started_at and finished_at; idempotency_key is derived from the connector and the watermark range, and (connector_id, idempotency_key, attempt_no) is unique. Workers claim a range, write to a client-owned target that offers no idempotency support of its own, then report back. A worker can die at any point, and a write can time out leaving the target's state unknown. Design the claim, report and settle endpoints and the status transitions a scheduler and an operator drive.
Approach
- Derive the run's identity from the work rather than from the caller. POST /connectors/{id}/runs carrying a watermark range computes idempotency_key deterministically, so a scheduler retrying a claim receives the same logical run instead of a second one covering the same rows. Mint a new attempt_no only on an explicit retry of an attempt that has already settled as failed, which keeps the attempt history readable and stops a crashed worker from quietly opening a parallel attempt over the same range.
- Give the claim a lease with an expiry rather than a lock. The worker heartbeats; an expired lease makes the run reclaimable. Without an expiry, a dead worker wedges the binding until someone notices. With a lease and no fencing, two workers can both believe they hold the range, so carry a monotonically increasing lease token and reject any settle whose token is stale — that is what stops a zombie's late report overwriting the live attempt's.
- Make
ambiguousa first-class status with its own transitions, separate fromfailed. A timeout, or a reset after the request bytes went out, leaves the target's state unknown: settling as failed and retrying duplicates, settling as succeeded and advancing the watermark loses data silently. The settle endpoint therefore accepts succeeded, failed with a class, or ambiguous with evidence, and only succeeded advances the watermark. - Keep the ledger write and the watermark advance in one local transaction, and have settle carry the dedupe keys actually applied. The target write and your commit cannot be made atomic without a distributed transaction no client will grant, so ambiguity is resolved by a reconciliation read: re-read the target by dedupe key over the claimed range and settle from what you find. Store the key's input fields and a derivation version alongside the hash, because the client will eventually rename or retype one of those fields and every previously computed key still has to be reproducible.
- Define each caller's behaviour precisely. The scheduler re-claims expired leases and retries only definite failures whose class is retryable; it never retries an ambiguous run. The operator has an explicit resolve endpoint used after reconciliation, recording who decided and on what evidence. An ambiguous run that auto-retries is the design bug — it converts an unknown into a duplicate inside a system you do not own and cannot correct.
- Close the loop with the counters. records_applied differs from records_read by the dedupe hit count, so a retry over a partially applied range should report a high hit count and little net change. That difference is the observable proving the dedupe key works: if a retry reports applied equal to read, the key is not matching and you are duplicating on the client's side right now.
Worked solution (40 min)
- Write the state machine — queued, running, succeeded, failed, ambiguous, cancelled, abandoned — with every legal transition and who triggers it.
- Write the claim endpoint's request and response, showing how idempotency_key is derived and what a repeated claim returns.
- Inject a crash between each adjacent pair of claim, fetch credential, validate, write, ledger, advance, and say what the next claim of that range does in each case.
- Write the settle payload for the ambiguous case, listing the evidence a later reconciliation will need.
- Trace a zombie worker settling ninety seconds after its lease expired, and show the token check rejecting it.
- Write the operator's resolve endpoint and the audit fields it records.
Expected result: A claim keyed on (connector_id, watermark range) that is safe to repeat, a leased and heartbeat-renewed claim with a fencing token checked at settle, a settle endpoint accepting succeeded, failed-with-class or ambiguous-with-evidence where only succeeded advances the watermark, a reconciliation path that resolves ambiguity by reading the target back by dedupe key, and an operator resolve step that never happens automatically.
Verify it yourself
- Retrying a claim for the same range never produces two runs with different idempotency_keys.
- A run left ambiguous stays ambiguous until something reads the target; nothing in the scheduler moves it.
- Replaying a fully applied range reports records_applied of zero with a dedupe hit count equal to records_read.
Follow-up
- The client's endpoint offers no read-back by your dedupe key. How do you settle an ambiguous run then?
- A worker's lease expires while its write is still in flight and a second worker claims the range. What exactly prevents the double apply?
- How long do you keep the dedupe ledger, and what happens when a record arrives after its ledger row was pruned?
Timesheet writes freeze during a month-end migration window
Medium · lock contention · ddl · migrations
At 23:40 on the last day of the month, every query touching work_record starts timing out: submissions, approvals and the billing generator. Database CPU is near zero and nothing is doing work. pg_stat_activity shows 60 backends with wait_event_type = 'Lock', an ALTER TABLE work_record ADD COLUMN issued at 23:38, and an analytics SELECT over work_record that started at 23:26 and is still running. Explain the mechanism, state precisely what you cancel first and why, and give the permanent guard.
Approach
- Read the wait states before changing anything. Grouping pg_stat_activity by wait_event_type and seeing 60 backends in 'Lock' with idle CPU says queueing, not load: nothing is slow, everything is waiting, and no amount of capacity helps.
- Walk the chain to its root with pg_blocking_pids(pid) rather than inferring from start times. You will find ordinary reads and writes blocked by the ALTER, and the ALTER blocked by the analytics SELECT.
- State the mechanism exactly. ADD COLUMN takes ACCESS EXCLUSIVE, which conflicts with the ACCESS SHARE the running SELECT holds, so the DDL waits. Lock requests queue, so every request that arrives afterwards queues behind the pending ACCESS EXCLUSIVE, including plain SELECTs that would never have conflicted with the analytics query. One waiting DDL statement freezes the table without ever executing a byte of work.
- Act in the correct order: cancel the DDL first with pg_cancel_backend on that backend. The queue drains immediately and the writes behind it proceed while the analytics query finishes harmlessly. Cancelling the analytics query instead lets the ALTER take the lock and rewrite or at minimum lock the table during the busiest write window of the month, which is worse than the outage you are in.
- Guard permanently at the migration session: SET lock_timeout to a couple of seconds so a DDL that cannot take its lock fails fast instead of queueing the world behind it, and retry the migration later rather than waiting. Know the version-specific escapes too: from PostgreSQL 11, ADD COLUMN with a non-volatile DEFAULT avoids the table rewrite but still requires ACCESS EXCLUSIVE, so no rewrite is not no lock; and where validation needs a scan, ADD CONSTRAINT ... NOT VALID followed by VALIDATE CONSTRAINT keeps the long phase at SHARE UPDATE EXCLUSIVE.
- Cap the other side as well: statement_timeout on the reporting role, so a long read can never become the head of an estate-wide stall, and keep DDL out of the month-end billing burst entirely.
Follow-up
- The migration has to ship this week and month-end runs for three days. What sequence gets it in without a quiet window?
- Now you need a NOT NULL column with a check constraint on the same table. Which step takes which lock, and for roughly how long?
- What signal would have paged you within 30 seconds of 23:38, given that CPU, error rate and query duration all looked normal at first?
Suggested practice checklist
For someone fluent in a dynamic language who has shipped real work but has never had to say what the runtime is doing underneath. The week is built on measuring and deliberately breaking things, because the questions that expose this background are the ones where the interviewer asks why a second time.
Day 1 — Measure before reasoning
- Take a slow piece of your own code, write down in advance where you believe the time goes, then profile it and record how wrong the guess was. The cost is usually an allocation you did not notice or an accidental quadratic membership test.
- Replace one list membership test inside a loop with a set and measure at a thousand, ten thousand and a hundred thousand elements, confirming the shape of the curve rather than only that it got faster.
- Write down the three quantities you can now measure instead of assert: wall time, peak memory, and call count for the function you suspected.
Deliverable: A before-and-after profile of real code plus a written note on the size of the gap between the guess and the measurement.
Day 2 — References, copies, and the bugs they produce
- Write the function with a mutable default argument, call it three times, and explain the accumulating result: the default is evaluated once when the function is defined, so every call shares one object.
- Build a nested structure, take a shallow copy, mutate an inner element, and show that both views changed, because a shallow copy duplicates the container and not the elements. Then fix it with a deep copy and state the cost you just accepted.
- Write two functions, one mutating its argument in place and one rebinding the local name, and predict the caller's view of each before running it. That single distinction produces most of the bugs that pass their tests.
Deliverable: Three small programs whose output you predicted correctly before running, each with a one-line statement of the rule underneath.
Day 3 — Types, once, in a language that checks them
- Port one module you have already written, roughly a hundred lines, into a statically typed language, and record every place the compiler demanded an answer your original had left implicit: a value that can be absent, a numeric width, a case never handled.
- Write the same signature in both languages and state what the static one guarantees before the program runs and what it does not, since it will not save you from a wrong algorithm or an index out of range.
- Write the difference between an interface satisfied by declaration and one satisfied structurally, with one case each where the other approach would miss the mistake.
Deliverable: One module in two languages plus a list of the questions the type checker forced you to answer.
Day 4 — Concurrency, starting with what actually runs at the same time
- Run the same CPU-bound function across four threads and four processes and measure both. Under the default CPython build the threaded version will not speed up, because only one thread executes bytecode at a time; the process version will. Check which build you are on first, since free-threaded builds remove that lock and change the result.
- Then run a blocking I/O workload across four threads and measure it speeding up, because the interpreter releases that lock around blocking calls, which is why treating threads as useless is wrong as a general claim.
- Build the lost update: two threads each incrementing a shared counter a hundred thousand times, and show a final value below the expected sum, because an increment is a load, an add and a store and the thread can be suspended between them. Fix it with a lock and then measure what the lock costs.
Deliverable: Three measurements, threads against processes on CPU work, threads on I/O work, and a demonstrated lost update, each with the mechanism written underneath.
Day 5 — Debugging as a procedure rather than an instinct
- Work one real failure as a bisection: find a revision or an input size where it is good and one where it is bad, halve repeatedly, and state the two assumptions bisection needs, that the property changes exactly once across the range and that the test is reliable.
- Minimise one failing input to the smallest version that still fails, and record how many rounds it took.
- Keep a hypothesis log for one bug in three columns, what I believe, what would disprove it, what I observed, and stop yourself the first time you are about to change two things at once.
Deliverable: One bug worked to root cause with a written hypothesis log and a minimised reproducing input.
Day 6 — Tests that catch the bug you are about to write
- Implement an LRU cache with a capacity bound, then write the three test cases that would catch an off-by-one in eviction: insert exactly capacity items and assert nothing was evicted, insert one more and assert the least recently used key is the one gone, and read an old key just before that insert so the eviction victim changes.
- Add a property test comparing your implementation against a deliberately slow reference, an ordered list scanned linearly, over a few thousand random operation sequences, because a slow reference finds the cases you would not have thought to write.
- Write one numeric test that fails under exact equality and passes with a tolerance, and state why the tolerance has to be relative rather than absolute once the magnitudes grow.
Deliverable: An LRU implementation with three boundary tests, one property test against a slow reference, and one tolerance-based numeric test.
Day 7 — Debug something broken, out loud
- Have someone plant three defects in a two-hundred-line program, an off-by-one, a shared mutable state bug, and a wrong error-handling path, then find them while narrating, under a fixed rule: state the hypothesis before touching anything.
- Time each one and record which tool found it, reading, a printed value, a debugger, or a test, because the question asked in interviews is how you would find it rather than what it was.
- Write the sentence you will use when you do not yet know the cause, one that names the next measurement instead of offering a guess.
Deliverable: A recorded debugging session with time-to-find per defect and the method that found each.
Disagreement needs an outcome you did not choose
Conflict answers where you were right and everyone came round are the weakest ones. Stronger: the evidence you went and collected, what would have changed your mind, and what you did in the weeks after the call went against you. Implementing a design you argued against, properly, is a specific and checkable behaviour.
Tell me about a time you had to resolve a conflict within your development team.
Medium · behavioural and engineering judgement
Approach
- Pick a story where you made the decision, not one where you watched it.
- Name the disagreement and how you resolved it with evidence.
- Give the blast radius: what could have broken, and what you measured.
Follow-up
- What would you do differently if you ran that again?
- How did you know your change caused the improvement?
Describe a time you had to explain a complex technical issue to a non-technical stakeholder.
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.
- Name the disagreement and how you resolved it with evidence.
Follow-up
- How did you know your change caused the improvement?
- What would you do differently if you ran that again?
How do you handle ambiguity when a client provides vague project requirements?
Medium · behavioural and engineering judgement
Approach
- Give the blast radius: what could have broken, and what you measured.
- Close with what you would do differently, concretely.
- 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?