cswork / libraryOpen practice workspace

Interview guides · Sagesure insurance managers

Sagesure insurance managers Software Engineer Interview Questions & Guide 2026

Caching practice for Sagesure insurance managers software engineer prep: settle the staleness budget before choosing an invalidation strategy.

The 60-second brief

As a Software Engineer (often designated as a Program Engineer) at Sagesure insurance managers, you serve as a critical bridge between complex insurance products and the technology that powers them. Your work directly impacts how the company manages risk, interacts with partners, and delivers value to policyholders. You will be expected to build scalable, reliable software solutions that simplify the insurance lifecycle in a fast-paced, data-driven environment.

Seniority moves the scope further than the words in the title do. An earlier-career loop mostly checks that you implement something correctly and can reason about its cost, while a senior loop checks that you can pick between two defensible designs and say what you gave up.

Focus areas

  • Name the isolation level each invariant requires
  • Reconcile the ledger against processor settlement files
  • Store money as integer minor units

Overview

This role is not just about writing code; it is about understanding the business mechanics of the insurance industry. You will collaborate with cross-functional teams to solve high-impact problems, ensuring that the technology stack is robust enough to handle the intricacies of property and casualty insurance. Whether you are optimizing internal workflows or enhancing external-facing platforms, your contributions will be vital to the operational success of Sagesure insurance managers.

The Sagesure insurance managers interview process

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

Recruiter Screen

Half of this call is the part candidates treat as small talk: start date, notice period, work authorisation and its timing, location and time zone, on-call, and the number. Those are what kill offers late, after several engineers have each spent a day. Surfacing a hard constraint now costs you nothing and occasionally buys you something, since a loop compressed to fit a competing deadline can usually only be arranged if it is asked for early. The common failure is deflecting the compensation question twice, then discovering at offer stage that the band never reached your number.

What to demonstrate

  • Whether your hard constraints are compatible with the role before a loop gets booked: earliest start, notice period, what authorisation you hold and when it needs action, days on site, willingness to carry a pager
  • Whether you give a compensation range with something behind it, such as current total compensation or a competing timeline, rather than leaving the band untested
  • Whether your stated timeline is real, since a competing deadline raised now is something scheduling can sometimes work around and the same deadline raised at offer stage usually is not

How to prepare

  • Write each constraint down in one line before the call and state them as facts rather than negotiating them live under a question you were not expecting
  • Set your range from two or three current data points for that level and location, and name the structure you are quoting in, so the number is comparable to the one they are holding
  • If another process is running, say where it stands and by when, and ask directly whether this loop can be scheduled inside that window

Interviews with Hiring Manager

Part of what is being decided is what your manager's week looks like once you are on the team: how they find out that something you shipped is broken, and whether they hear it from you or from a customer. The material that moves this round is therefore not the launch, it is the week after. Describe how you knew the change was working, which number you watched and for how long, and what you would have reverted to. A project that ends at the word shipped leaves the manager guessing about the part they care about most.

What to demonstrate

  • How a change of yours was verified in production, and whether that check existed before the deploy or was assembled afterwards once something looked wrong
  • Whether the rollback story is specific, including the case where a revert is not enough: once a migration has dropped a column or rewritten data, going back is its own change with its own risk
  • Whether you can describe an incident you caused, how it was found, and how much time passed between it starting and anyone noticing
  • Whether bad news in your account travels early and from you, or consistently arrives via somebody else

How to prepare

  • For your last significant change, write down what you watched after the deploy, for how long, and the number that would have made you revert. If nothing was watched, say that plainly instead of inventing a dashboard.
  • Work out the rollback answer for a change that touched stored data, and be able to say what made it reversible or what you would have had to do instead. That distinction is a level signal on its own.
  • Write the two-minute version of an incident you owned: what broke, how it surfaced, what you did in the first ten minutes, and the change that stopped it recurring. Get the timeline straight, because this is the story most likely to be interrupted with questions.

Technical Team Interviews

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

Where candidates lose points

Assuming the default isolation level enforces the invariant you wrote down

PostgreSQL defaults to READ COMMITTED, where every statement takes a fresh snapshot, so a read-modify-write on a balance loses updates under concurrency. Its REPEATABLE READ is snapshot isolation, which blocks that particular anomaly by aborting the loser with SQLSTATE 40001 but still permits write skew across two different rows; only SERIALIZABLE closes that, and both levels therefore require a bounded retry loop on 40001 that many implementations simply never write. MySQL's InnoDB REPEATABLE READ behaves differently again — it does not abort on a conflicting write, so the identical application code silently changes behaviour when the engine changes. Two-sided transfers add a second failure mode on top: without a deterministic lock ordering, such as always locking account ids in ascending order, concurrent opposing transfers deadlock (SQLSTATE 40P01).

