cswork / libraryOpen practice workspace

Interview guides · Good Energy

Good Energy Software Engineer Interview Questions & Guide 2026

A prep plan for Good Energy software engineer candidates: answer first, read the solution second, repeat the misses later.

The 60-second brief

The Software Engineer role at Good Energy is central to the company’s mission of driving the transition to a greener, decentralized energy system. You will be responsible for building and maintaining the digital infrastructure that empowers customers to engage with renewable energy services. Your work directly impacts how energy data is processed, how products are delivered to users, and how the company scales its technical operations in a competitive, mission-driven market.

Prepare in one language you know well enough to debug in rather than the one you think reads best. Under a clock an unfamiliar language costs you standard-library lookups and iteration mechanics, and that time comes out of your thinking budget, not your typing budget.

Focus areas

  • Reserve inventory without overselling under concurrent allocation
  • Reconcile a movement ledger against derived positions
  • Fold late, out-of-order scan events by event time

Overview

This role requires a balance of technical precision and collaborative problem-solving. You will contribute to a team that values high-quality, maintainable code and modern development practices. Because Good Energy operates in a specialized sector, you will face complex challenges regarding data integrity, system reliability, and user-centric design. It is an environment where your ability to translate business goals into robust software solutions will be tested and highly valued.

Expect to work within a close-knit engineering culture where technical alignment is prioritized. You will not only be writing code but also participating in architectural discussions, mentoring peers, and ensuring that the software you build is resilient and aligned with the company’s long-term sustainability goals.

The Good Energy interview process

Good Energy candidates report 3 rounds · ≈ 3-5 weeks. The stages below are what candidates describe, not a published process.

Initial Conversation

Because the format is not fixed, the first job in the room is classification. Listen to the opening question and decide what it is: a probe into work you have already described, a fresh problem to solve now, or a conversation about how you operate. Each wants a different register, and the common failure is forcing a rehearsed structure onto a question that did not ask for it. Running a full design ritual on a ten-minute debugging question reads as not listening. When you cannot tell which it is, ask how long they want to spend and answer at that depth.

What to demonstrate

  • Whether the shape of your answer matches the question, so a yes-or-no gets answered before it is justified and an open prompt gets a direction before a detour
  • Whether you check how much depth is wanted instead of deciding for them, and whether you stop when the answer is complete rather than continuing until someone interrupts
  • Whether you can be redirected in the middle of an answer without restarting it from the beginning
  • Whether a question outside your experience gets an honest boundary followed by reasoning from what you do know, instead of a confident answer with nothing behind it

How to prepare

  • Rehearse one project at three lengths, roughly thirty seconds, three minutes, and a full walkthrough at the depth of a design review, and practise switching between them when someone interrupts mid-telling
  • Have someone ask you five questions of deliberately mixed type in one sitting without telling you the types, and score only whether you identified each one correctly before you started answering
  • Draft the sentence you will use to check depth, along the lines of asking whether the short version is useful here or they want the detail, and use it in a real conversation this week so the day of the round is not its first outing

Technical Evaluation

Input bounds are the part of the prompt most often skimmed, and they usually contain the answer. They tell you which complexity class is admissible, which narrows the search before you have thought about the problem itself. As a rough planning figure, a compiled language does on the order of 10^8 simple operations per second and an interpreted one roughly an order of magnitude less. So n up to about twenty admits enumerating subsets, a few thousand admits a quadratic pass, and a million admits neither: you need near-linear, or linear with a log factor. If the bounds are missing, ask for them.

What to demonstrate

  • Whether the approach is justified by the stated input size rather than by whichever pattern you recognised first
  • Whether you ask about the properties that change the algorithm: whether the input arrives sorted, whether duplicates occur, whether values are bounded integers, whether it all fits in memory
  • Whether you can name the bottleneck in your own solution and what would remove it, even when you deliberately leave it in place
  • Whether a claimed speedup is real, since memoising a recursion only helps when subproblems genuinely overlap and the state can be keyed cheaply

How to prepare

  • For each algorithm you rely on, write down the largest n it handles in roughly a second, then check two of those figures by timing them in the language you will actually type in
  • For two weeks, write one line naming your target complexity and the bound that justifies it before you write any code, then compare that line with what you ended up submitting
  • Practise the conversion backwards: given a required O(n log n), list the mechanisms that get you there (sorting, a heap, an ordered map, divide and conquer) and choose by what the problem needs to query, not by what you used last

Final Stage Review

Nobody in the room with you decides this. Interviewers typically write their rounds up separately, often before seeing anyone else's, and the outcome is settled later from those write-ups. A split panel gets resolved by whichever note carries specific evidence, so what you want out of each room is one concrete thing that person could write down: a bug you caught yourself, a trade-off you named, a decision you owned. The rest is arithmetic. The project you describe in a behavioural conversation is often the same system you sketched an hour earlier, and the two accounts have to agree.

What to demonstrate

  • Whether the scale, team size and timeline you attach to a project hold steady when that project resurfaces in a different round
  • Whether each interviewer leaves with a specific thing to cite rather than a general impression of competence
  • Whether a trade-off you defended in one round survives a challenge in another, instead of being quietly swapped for the answer the new interviewer seemed to want
  • Whether a question you have already answered earlier in the day gets the same answer at the same depth, without visible impatience