Treating money as a decimal with two places

ISO 4217 exponents are 0 for currencies such as JPY and KRW, 2 for most, and 3 for BHD, KWD, JOD, OMR and TND, so a hard-coded multiply-by-100 is off by a factor of 100 or 10 depending on the currency, in opposite directions. Floating point is worse: IEEE 754 binary64 cannot represent 0.1 exactly, so repeated accrual accumulates drift that appears as a handful of minor units in the daily reconciliation and then gets 'fixed' by widening the match tolerance, which is how a genuine break becomes invisible. The only forms that survive a reconciliation are integer minor units with the exponent carried alongside the currency code, or a fixed-scale decimal type with exactly one documented rounding point.

Saying 'eventually consistent' without naming the anomaly a user would see

Describe the concrete symptom you are choosing to accept: the author reloads and their own comment is missing for two seconds, or two devices show different balances for a minute. The class of consistency model is a technical label; the tolerable anomaly is the actual product decision.

Issuing one query per row of a result set

Fetch related rows in a single batched query keyed by the ids you already hold, or join them into the original query. A per-row round trip multiplies network latency by the row count, and it looks perfectly fine against the ten rows in your development database.

Questions & practice

Answer as-of balance queries over an append-only entry log

Hard · prefix sums · offline queries · append-only

Given 400 million ledger_entry rows (entry_id, account_id, direction, amount_minor, currency, business_date) and 2 million queries of (account_id, currency, as_of_date) asking for the balance at the end of that business date, produce every answer. The obvious solution — per query, sum that account's entries with business_date <= as_of_date — is correct. Say precisely why it will not finish, then give one that will, with time and space complexity. Corrections are posted as new entries carrying their own business_date.

Approach

  1. Cost the naive version in numbers before rejecting it. Spread uniformly over 20 million accounts, each query touches about 20 rows behind a per-account index and 2 million queries is 4e7 row touches — perfectly fine. The problem is skew: one pooled clearing or merchant settlement account holding 3e7 entries, taking 10% of the queries, is 6e12 row touches. Name the skew; 'n is large' is not the reason.
  2. The structural fact that buys a cheap answer: entries are append-only and never updated, so a prefix sum over an account's entries ordered by (business_date, entry_id) is stable — nothing behind position i can change. No mutable-balance design offers that, and it is why the storage is worth paying for.
  3. Offline sweep, when all queries are known up front: externally sort entries by (account_id, currency, business_date, entry_id) and queries by (account_id, currency, as_of_date), then merge-walk both with a running sum, emitting each query's answer as the sweep passes its date. O((n + q) log(n + q)) dominated by the sort, O(1) beyond sort buffers, one sequential pass over each input instead of 2 million random seeks.
  4. Online alternative: materialise end-of-day snapshots — one row per (account_id, currency, business_date) that had activity, holding the cumulative total. A query becomes one index seek for the latest snapshot at or before as_of_date, O(log n) per query, over far fewer rows than n. Use snapshots when queries arrive singly and the sweep when they arrive as a batch.
  5. Corrections are the subtlety: an entry posted today but dated back changes historical answers, so every snapshot for that account from that date forward is stale. Either keep a Fenwick tree over dates per account (O(log D) update and prefix query) or recompute that account's snapshots from the corrected date onward. Then be precise about what reproducibility means — yesterday's statement is reproducible as of a stated snapshot time, not identical forever.
  6. Bound the resources: int64 sums throughout, no float; 400 million rows at roughly 48 bytes of the columns you actually need is about 19 GB, so the sort is external and its fan-out is chosen from the sort buffer, not from the row count.

Follow-up

  • One account holds 30% of all entries. What does the external sort do with it, and what would you do for that one key instead?
  • Queries now arrive online at 500 per second. Which design survives, and what does keeping the other one warm cost?
  • A correction lands with a business_date 90 days back. Which snapshots are now wrong, and how does a reader find out?

Parse settlement amounts into minor units without floating point

Easy · parsing · money representation · iso-4217

A settlement file carries amounts as text: 1234.56, -0.07, 1,234.5, 1234, 12 345,67, and for three-exponent currencies 1.234. Each line also carries an ISO 4217 alphabetic code and the file's sign convention. Write the function converting one amount string plus its currency into a signed amount_minor int64, scaling by the currency's ISO 4217 exponent — 0 for JPY and KRW, 2 for USD and EUR, 3 for KWD and BHD. Reject anything not convertible exactly, with a typed reason. Never raise, never use a float.