How to prepare

  • Write a one-page sheet per project fixing the figures you will quote — request volume, data size, team size, elapsed time, what broke — and say them aloud from the sheet until they come out identical every time
  • For each round on the schedule, decide in advance the one sentence you want in that person's notes, then check in a mock that you said it outright instead of leaving it to be inferred
  • Have someone ask you the same project question twice, an hour apart, and diff the two answers for numbers that moved or a trade-off that reversed

Where candidates lose points

Judging delivery windows and cut-offs in UTC

A delivery appointment, a receiving cut-off and a same-day promise are all statements about local wall-clock time at a specific stop, so evaluating them in UTC misclassifies on-time performance for any leg that crosses a zone, and misses cut-offs by an hour on the two days a year a region changes offset. The subtlety that catches experienced engineers is that converting a future local time to a UTC instant at write time bakes in the offset rules as they were known then: when a jurisdiction changes its rules, an appointment stored that way silently moves, which is why a future window is better stored as local wall time plus an IANA zone identifier and resolved at read time, while a past event is correctly stored as an instant. Duration arithmetic needs the same care, because a transit that spans a DST boundary is genuinely 23 or 25 hours long in local terms even though the elapsed instant count is unchanged. Storing only a date with no zone - common on partner feeds - is the version of this that cannot be fixed downstream, so the zone has to be captured at ingestion.

Treating the database as the truth about physical stock

Every system in this domain drifts from reality, through miscounts, unrecorded damage, mis-scans and theft, so a design with no adjustment path forces the drift somewhere worse. The usual shape is a CHECK constraint or application guard that clamps a position at zero: the negative that would have exposed a missing receipt is silently swallowed, and the first visible symptom is a picker standing at an empty location for an order the system promised. The correct posture is to let the invariant fail loudly - record the cycle-count adjustment as a first-class movement with its own reason code, surface the variance, and let planning see that this location is unreliable - because an adjustment that is indistinguishable from a normal pick destroys the only signal that would have found the root cause. A related version of the same mistake is deleting or updating a movement row to fix a mistake, which leaves the position correct and the history a lie.

Assuming the bug is in the framework

Suspect your own code first: read the stack trace top to bottom, check which versions are actually installed rather than which ones you believe are, and reproduce in isolation before blaming a library that thousands of people run daily. When the fault really is upstream, you need that minimal reproduction to say so credibly anyway.

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.

Questions & practice

Discussion on technical challenges you have faced in previous roles.

Medium · data structures and algorithms

Approach

  1. Name the brute-force solution and its complexity before improving on it.
  2. Choose the data structure from the access pattern, not from familiarity.
  3. State the target complexity and say which constraint rules the naive version out.

Follow-up

  • How does this change if the input no longer fits in memory?
  • Which test case would catch an off-by-one here?

How do you balance code quality (e.g., unit testing) with project time constraints?

Medium · data structures and algorithms

Approach

  1. Restate the input: its shape, its size, and what is guaranteed about it.
  2. Walk one small example through your approach before writing the whole thing.
  3. Name the brute-force solution and its complexity before improving on it.

Follow-up

  • Which test case would catch an off-by-one here?
  • How does this change if the input no longer fits in memory?

Questions regarding your technical test: why you chose a certain approach and what alternatives were considered.

Medium · data structures and algorithms

Approach

  1. State the target complexity and say which constraint rules the naive version out.
  2. Choose the data structure from the access pattern, not from familiarity.
  3. Walk one small example through your approach before writing the whole thing.

Follow-up

  • How does this change if the input no longer fits in memory?
  • Which test case would catch an off-by-one here?

Split an order across the fewest nodes that can fill it

Hard · set cover · bitmask dp · approximation

A fulfilment order has L lines, L up to 25. After filtering by capability, service time and stock, N candidate nodes remain; N is usually under 14 but can reach 200. Each node covers a known subset of the lines in full. Return a minimum-size set of nodes covering every line, tie-broken by total distance. Give an exact algorithm for the small case with its exact cost in operations and bytes, say precisely why enumerating node subsets stops working, and give what you ship when N is 200.