Approach

  1. Fix the contract first: return a result union of Ok(int64) or Err(reason) with reason drawn from unknown_currency, malformed, too_many_fraction_digits, overflow. A parser on this path that throws turns one bad line into a failed fifty-million-line run.
  2. Decide which character is the decimal mark from the file's format spec, not from a heuristic on the last separator, then strip grouping separators. 1,234 is genuinely ambiguous between 1234 and 1.234 exactly when the exponent is 3, so an unstated spec is a reject, not a guess.
  3. Split on the decimal mark. Right-pad the fraction with zeros to the currency's exponent e; if it is longer than e and any dropped digit is non-zero, return too_many_fraction_digits. Silently truncating is how half a minor unit becomes a reconciliation break nobody can source.
  4. Build the integer by digit accumulation on int64 with checked multiply and add, or equivalently concatenate the integer and padded-fraction substrings and parse once. Either way the value never passes through a float.
  5. Apply the sign last, from the convention the file declares — trailing CR/DR, a trailing minus as in 1234.56-, or a leading minus. Assuming a leading minus silently flips every credit in a file that uses the trailing form.
  6. Complexity is O(len) per amount with no allocation beyond a digit buffer, so the whole file is one O(total bytes) pass.

Worked solution (20 min)

  1. Write the exponent table {JPY:0, KRW:0, USD:2, EUR:2, KWD:3, BHD:3} and the result type before any parsing code.
  2. Implement integer-only scaling: split on the decimal mark, pad or reject the fraction, concatenate, parse once with checked arithmetic.
  3. Table-test: (1234.56, USD) to 123456; (1234, JPY) to 1234; (1.234, KWD) to 1234; (1234.56, JPY) to Err(too_many_fraction_digits); (0.005, USD) to Err; (-0.07, USD) to -7.
  4. Write the float implementation beside it and run both over ten thousand random amounts across all six currencies, printing every disagreement.
  5. Round-trip: format amount_minor back to text at the currency's exponent and assert equality with the normalised input.

Expected result: All six table cases pass. The float version agrees with the integer version on ordinary two-decimal USD amounts — which is exactly why it survives review — and disagrees on every JPY amount by 100x, every KWD amount by 10x, every input with more fraction digits than the exponent (which it rounds instead of rejecting), and under truncation, where int(1.15 * 100) yields 114.

Verify it yourself

  • For every input that parses, formatting the result back at the currency's exponent reproduces the normalised input string.
  • No code path returns a value when the fraction is longer than the exponent and a dropped digit is non-zero.
  • The function body contains no float, no double and no %f — grep is a legitimate test here.
  • The sum of parsed minor units over a whole file equals the file's own control total exactly, with no tolerance applied.

Follow-up

  • Exponent 4 exists (CLF). Does the exponent come from a compiled-in table or from the file header, and what happens when the two disagree?
  • A line has more fraction digits than the exponent allows. Is that a reject, a round, or a break — and who makes that call?
  • How do you fuzz this so that every failure is a typed rejection rather than a wrong number?

Match a settlement file to ledger postings under duplicate keys

Medium · hash join · reconciliation · external memory

You have one business date of ledger postings (about 12 million rows: transaction_id, source_id, amount_minor, currency, business_date) and the processor's settlement file (about 12 million lines: settlement_line_id, external_reference, amount_minor, currency, business_date), where source_id carries the external reference. Match one-to-one on (external_reference, amount_minor, currency, business_date). Duplicate keys occur legitimately — the same amount can appear twice. Emit every unmatched item classified ledger_only, file_only or duplicate_match, in linear expected time. Then say what you do when neither side fits in memory.

Approach

  1. Build the smaller side into key -> deque of row ids, never key -> row id. A duplicate key is data, not corruption; a single-row map drops one of a legitimate pair and the break report then shows a file_only that does not exist.
  2. Probe the larger side once, carrying one extra bit per bucket: whether that bucket was ever hit. Pop from the bucket on a match and set the bit. An absent key is a probe-side-only row. A present-but-empty bucket means the probe side holds more copies than the build side — surplus, so duplicate_match. After the pass, a leftover non-empty bucket that was never hit is build-side-only; one that was hit is build-side surplus, so also duplicate_match.
  3. That bit is what makes the classification a function of the per-key counts rather than of which side you happened to build. For a key with L ledger and F file copies: min(L, F) match, and the |L - F| surplus rows are duplicate_match tagged with the side that is over, degenerating to ledger_only or file_only exactly when min(L, F) is 0. Without the bit, surplus is only observable as a present-but-empty bucket, which can only ever happen on the probe side — so the same input reports different break classes depending on build order, and the smaller-side heuristic in bullet one silently decides which.
  4. A genuine amount difference does not surface as amount_mismatch here, because the amount is inside the key — it surfaces as a ledger_only and a file_only sharing a reference. Promote those in a second, separate pass keyed on reference alone, recording signed delta_minor as ledger minus file. Keep that promotion out of the exact pass.
  5. Cost: O(N+M) expected time and O(min(N,M)) memory; the hit bit packs into the bucket header and changes neither bound. The constant is the hash map, roughly 60 to 100 bytes per entry in most runtimes, so 12 million rows is order 1 GB — measure it rather than assert it.
  6. When neither side fits, use a grace hash join: partition both sides with the same hash function into P spill files so a key lands in the same partition on both sides, then join partition by partition in memory. Cost is two extra sequential passes; skew inside one partition is the failure mode, handled by re-partitioning that partition under a second hash.
  7. Sort-merge is the alternative at O(N log N + M log M) with external sort, and it wins when the file already arrives sorted by reference or the output must be ordered. It also gets the surplus classification for free, since a merge sees L and F side by side. Whichever you pick, do not widen the amount comparison to make breaks disappear: a tolerance wide enough to absorb rounding is wide enough to absorb a real loss.

Follow-up

  • The file nets three fee lines into one batch total. Which pass catches that, and what is its stopping rule?
  • The processor's business date sits one cutoff behind yours for forty minutes of traffic. What does that do to the exact join, and what does it do to break ages?
  • The same break recurs on the next run. Why must it link to the existing reconciliation_break row rather than open a second one?

Store multi-currency amounts and prove each transaction balances

Easy · minor units · normalisation · constraints

ledger_entry holds entry_id, transaction_id, account_id, direction (debit or credit), amount_minor (bigint, CHECK > 0), currency char(3), source_type, source_id, business_date and posted_at. The service posts in JPY, USD and KWD. Specify the column types for money and the reference table that carries each currency's ISO 4217 minor-unit exponent, and say why that exponent must not be a constant in application code. Then write the query that returns every transaction_id whose entries fail to sum to zero within each currency, ordered by the largest absolute imbalance.

Approach

  1. State the representation: amount_minor is an integer count of minor units and the scale lives with the currency, not in the column. int4 tops out at 2,147,483,647 minor units, which is about 21.5 million units of a 2-exponent currency, so bigint, and the currency code travels on every row that carries an amount.
  2. Normalise the scale into currency(code char(3) primary key, exponent smallint, name) and join it only at display and parse boundaries. The exponent is 0 for JPY and KRW, 2 for USD and EUR, 3 for KWD, BHD, JOD, OMR and TND, so a hard-coded divide by 100 is wrong by 100x for JPY and by 10x for KWD, in opposite directions.
  3. Rule out binary floating point outright: IEEE 754 binary64 cannot represent 0.1, so repeated accrual drifts by a few minor units that later appear as reconciliation breaks. If a fixed-scale decimal is used instead, pin the scale per currency and name the single place rounding happens.
  4. Write the balance check as a grouped aggregate: sign the amount with a CASE on direction, GROUP BY transaction_id, currency, and HAVING the signed sum <> 0. The sign belongs in direction, and the CASE is the one place it becomes arithmetic.
  5. Then order in a second layer, because the ordering the question asks for cannot be written on the grouped query itself. PostgreSQL lets an output alias stand alone in ORDER BY but resolves anything inside an expression against the input columns, so ORDER BY abs(imbalance_minor) DESC raises SQLSTATE 42703, column "imbalance_minor" does not exist, while the bare ORDER BY imbalance_minor DESC is accepted and sorts signed rather than absolute. Wrap the grouped query and order by abs() outside it, or repeat the aggregate as ORDER BY abs(SUM(CASE ...)) DESC, which is legal because aggregates are allowed there.
  6. Group per (transaction_id, currency) rather than per transaction, because a cross-currency movement balances only within each currency, bridged by an explicit FX position account whose rate, source and timestamp are stored on the transaction.
  7. Note that no CHECK constraint can express this, since it spans rows: enforce it at write time with a DEFERRABLE INITIALLY DEFERRED constraint trigger that fires at commit, or by routing every posting through one function, and keep this query as the independent audit.