Approach

  1. Name it: this is minimum-cardinality set cover, which is NP-hard, so the useful move is choosing which parameter you are exponential in rather than hunting for a polynomial exact algorithm. Enumerating subsets of nodes is 2^N and correct — at N = 14 that is 16,384 subsets and finishes instantly, which is exactly why it survives review and then fails in production at N = 200, where 2^200 is about 1.6e60.
  2. Be exponential in L instead, because L is the parameter you bound. Index the DP by the set of lines still uncovered: dp[rem] is the cheapest way to cover the lines in rem, with dp[0] = (0, 0) and the value a (node_count, total_distance) pair compared lexicographically. Let b be the lowest set bit of rem; any cover must contain some node that stocks line b, so the transition ranges only over those nodes: dp[rem] = min over v with b in cover(v) of dp[rem & ~cover(v)] + (1, dist(v)). Worst case O(2^L * N) — at L = 25 and N = 14 that is 33,554,432 states times 14, about 4.7e8 transitions — and the lowest-bit restriction cuts the real branching factor to the nodes stocking one specific line.
  3. Quote the memory for what you actually store, not for a count array alone. Over 2^25 masks you need three dense arrays: uint8 node count (33.5 MB), uint32 total distance (134.2 MB, integer metres so the tie-break compares exactly and the array stays fixed-width), and uint8 chosen node (33.5 MB, since N <= 200 fits a byte) — about 201 MB, roughly six times the 33.5 MB that a bare count array would suggest. The same layout at L = 20 is 6.3 MB, which is why this is comfortable up to about 20 lines and needs a deliberate decision at 25.
  4. Reconstruction is free in this formulation and needs no parent-mask array: because every transition eliminates the lowest uncovered line, the predecessor of rem under the stored choice v is exactly rem & ~cover(v), so walk rem from the full mask down to 0 emitting choice[rem]. If the reachable state count is far below 2^L — coverage sets are usually highly correlated — a top-down memoized recursion over a hash map trades the 201 MB for roughly 40-60 bytes per visited state; measure the visited count first, because a hash map that ends up touching most masks is strictly worse than the dense arrays. Add a branch-and-bound cut of ceil(popcount(rem) / max_v popcount(cover(v) & rem)), which prunes long before the theoretical bound bites.
  5. When exact is out of reach, ship greedy with a stated guarantee: repeatedly take the node covering the most currently-uncovered lines, using bitset popcounts, O(N * ceil(L/64)) word operations per pick and at most min(N, L) picks. Greedy is within H(L) <= ln L + 1 of optimal (Chvatal), and no polynomial algorithm achieves (1 - o(1)) ln L unless P = NP (Dinur and Steurer, 2014) — so effort belongs in the pre-filter and the tie-break, not in a cleverer heuristic. At L = 25 the bound is H(25), about 3.82x worst case, while the realistic gap on correlated coverage sets is zero or one node.
  6. Close on the objective, because fewest nodes is not cheapest. Two shipments from distant nodes routinely cost more than three from near ones, and a split also costs a second box, a second carrier pickup and a worse customer experience. If cost is the real objective it is weighted set cover — greedy by cost divided by newly-covered lines, same logarithmic bound — and if a node can cover a line only partially, the line mask is no longer sufficient state, since you must track remaining quantity per line; that is the point at which you stop hand-rolling and either restrict splits or hand an MILP to a solver with a time budget.

Worked solution (40 min)

  1. Write cover(v) as an L-bit mask per node and confirm that the union of all masks equals the full mask, otherwise the order is unfillable and the answer is a backorder, not a cover.
  2. Implement the DP over uncovered masks with the lowest-uncovered-line transition, the (node_count, total_distance) value and the choice array, then measure resident memory and wall time at L = 20 and L = 25 against the quoted 6.3 MB and 201 MB.
  3. Implement greedy separately and run both on 10,000 random instances with L = 12, N = 10, comparing sizes.
  4. Construct the textbook instance where greedy is strictly worse than optimal — nested sets of geometrically decreasing size plus two disjoint halves — and confirm your greedy reproduces the gap.
  5. Add the distance tie-break to both and confirm the exact path returns the cheapest minimum-size cover, not merely a minimum-size one.

Expected result: The DP and greedy agree on the large majority of random instances; on the adversarial instance greedy returns more nodes than the DP, by a margin consistent with the H(L) bound; the DP at L = 25 holds about 201 MB across its three arrays and runs in seconds rather than milliseconds, with cache misses over that working set dominating the 4.7e8 transitions.

Verify it yourself

  • Measured resident memory at L = 25 is near the quoted 201 MB — if it comes out near 33 MB you have dropped either the distance component or the reconstruction array, and the reported figure is describing a program you did not write.
  • Every returned set actually covers the full mask — assert it on the result, not just on the transitions.
  • Reconstruction from choice alone reproduces a set whose node count and total distance equal dp[full], which is what proves the predecessor identity rem & ~cover(v) holds.
  • The DP's answer never exceeds greedy's on any instance.
  • Doubling N multiplies DP runtime by about two, while subset enumeration would square its work — measure it rather than asserting it.

Follow-up

  • The pre-filter is what keeps N small. What is in it, and what happens to your exact path the day someone loosens it?
  • One node can cover a line only partially. Show where the bitmask formulation breaks and what state replaces it.
  • You have 40 milliseconds inside a checkout call. Which of these runs there, and what runs asynchronously afterwards?

Write the reconciliation query that finds ledger and position variance

Easy · aggregation · outer joins · reconciliation

inventory_movement(movement_id, item_id, node_id, lot_id, state, delta_qty) is the ledger; inventory_position(item_id, node_id, lot_id, state, qty, version, last_movement_id, reconciled_through_movement_id) is its running sum, written in the same transaction as each movement. Write one query that reports every key whose stored qty disagrees with the sum of its movements, including keys present in only one of the two tables, bounded by a watermark movement_id you pass in. State what the job does with each variance, and how you avoid re-summing four billion rows every night.