Worked solution (15 min)

  1. Define currency(code, exponent, name) and seed JPY 0, USD 2, KWD 3; declare amount_minor bigint NOT NULL CHECK (amount_minor > 0) and currency char(3) REFERENCES currency(code).
  2. Write it in two layers so it runs: SELECT transaction_id, currency, imbalance_minor FROM (SELECT transaction_id, currency, SUM(CASE WHEN direction = 'credit' THEN amount_minor ELSE -amount_minor END) AS imbalance_minor FROM ledger_entry GROUP BY transaction_id, currency HAVING SUM(CASE WHEN direction = 'credit' THEN amount_minor ELSE -amount_minor END) <> 0) b ORDER BY abs(imbalance_minor) DESC.
  3. Post one clean capture, one deliberately unbalanced transaction, and one cross-currency transfer with an FX position account, then run the query.
  4. Convert 1000 JPY and 1000 KWD through the exponent table in both directions and confirm the round trip is exact.

Expected result: Exactly one row: the deliberately unbalanced transaction, with a signed imbalance_minor equal to the amount omitted. The clean capture and both single-currency sides of the FX transfer return nothing.

Verify it yourself

  • The cross-currency transaction produces two groups, one per currency, and neither appears in the output.
  • Flipping one entry's direction makes imbalance_minor exactly twice that entry's amount, which confirms the sign convention.
  • Inserting an entry with amount_minor = 0 or a negative value is rejected by the CHECK rather than by application code.
  • Execute the statement rather than reading it: ordering the grouped query directly by abs(imbalance_minor) fails with 42703, which is why the abs() sits outside the subquery.

Follow-up

  • A transaction has a USD leg and a JPY leg. Which accounts appear, and where does the rounding residual land?
  • How would you enforce the zero-sum rule at write time without serialising all postings behind one lock?
  • Someone proposes storing a display amount alongside amount_minor. What goes wrong at the first rate change or rounding rule change?

Explain why the outbox relay stopped using its partial index

Medium · indexing · vacuum · queue tables

outbox_event holds event_id, aggregate_type, aggregate_id, aggregate_version, event_type, payload jsonb, published_at, attempts, last_error, created_at, with index ix_unpub ON outbox_event (created_at) WHERE published_at IS NULL. The relay runs SELECT ... WHERE published_at IS NULL ORDER BY created_at LIMIT 500 FOR UPDATE SKIP LOCKED, then marks each row by setting published_at. Unpublished rows hold steady near 400, but the query has gone from 3 ms to 900 ms. Explain what EXPLAIN (ANALYZE, BUFFERS) will show, why it happens, and the fix.

Approach

  1. Read the plan for the gap between rows returned and work done: an index scan on ix_unpub returning 500 rows while touching tens of thousands of buffers is the signature. Rows Removed by Filter and the buffer counts name it; wall-clock alone does not, because a warm cache hides it.
  2. Explain the mechanism: marking a row published is an UPDATE, which writes a new tuple version. The new version fails the index predicate and leaves ix_unpub, but the dead old version's index entry stays until vacuum removes it, so the scan walks dead entries and discards them. PostgreSQL can hint an entry LP_DEAD once a scan has proved it dead, which cheapens repeat visits, but the index pages themselves still have to be read and are not reclaimed.
  3. Ask why vacuum is not reclaiming. Anything holding the xmin horizon back prevents removal: a long-running query, an idle-in-transaction session, an abandoned prepared transaction, or an inactive replication slot. Check the oldest xact_start in pg_stat_activity, pg_replication_slots, pg_prepared_xacts, and n_dead_tup with last_autovacuum in pg_stat_all_tables.
  4. Fix in order of leverage: delete or archive published rows instead of leaving them in place, so a queue table stays a queue; keep the transaction horizon short and alert on it; then tune autovacuum on this one table with an aggressive scale factor rather than changing the global setting.
  5. Rule out the other failure with the same symptom: a partial index is usable only when the planner can prove the query predicate implies the index predicate, so rewriting the filter as coalesce(published_at, 'epoch') = 'epoch' or wrapping the column in a function disqualifies the index entirely and produces a sequential scan instead of a bloated index scan.
  6. Verify by re-running EXPLAIN (ANALYZE, BUFFERS) after the horizon is released and a VACUUM completes, comparing shared buffer reads rather than elapsed time, and confirm the relay keeps per-destination ordering after the change.

Follow-up

  • SKIP LOCKED means two relay workers never block each other. What else does it change about ordering guarantees for a single destination?
  • You archive published rows to a second table. What does that do to the relay's crash recovery and to duplicate delivery?
  • The relay batches 500 rows and publishes them, then marks them. Where exactly can it crash, and what does the consumer see?