Approach

  1. Aggregate once and join once: a CTE doing SELECT item_id, node_id, lot_id, state, SUM(delta_qty) AS ledger_qty FROM inventory_movement WHERE movement_id <= :w GROUP BY 1,2,3,4, then FULL OUTER JOIN inventory_position USING those four columns. One pass plus one hash aggregate, O(n) in movements scanned; a correlated subquery per position row instead costs one index scan per key and is orders of magnitude slower at 40 million keys.
  2. Use FULL OUTER JOIN, not LEFT. A key with movements and no position row is the signature of a lost position update, and a position row with no movements is the signature of a write that bypassed the ledger. Both disappear under a LEFT JOIN from either side.
  3. Compare with IS DISTINCT FROM and COALESCE the join keys out of both sides, so a missing row reads as a variance rather than as NULL = NULL evaluating to unknown and being filtered away.
  4. Run both sides in one snapshot: either a single statement, or a REPEATABLE READ transaction. Reading positions in one transaction and movements in another manufactures variances for every key written in between.
  5. Make it incremental with reconciled_through_movement_id: sum only movements in (reconciled_through, :w] and add that delta to the previously verified total. The catch is that sequence values are handed out before commit, so a movement whose id is below :w can become visible after the scan and would then never be re-summed. Set :w to a max(movement_id) observed a few minutes earlier, beyond the longest write transaction, and full-scan one partition per night on a rotation as a backstop.
  6. A variance is reported, not repaired. Rewriting qty to match the ledger hides the double-applied retry that caused it; only a physical count may produce a cycle_count_adjust movement, and the location should be flagged unreliable to planning until it is counted.

Worked solution (25 min)

  1. Write the aggregate CTE with the watermark predicate, then the FULL OUTER JOIN and the IS DISTINCT FROM filter, projecting COALESCE(m.item_id, p.item_id) and both quantities.
  2. Seed a fixture with four cases: agreeing key, disagreeing key, movements with no position row, position row with no movements.
  3. Run it and confirm exactly three rows come back, with the agreeing key absent.
  4. Rewrite the same comparison as an incremental sum from reconciled_through_movement_id and confirm it reports the same three keys on a second run after new movements land.

Expected result: A result set of (item_id, node_id, lot_id, state, ledger_qty, stored_qty, ledger_qty - stored_qty) containing one row per disagreeing key and both orphan cases, and zero rows on a consistent fixture.

Verify it yourself

  • Swap the FULL OUTER JOIN for a LEFT JOIN and confirm the movements-only orphan vanishes from the output.
  • Replace IS DISTINCT FROM with <> and confirm the rows with a NULL on one side stop being reported.
  • Insert a movement while the query runs and confirm the result does not change, proving both sides came from one snapshot.
  • Compare the incremental result against a full re-sum of the same partition and require an exact match.

Follow-up

  • The job finds 14 variances, all on one node, all negative. What do you look at first, and what would make you suspect the reconciliation job itself rather than the data?
  • How do you run this without the nightly scan competing with the ATP read path for buffer cache and I/O?
  • What does the query return for a key whose only movements are a receipt and its exact reversal, and is that row worth reporting?

Add a required unit column and uniqueness to a live ledger

Hard · online migration · lock timeouts · unique constraints

inventory_movement holds 4.2 billion rows on PostgreSQL 15 and takes about 2,000 inserts per second around the clock. Two changes are required with no downtime: add uom_code TEXT NOT NULL, whose truthful value must come from the conversion factors in force at each row's occurred_at rather than from today's item master; and make idempotency_key UNIQUE, although duplicates predating the retry fix already exist. Give the ordered steps, the lock each one takes, its expected duration and its rollback.

Approach

  1. ADD COLUMN with a non-volatile DEFAULT has been metadata-only since PostgreSQL 11, so no rewrite, but it still takes ACCESS EXCLUSIVE for an instant. That lock queues behind any long-running reader while blocking every statement behind it, so one 10-minute analytics scan turns a millisecond change into a 10-minute outage. Set lock_timeout to 2s and retry in a loop rather than waiting.
  2. Reach NOT NULL without a scan under an exclusive lock: ADD CONSTRAINT ... CHECK (uom_code IS NOT NULL) NOT VALID, which is instant, then VALIDATE CONSTRAINT, which takes only SHARE UPDATE EXCLUSIVE and runs concurrently with inserts, then SET NOT NULL, which PostgreSQL 12 and later proves from the validated check and so skips its own scan. Drop the redundant check afterwards.
  3. The truthful backfill is the long pole and must read the versioned conversion factor as of each row's occurred_at, not the current item master, or a supplier moving a case from 12 units to 10 retroactively reinterprets last quarter. Batch by primary-key range, a few thousand rows per transaction, with a sleep between batches and a resumable cursor in a control table. At 5,000 rows a second, 4.2 billion rows is roughly ten days of throttled work: that is a schedule, not a failure, and it must not block the deploy.
  4. The UNIQUE index cannot be built until the duplicates are gone. CREATE UNIQUE INDEX CONCURRENTLY aborts on the first violation and leaves an INVALID index behind that is ignored by queries but still carries update overhead on every insert, and it can only be removed with DROP INDEX CONCURRENTLY. So: find the duplicates, resolve each one as a compensating reversal rather than a DELETE, since every duplicate key is a double-applied movement and deleting it fixes the constraint while leaving the position wrong.
  5. Then CREATE UNIQUE INDEX CONCURRENTLY, which makes two passes over the table and waits out concurrent transactions, so budget hours here, and note it cannot run inside a transaction block: a migration framework that wraps every step in BEGIN will fail on this one. Promote it with ALTER TABLE ... ADD CONSTRAINT ... UNIQUE USING INDEX, which is a brief ACCESS EXCLUSIVE.
  6. Question whether the constraint should cover all history. At roughly 40 bytes per key plus overhead, a btree over 4.2 billion rows is in the hundreds of gigabytes, while a retry never arrives a year later. A partial unique index bounded by a fixed occurred_at literal, rotated forward on a schedule, gives the same protection for a fraction of the size, and the predicate must be a literal because now() is not immutable.

Follow-up

  • Sequence the application deploy against these steps. When exactly may the code start writing uom_code, and when may it start requiring it?
  • The backfill is on day six and the conversion-factor table is itself corrected. What happens to the rows already written?
  • How do you roll back after SET NOT NULL is in place and half the traffic depends on the column?

What are the different ways that services can be registered in a Dependency Injection (DI) framework?

Medium · system design and architecture

Approach

  1. Name the read and write paths separately; they rarely have the same bottleneck.
  2. Name the failure you are designing for, then the recovery path.
  3. 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?
  • How does this behave when that dependency is down for an hour?

Experience with different design patterns and their usage.

Medium · system design and architecture

Approach

  1. Fix the scope first: who calls this, how often, and what they do when it fails.
  2. Name the read and write paths separately; they rarely have the same bottleneck.
  3. Choose a partition key and say what query it makes expensive.

Follow-up

  • What breaks first when traffic grows ten times?
  • What would you drop to keep the system up under load?

How does MVVM/MVC/MVP apply to React?

Medium · system design and architecture

Approach

  1. Name the failure you are designing for, then the recovery path.
  2. State the consistency you need, and where you are willing to be stale.
  3. 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 do you know about Good Energy?

Medium

Approach

  1. Say what you would check first and why it is the highest-information step.
  2. State your assumptions explicitly before working the problem.
  3. Work from the requirement backwards to the design.

Follow-up

  • How would you know your answer was wrong?
  • What assumption would you test first?

Why are you interested in the renewable energy sector?

Medium

Approach

  1. Say what you would check first and why it is the highest-information step.
  2. Work from the requirement backwards to the design.
  3. State your assumptions explicitly before working the problem.

Follow-up

  • How would you know your answer was wrong?
  • What assumption would you test first?

Accept a reconnecting site's six hours of buffered movements

Medium · partial failure · batch api · at-least-once

A Warehouse Execution Service buffered movements locally through a six-hour network partition and now has 40,000 records to replay. Design POST /movements:batch. Cover the per-record shape (caller idempotency_key, client_sequence, item, node, state, delta_qty, uom_code, occurred_at), the size limits, the response when record 8,113 fails validation while the rest apply, and the acknowledgement the site uses to truncate its buffer. The site must lose nothing and apply nothing twice. State what it does on 429, on 413 and on a timeout.

Approach

  1. Put the idempotency at the record, not the batch. The unit of work is one movement, because a partially applied batch has to be resumable; echo the site's client_sequence back so the acknowledgement is positional and the buffer can be truncated precisely rather than approximately.
  2. Apply with one statement: INSERT ... ON CONFLICT (idempotency_key) DO NOTHING RETURNING idempotency_key, then derive per-record outcomes of applied, duplicate or rejected from what came back. That is O(n) in one round trip instead of n round trips, and it makes duplicate a success rather than an error - which it must be, since at-least-once delivery is the premise.
  3. Return 200 with a per-record array and keep business outcomes out of the transport status. One bad record must not make the response non-2xx, or the site re-sends 40,000 records whose individual fate it cannot determine. Avoid 207, which is defined for WebDAV in RFC 4918 and reaches general clients as an unrecognised 2xx.
  4. Bound the request: cap records and bytes, answer 413 with the limit in the body so the client can split deterministically, and use 429 with Retry-After for backpressure. Write the rule that the site retains every record until that record is individually acknowledged - a timeout is indistinguishable from success, so it re-sends and the keys absorb it.
  5. Decide ordering deliberately. Signed deltas commute for conservation, so movements need no global order, but state transitions and allocation consumption do not commute; either apply within an (item, node) group in client_sequence order or reject a record whose prerequisite is absent with a retryable code, and say which you chose and why.
  6. Finish with reconciliation rather than trust: after the replay, re-sum inventory_movement into inventory_position for the affected keys, compare against the running totals and raise variance instead of assuming the incremental path got it right.