How do you ensure your code is maintainable and scalable?

Medium

Approach

  1. State your assumptions explicitly before working the problem.
  2. Clarify what is being asked and what a complete answer contains.
  3. 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?

Describe your process for debugging a critical production issue.

Medium

Approach

  1. State your assumptions explicitly before working the problem.
  2. Clarify what is being asked and what a complete answer contains.
  3. Say what you would check first and why it is the highest-information step.

Follow-up

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

What is the largest cost incurred by an insurance company?

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?

Deliver merchant webhooks at least once preserving per-destination order

Medium · at-least-once · ordering · queueing

Outbound events come from outbox_event (event_id uuid, aggregate_type, aggregate_id, aggregate_version bigint with UNIQUE (aggregate_type, aggregate_id, aggregate_version), event_type, payload jsonb, published_at nullable, attempts, last_error). Consumers are merchant HTTP endpoints; one is down for six hours and another answers in 8 s. Peak is 5,000 events per second across 20,000 destinations. Specify the delivery contract you publish to merchants (signature, retry schedule, ordering guarantee, duplicate semantics) and the worker topology that keeps one dead endpoint from delaying every other merchant's events.

Approach

  1. Publish at-least-once and say it plainly in the contract: duplicates are expected, event_id is stable across every attempt of the same event, and consumers must deduplicate on it. Minting a fresh delivery id per attempt is the outbound twin of regenerating an idempotency key, and it disables the consumer's dedup while leaving it in place.
  2. Give consumers what they need to order for themselves: aggregate_id and aggregate_version in every payload, with the documented rule that an event whose version is not greater than the last applied version is discarded. Delivery-side ordering can only ever be best effort, because the first retry necessarily reorders an event relative to those that followed it.
  3. Partition work by destination rather than sharing one pool. Give each destination a bounded number of in-flight deliveries so the 8 s endpoint occupies only its own slots and the dead one only its own. One shared pool across 20,000 destinations converges on head-of-line blocking behind the slowest consumers.
  4. Do the arithmetic before promising ordering. Strict per-destination FIFO caps a destination at 1/latency deliveries per second, which is 0.125/s for the 8 s endpoint, so it will never drain 5,000/s of fan-out. Partition ordering by aggregate_id instead, which is the ordering consumers actually act on, and state that cross-aggregate order is not guaranteed.
  5. Retry with exponential backoff and jitter out to 24 hours, cap attempts, then disable the endpoint, notify the merchant, and hold the events behind a replay API rather than dropping them. Synchronised retries across many destinations after a shared outage need the jitter, not just the backoff.
  6. The relay reads only the partial index WHERE published_at IS NULL, claims rows under a lease so two relay instances do not both deliver, and treats a crash after delivery but before marking as an accepted duplicate, which is exactly what the at-least-once contract already licenses.

Worked solution (35 min)

  1. Write the merchant-facing contract page: signature scheme over raw bytes, retry schedule with the exact backoff points, at-least-once with a stable event_id, and ordering stated as per-aggregate best effort with aggregate_version as the tiebreaker.
  2. Implement the relay against the partial index with a lease-based claim, and run two relay instances to confirm no row is delivered twice within one lease.
  3. Stand up three fake destinations: fast, 8 s slow, and hard down. Drive 10,000 events and measure end-to-end latency for the fast destination while the other two misbehave.
  4. Kill the relay mid-batch and assert no event is lost and that the resulting duplicates are all the same event_id.
  5. Compute the per-destination throughput ceiling under strict FIFO at the observed latencies and write down which ordering guarantee you can actually keep.

Expected result: The fast destination's p99 delivery latency is unchanged while one destination is down and another takes 8 s; every event is delivered at least once across the relay kill; duplicates all share their original event_id and aggregate_version.

Verify it yourself

  • A consumer that discards events whose aggregate_version is not greater than its last applied version ends in the same state regardless of delivery order.
  • The dead destination's queue depth grows without any growth in the other destinations' queue depth.
  • The relay's only scan is the partial index on unpublished rows, confirmed by EXPLAIN rather than by reading the code.

Follow-up

  • A merchant asks for exactly-once delivery. What do you offer instead, and what do they have to build?
  • The relay's unpublished backlog grows to 40 million rows during an outage. What does that do to the partial index and to the claim query?
  • How do you let a merchant replay yesterday's events without replaying them to every other destination?

Approval rate collapses when one feature-store shard fails

Medium · cache stampede · partial outage · bulkhead · retry amplification