Worked solution (35 min)

  1. Write the request and response JSON for a three-record batch where one is a duplicate and one is invalid.
  2. Write the insert statement and show how the RETURNING set plus the submitted set yields the three outcome classes.
  3. Pick concrete limits - records per batch, bytes per batch, expected wall time for 40,000 records - and derive the number of requests the replay takes.
  4. Write the client's state machine: send, await per-record ack, truncate acked prefix, retry the rest, honour Retry-After, never drop unacked records.
  5. Run the reconciliation query for the touched (item, node, lot, state) keys and decide what happens to a variance.

Expected result: Per-record keys and sequences, a single-statement apply, a 2xx carrying a per-record outcome array, explicit 413 and 429 semantics with the buffer retained until individual acknowledgement, and a reconciliation pass after the replay.

Verify it yourself

  • Re-sending the identical batch produces the same outcome array with every record marked duplicate and no position change.
  • A batch containing one invalid record still applies the other 999 and identifies the failure by client_sequence.
  • After the replay, re-summing the ledger for the affected keys matches inventory_position exactly, or reports a variance rather than silently correcting one.

Follow-up

  • Two sites reconnect at once and saturate the write path. What sheds first, and what must never shed?
  • A buffered pick conflicts with a cycle count taken during the partition. Which one wins, and what evidence survives?
  • How does the site learn that a record it sent three hours ago was rejected, and who owns it after that?

Allocation p99 hits nine seconds while database CPU stays flat

Medium · lock contention · connection pool · outbox

At 14:10 allocation p99 went from 30 ms to 9 s and an unrelated read service sharing the connection pool began failing. Database CPU and mean statement time are unchanged. pg_stat_activity shows 80 of 100 connections in state 'idle in transaction' with a mean transaction age of 18 s, and lock waits concentrated on about 30 inventory_position rows. In the same minute a carrier's rate-quote p99 went from 180 ms to 22 s. Give the ordered diagnosis, the immediate mitigation, and the structural fix.

Approach

  1. Ask whether the database is working or waiting, because the two look the same from the client. Flat CPU and flat mean statement time with most connections in 'idle in transaction' says the database is idle inside open transactions: the application has begun a transaction and gone off to do something else. That state name is the whole diagnosis and it points at application structure, not at the database.
  2. Tie the holders to the dependency rather than assuming the link. Compare the distribution of now() minus xact_start against the carrier's latency histogram in the same window; when the two track each other, the transaction is wrapping the external call. The 18 s mean transaction age against a 22 s partner p99 is that match.
  3. Locate the contention: join pg_locks to pg_stat_activity, group by the locked tuple, and confirm the waits concentrate on a few dozen hot (item_id, node_id) keys. Demand concentrates on those rows precisely during a promotion, which is also when partner latency degrades, so the two curves multiply rather than add.
  4. Mitigate without a deploy where possible. Set a call timeout on the partner shorter than the lock budget, set idle_in_transaction_session_timeout and lock_timeout so a stall sheds instead of queueing, and give the carrier gateway its own connection pool so one degraded partner cannot drain the pool an unrelated service depends on. A circuit breaker on that partner turns a 22 s wait into an immediate, handled failure.
  5. Fix the structure: take the external call out of the transaction. Write the allocation state change and an outbox row in one short local transaction, let a relay make the rating or tender call asynchronously with a persisted idempotency key, and advance the saga on the result. Lock hold time then equals local work, single-digit milliseconds, and is independent of any partner.
  6. Accept and handle what the outbox costs you. Publication becomes at-least-once, so the consumer and the external effect must both be idempotent, and the caller now sees an asynchronous outcome it must be designed for. That is the trade: bounded lock hold time in exchange for eventual consistency and duplicate delivery you have to absorb.

Follow-up

  • Your mitigation adds a lock_timeout. What does the allocation path do when it fires, and what does the customer see?
  • Even with short transactions, 30 rows absorb most writes on promotion day. How do you keep those keys from serialising everything?
  • How do you test this before the next promotion without a degraded partner to hand?

Suggested practice checklist

Four days sample coding, design, fundamentals and the practical rounds at deliberately shallow depth, which is enough to surface the topics you did not know were in scope. That map, rather than a guess made on day one, decides where the last three days go.

Day 1 — Coding, one pass at shallow depth

  • Solve one problem from each of six families, an array with two pointers, hash counting, binary search, a tree traversal, a graph traversal and one dynamic program, under a hard twenty-minute cap with no extensions, marking each finished, late, or stalled.
  • For every stall, write the exact move you could not make rather than the subject, so the note reads could not turn the recurrence into a loop rather than bad at dynamic programming.
  • Fix nothing today. The value of the pass is the unfixed record.

Deliverable: Six timed attempts marked finished, late or stalled, each stall carrying a named blocking move.

Day 2 — Design, one pass at shallow depth

  • Spend twenty minutes each on three different shapes, a read-heavy feed, a write-heavy ingest path, and something needing a transaction across two entities, stopping each at requirements, interface and data model.
  • After each, write the first question you could not answer, which is usually a number you could not estimate or a failure mode you had no vocabulary for.
  • Mark which of the three you would be most relieved not to be asked, and treat that as data rather than as a preference.

Deliverable: Three shallow designs, each with the first unanswerable question written at the bottom.