The risk decision service answers about 3,000 authorisations per second within an 80 ms p99, reading features from an eight-shard store behind a local cache with a fixed 60-second TTL, a 2-second client timeout and two retries on timeout. One shard becomes unreachable at 14:02. Approval rate falls from 96% to 71% within 90 seconds, and the seven healthy shards show p99 rising to 900 ms with normal error rates. Diagnose in order, name the cause of the approval drop, and state the degraded behaviour this service should have had.

Approach

  1. Establish what declined actually means here before touching the cache. Split the rate into approve, decline, refer and timeout. If decline counts are flat and timeouts account for the whole 25-point drop, the model is not declining anything; it is failing to answer, and something downstream is reading silence as a decline. That reading is the first finding and the most expensive one.
  2. Break the store's metrics out per shard. One shard at 100% errors and seven at elevated latency with baseline error rates is the signature of queueing, not of seven shards failing. Healthy dependencies with healthy error rates and bad latency are almost always victims of a shared resource.
  3. Follow the resource. A single connection or thread pool shared across shards means requests to the dead shard occupy slots for the full 2-second client timeout, which is 25 times the whole p99 budget, and with two retries each such request can hold a slot for 6 seconds. Confirm by comparing pool acquisition wait against per-shard service time: if wait dominates, the pool is the bottleneck.
  4. Quantify the amplification. Two retries on timeout offer 3x load, and an unjittered 60-second TTL expires a warm set together, so misses arrive as a synchronised wave rather than spread across the window. Retrying a timeout is only correct when the failure is likely transient; against a hard-down shard it is pure amplification and needs a circuit breaker to gate it.
  5. Fix the three separate defects separately: per-shard pools so one shard cannot starve seven, a breaker that stops retrying a shard that is failing, and per-key TTL jitter with single-flight refresh so N concurrent misses for one key produce one upstream read.
  6. Write down the degraded decision as a product decision, not a timeout handler's accident. With features unavailable the service must return a stated answer, such as deterministic rules alone below an amount threshold and refer above it, and must record that the decision was degraded so it is auditable and reproducible from stored inputs.

Follow-up

  • Approving on deterministic rules alone during a feature outage carries a fraud cost. How do you size the amount threshold, and who signs it off?
  • What should the orchestrator do with a risk timeout, given it cannot distinguish one from a decline?
  • Does the circuit breaker fix the healthy shards' latency, or is the bulkhead doing that work? Which would you ship first if you could only ship one?

Suggested practice checklist

For someone who has spent the last few years shipping features and reading other people's code, and who has not solved a timed problem from a blank file in a long time. Five days rebuild the primitives and the patterns that sit on them, working from invariants rather than remembered solutions, and the last two attach that back to the rest of the loop.

Day 1 — Rebuild the primitives by implementing them

  • Implement a dynamic array with doubling growth and an operation counter, then change the growth rule to add a fixed sixteen slots instead, and time both for n of ten thousand, a hundred thousand and a million. The fixed-increment version resizes n/16 times at O(n) each, so its total work is quadratic; doubling is what makes append amortised constant.
  • Implement a hash map with separate chaining and a load-factor resize, then insert ten thousand keys engineered to land in one bucket and record what happens to lookup time, so that average-case O(1) becomes a claim with a stated precondition rather than a reflex.
  • For dynamic-array append and hash-map insert, write down which cost is amortised rather than worst-case, which single operation pays the whole bill, and what a system with a hard per-operation deadline would have to do instead.

Deliverable: Two working implementations plus a timing table showing the input at which each structure's advertised complexity stops holding.

Day 2 — Arrays under an invariant: two pointers, sliding window, binary search

  • Solve longest-subarray-with-sum-at-most-K using a sliding window, then run it on an input containing negative numbers and watch it return the wrong answer: extending the window only moves the sum monotonically when every element is non-negative, and that precondition is the whole reason the technique works.
  • Write the binary search that finds the first index satisfying a predicate rather than an exact value, put the loop invariant above the loop in a comment, and verify termination on the two inputs that break careless versions: the empty range, and a range where every element satisfies the predicate.
  • Compute the midpoint as lo + (hi - lo) / 2 and write one line on why the obvious (lo + hi) / 2 is a genuine defect in a fixed-width integer type and a non-issue in a language with arbitrary-precision integers.

Deliverable: Three solved problems, each with its invariant written above the loop, plus one recorded input on which the sliding window is provably wrong.

Day 3 — Sorting, heaps, and the greedy argument that has to be proved

  • Solve one top-k problem three ways, by full sort, by a size-k heap, and by quickselect, then write the values of n and k at which each becomes the right choice, along with quickselect's quadratic worst case and why a randomised pivot makes that unlikely rather than impossible.
  • Implement bottom-up heapify and count sift-down steps to confirm it does linear work rather than n log n, because most nodes sit near the bottom of the tree and therefore move only a short distance.
  • Take interval scheduling by earliest finishing time and write the exchange argument out in full: given any optimal schedule, swapping in the earliest-finishing interval keeps it feasible and no smaller. Then construct the weighted variant where that same greedy fails and name what has to replace it.

Deliverable: A three-way top-k comparison with measured crossover points, one written exchange argument, and one counterexample to a greedy rule that looks almost identical.

Day 4 — Recursion, memoisation, and the step to a table

  • Take one problem with overlapping subproblems, such as edit distance or coin change, instrument the plain recursion with a call counter to show the blow-up, then add memoisation and re-count.
  • Convert the memoised version to a bottom-up table and state the two properties you relied on: each subproblem's result depends only on its arguments, and the dependencies form a DAG you can enumerate in order.
  • Rewrite one deep recursion with an explicit stack, then find the input length at which the original hits the interpreter's frame limit, which defaults to about a thousand frames in CPython, so you know when the rewrite is required rather than decorative.

Deliverable: One problem in three forms, naive, memoised and tabulated, with call counts for each and the input length at which recursion depth becomes the binding constraint.

Day 5 — Graphs, where most of the work is choosing the traversal

  • Implement BFS and DFS over one adjacency list, then answer for each which finds a shortest path in an unweighted graph and which you would use to detect a cycle in a directed graph, including why the in-progress versus finished distinction matters for the second.
  • Implement topological sort by in-degree, feed it a graph containing a cycle, and confirm the failure signature is that fewer than V nodes come out rather than an exception, then note that the order it produces is one of several valid ones.
  • Run a shortest-path search on a graph with a single negative edge weight and show the wrong answer, then write the precondition Dijkstra actually needs, non-negative weights, because it finalises a node's distance the first time that node is popped, and name the algorithm you would switch to and its own limit.

Deliverable: A small graph library with BFS, DFS and topological sort, plus two inputs that produce documented wrong answers under the wrong algorithm choice.

Day 6 — One day for everything that is not an algorithm

  • Sketch one system only to the depth a coding-heavy loop tends to reach: the endpoints, what the service stores, and the single query pattern that decides the schema. Stop at twenty-five minutes.
  • Prepare the project answer for an interviewer who codes, which means rehearsing the two levels they push to: the specific thing you built, and why you chose that approach over the alternative they will name. Open with a number and be ready to say what it excludes.
  • Prepare the answer to what you would do differently, choosing a real technical mistake with a specific fix rather than a complaint about process or staffing.

Deliverable: One design sketch at endpoint-and-schema depth, plus a project answer rehearsed to two levels of follow-up.

Day 7 — Solve out loud, under time

  • Do three timed problems at twenty-five minutes each in a plain editor with no autocomplete and no execution until the end, then tally separately the failures that were syntax and the ones that were approach, because those two numbers call for different fixes.
  • Narrate one solution from the first sentence, stating the approach and its complexity before writing any code, and rehearse the sentence you will use when you realise mid-solution that the approach is wrong.
  • Re-solve from blank the two problems you were slowest on this week and compare the times against the day they first appeared.

Deliverable: A recording of one fully narrated solution and a tally that separates syntax failures from approach failures.

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.

What draws you to the mission and culture of our company?

Medium · behavioural and engineering judgement

Approach

  1. Close with what you would do differently, concretely.
  2. Name the disagreement and how you resolved it with evidence.
  3. State the situation in two sentences and spend the rest on the reasoning.

Follow-up

  • What would you do differently if you ran that again?
  • What did you decide not to do, and why?

How do you handle shifting priorities within a fast-moving project environment?

Medium · behavioural and engineering judgement

Approach

  1. State the situation in two sentences and spend the rest on the reasoning.
  2. Give the blast radius: what could have broken, and what you measured.
  3. Close with what you would do differently, concretely.

Follow-up

  • How did you know your change caused the improvement?
  • What did you decide not to do, and why?

Can you describe a time you had to explain a complex technical issue to a non-technical stakeholder?

Medium · behavioural and engineering judgement

Approach

  1. Close with what you would do differently, concretely.
  2. Name the disagreement and how you resolved it with evidence.
  3. Give the blast radius: what could have broken, and what you measured.

Follow-up

  • What did you decide not to do, and why?
  • What would you do differently if you ran that again?