Day 3 — Fundamentals and the practical rounds

  • Answer eight short questions in writing at four minutes each, covering the material that fills the gaps between the big rounds: what happens between a URL and a rendered page, what an index costs on write, when a process is preferable to a thread, and what conditions a deadlock requires.
  • Do one thirty-minute practical task of the kind a take-home compresses: read an unfamiliar two-hundred-line file and write what it does, what you would change, and the one thing you remain unsure of.
  • Score every answer fluent, correct but slow, or absent, and keep the absent ones visible.

Deliverable: Eight scored short answers and one written reading of unfamiliar code.

Day 4 — The rounds that are about you, and the map

  • Deliver three behavioural answers aloud against a timer, a conflict, a failure you owned, and a decision made without enough information, marking any that ran past three minutes or contained no number.
  • Assemble the map: every marked item from days one to three on a single page, sorted by how likely it is to appear in your loop rather than by how uncomfortable it felt.
  • Choose exactly two areas for the remaining three days and write down what you are deliberately abandoning.

Deliverable: A one-page scored map of the whole surface area with two areas chosen and the rest explicitly abandoned.

Day 5 — First chosen area, to the depth you skipped

  • Work the higher-ranked area in four focused blocks, choosing items one level above where you stalled rather than repeating what already works.
  • After each block write the rule you extracted in one sentence with its precondition attached, since a rule carrying no precondition is exactly what fails under a variation.
  • Re-attempt the day-one or day-two item that exposed this area and compare against the original timing.

Deliverable: Four worked blocks, a timed re-attempt against the original, and three one-sentence rules with preconditions.

Day 6 — Second chosen area, where the gap is coverage rather than speed

  • Treat the second area differently from the first. Day five drilled something you could already half-do; this one is usually a topic you had simply never met, so build one worked reference example end to end and keep it, rather than attempting six problems badly.
  • Write down the vocabulary you were missing on day two or three, five terms at most, each with the one sentence that makes it usable in an answer rather than the textbook definition.
  • Redo the shallow attempt that exposed this area and note whether you now fail later in the problem, because moving the failure point is the realistic gain from a single day and is worth more than a score that did not change.

Deliverable: One worked reference example for the newly covered area, a five-term vocabulary list, and a note on where the failure point moved.

Day 7 — Reassemble the loop

  • Sit two rounds back to back with no gap, ordering them so the area you chose second comes last, because the map was built from rested, isolated attempts and the loop will reach your weaker area when you are already spent.
  • Write where the second round suffered from the first, which is normally the point at which structure collapses into narration.
  • Reduce the week to one page holding only the rules you can state without reading them.

Deliverable: Mock notes on cross-round carryover plus a one-page card of rules you can recite from memory.

The estimate and the day you knew

A slipped date is only a bad story if you sat on it. What matters is what you believed when you gave the number, the signal that told you it was wrong, how many days passed before you said so, and what you cut rather than asking for more time. Scope you defended counts as much as scope you dropped.

Reverse an allocation design after peak contention

Medium · concurrency control · lock contention · reversibility · measurement

You chose optimistic concurrency for allocation: read the position, then UPDATE inventory_position SET qty = qty - :n, version = version + 1 WHERE version = :v, retrying on an affected-row count of zero. It was correct and fast in load tests with spread keys. At peak, a few hundred hot item-node pairs absorbed most write traffic, retries amplified, and allocation p99 went past the checkout budget. Describe a decision you reversed under production evidence: what you originally reasoned, the measurement that forced the change, what you replaced it with, and what you would have measured before committing.

Approach

  1. Say why the original choice was reasonable, because a reversal story is only useful if the first decision was defensible. Compare-and-set avoids holding a lock across the read, has no deadlock surface, and is uncontended on the long tail of keys, which is most keys most of the time.
  2. Name the mechanism of the failure rather than calling it contention. On PostgreSQL under READ COMMITTED, a conflicting UPDATE does not fail fast: it blocks on the row lock until the other transaction commits, then re-evaluates its predicate against the new row version and reports zero rows affected. Each loser therefore pays a full lock wait before learning it must retry, so with k writers queued on one pair the work is quadratic in k across the burst, and the retry loop adds round trips rather than avoiding waits.
  3. Bring the measurement that settled it, not the anecdote: attempts per successful allocation on the hottest pairs, the distribution of write traffic across item-node keys, and the p99 contribution of lock wait time separated from query time. Load tests with spread keys cannot show any of this, which is the real lesson and the thing you would run differently.
  4. State the replacement and its cost. Serialising each hot key behind a single writer with a bounded queue converts an unbounded retry storm into a bounded wait plus explicit shedding, at the cost of a new component, a routing decision and a failure mode when the writer for a key is unavailable. SELECT ... FOR UPDATE is the smaller change and trades the retry loop for an in-database queue that still consumes a connection per waiter.
  5. Describe the migration, since reversing a write path in production is where these stories become concrete: route only the measured hot keys first, keep both paths live behind a per-key decision, and verify with the same attempts-per-success metric before widening.
  6. Close on what you would have measured before committing, and be specific: key skew from production traffic, not from a synthetic generator, is the input the original decision was missing.

Follow-up

  • Under REPEATABLE READ on PostgreSQL that same conflict raises a serialization failure instead. What changes in your retry code and your error budget?
  • One key becomes so hot that even the single writer saturates. How do you shed load without overselling?
  • An allocation must span two positions atomically. What breaks in your replacement design if those rows live on different shards?

Own an outage that double-tendered loads to a carrier

Medium · incident response · idempotency · blast radius · carrier integration

A retry wrapper in the carrier gateway re-issued tenders whose responses had timed out, minting a fresh idempotency key on each attempt. Over 70 minutes, 212 of 1,340 legs were tendered twice and 19 became second physical pickups before dispatchers noticed. Take the on-call role and describe an outage you owned with a comparable blast radius: how it was detected, how you bounded the affected legs, what you stopped first, and how the duplicate physical work was unwound. Give a wall-clock timeline, the query that sized the damage, and the guard that would have caught it in minutes rather than an hour.

Approach

  1. Open with the invariant that broke rather than the symptom: at most one booking exists per shipment_leg, keyed by booking_idempotency_key. Stating the invariant tells the listener what is countable, which is what makes the rest of the story checkable.
  2. Size the population with a named query, not an adjective. Duplicates are visible two ways and a strong answer says which one it trusted: legs carrying more than one distinct carrier_booking_ref, and the carrier's own booking export left-joined to shipment_leg on the reference, where the unmatched rows are bookings the system does not know it owns. The second is authoritative because the partner, not you, holds the truth about what was accepted.
  3. Separate mitigation from fix and say which came first. Mitigation is blunt and cheap: disable the retry path, drop gateway concurrency, hold tendering behind a flag so the floor keeps picking while dispatch stops booking. The fix, minting and persisting the key before the call with an explicit unknown status, is not an incident-window change.
  4. Split the blast radius into tiers, because the recovery cost is not uniform: a duplicate record is a delete, a duplicate booking not yet dispatched is a cancellation call, and a duplicate that became a pickup is a physical reversal with a trailer, a driver and an invoice behind it. The 19 that moved dominate the cost and belong at the front of the account.
  5. Close with one detector and its price. A counter on bookings-per-leg with an alert above one, or a nightly reconciliation of the partner export against shipment_leg, are both defensible; naming one and saying how many false pages per week it costs beats listing five that nobody staffed.
  6. Name what you got wrong inside the response window, not only in the writeup. Interviewers weight that heavily because it is the part candidates rehearse away.

Follow-up

  • The partner has no idempotency concept on their tender endpoint. What do you do differently, and what is the residual exposure you accept?
  • How would you have detected this in five minutes instead of 70, and what would that detector have cost in noise?
  • Dispatch disputes your count of duplicate pickups. What artefact do you show them?

Turn an implicit fail-open availability answer into a decision

Medium · degradation · fail open · decision records · partial failure

When the inventory service is unreachable, the availability client returns the last value it cached, because a broad except block around the call does that by accident. Nobody chose it. During a 40-minute outage the storefront kept promising stock for items that had sold out, and 900 lines short-shipped. Describe a time you converted an implicit behaviour into an explicit decision: how you surfaced it, the two directions the error can go and what each costs, what you actually chose for the checkout path versus a browse page, and where the choice is now written down.

Approach

  1. Surface the behaviour as a fact before arguing about it: the except block is a policy, it was never chosen, and it currently applies identically to a browse page and to the promise made at checkout. Framing it as an unowned decision rather than as someone's bug is what gets it looked at.
  2. Price both error directions with the operations they trigger, since neither is free. Promising stock you do not have produces a short-ship, a customer contact, a re-source or refund and, on the floor, a picker sent to an empty location; refusing stock you do have loses the order outright. Both are real, the second is cheaper to reverse, and that asymmetry is the argument.
  3. Split the decision by path instead of choosing one policy for the system. A browse page can serve a stale value with a staleness label and be right most of the time; a checkout promise should degrade to a conservative answer, which is not zero but the last known position reduced by an in-flight allowance sized from the recent allocation rate for that key. Applying one policy to both paths is the actual defect.
  4. Bound the staleness, because a cached value with no age is the same bug wearing a different hat. Serve the cached number while it is under a stated age, then switch to the conservative answer, then refuse; the thresholds need owners and should appear on a dashboard as the share of reads answered from each tier.
  5. Write it down where the next person meets it: a short decision record giving the behaviour, the two costs, the chosen policy per path and the date, plus a test that asserts the checkout client cannot serve an unlabelled stale value. A decision that lives only in a thread gets reverted by the next broad except block.
  6. Report what the change actually cost. If the conservative answer suppressed some orders that would have been fillable, say how many, because a degradation policy that was never measured is indistinguishable from one that was never enforced.

Follow-up

  • How do you size the in-flight allowance, and what happens to it during a promotion on that item?
  • The outage is 4 hours rather than 40 minutes. Does your policy still hold, and what changes at which threshold?
  • Someone adds a new consumer of the availability client next quarter. What stops them inheriting the wrong policy by default?