R0 — Foundations: what a distributed system can and cannot promise
Read this before anything else. Every section that follows is an answer to a problem stated here.
flowchart LR A["Exactly-once DELIVERY<br/>impossible — two generals"] -.-> B["Exactly-once PROCESSING<br/>possible inside one<br/>transactional system"] B -.-> C["Exactly-once EFFECT<br/>at-least-once + dedup<br/>+ idempotent ops"] C --> D["Residue handled by<br/>reconciliation"]
flowchart TD
X["Message sent, no acknowledgement"] --> Y{"Retry?"}
Y -->|"yes: at-least-once"| D["Possible duplicate<br/>receiver recognises and drops it"]
Y -->|"no: at-most-once"| L["Possible loss<br/>no trace anywhere, found days later"]
D --> OK["Recoverable"]
L --> BAD["Not recoverable"]The story, in one page
In an ideal world, correctness would be easy. A message would be delivered exactly once, processed exactly once, and its effect applied exactly once. There are three semantics for message delivery — at-most-once, at-least-once, and exactly-once — and in a perfect system, exactly-once delivery is the standard.
We do not live in that world. At any moment a machine can go down or a network connection can break, and because a distributed system is many components each doing their own piece of work, each one fails independently and unpredictably.
To account for those failures, systems are built with recovery mechanisms — primarily, replaying failed requests. Because of those retries, duplicate messages become common, and we are shifted into an at-least-once reality whether we chose it or not. That is the first thing to accept: duplicates are not a defect in your design, they are the cost of being able to recover at all.
But what happens when a system cannot recover even with retries? If a failure is unrecoverable, any partial work already done needs to be undone.
This was never an issue when everything was handled by a single system. In a single database we use a transaction to guarantee atomicity — the property that a group of connected operations either all happen, or none of them do. Undo is free, because the database does it for you.
Achieving that same atomicity becomes incredibly difficult when the connected operations are scattered across different machines. You need mechanisms that can orchestrate operations, undo them when a single node fails, and guarantee a consistent result across the board. That requirement is exactly what leads to distributed transactions and protocols like two-phase commit.
Fundamental concepts and principles
Everything in this pack leans on this vocabulary, and interviewers grade the precision of it. Read it once now; each item is expanded where it is first needed.
The three delivery guarantees
Everything a messaging system can offer falls into three buckets.
| Guarantee | What it means | Cost |
|---|---|---|
| At-most-once | Send and forget. Never retry | Messages are silently lost. Unacceptable for anything that matters |
| At-least-once | Retry until acknowledged | Duplicates, which the receiver must handle |
| Exactly-once | Delivered once, no loss, no duplicate | Impossible as a delivery guarantee — the two generals problem below shows why |
There is no fourth option, and the third one is not available at the level of delivery. Every real system picks at-least-once and then does something about the duplicates.
Delivery, processing and effect: three different claims
The phrase "exactly-once" gets used for three different things. Interviewers grade this distinction, so it is worth being precise.
| What it claims | Scope | Achievable? | How | |
|---|---|---|---|---|
| Delivery | The message crosses the wire exactly once | The network hop | No — two generals | — |
| Processing | It may arrive many times but takes effect once | Inside one transactional system | Yes, within that boundary | The work and the record of having done it commit together |
| Effect | End to end, the account is debited once | The whole business operation | Yes | At-least-once delivery + deduplication + repeatable operations |
Read the table left to right and the trap is obvious: the claims sound the same, the scopes are completely different. Kafka's transactional consume-process-produce is genuine exactly-once processing, and it stops at Kafka's edge — the moment your handler also writes to Postgres or calls a rail, you are back to building exactly-once effect by hand.
Say it in that order and the rest of your answer follows: delivery cannot be exactly-once, so I design for exactly-once effect.
ACID, and what it is a property of
The four guarantees a single database gives you inside one transaction:
| What it promises | Delivered by | |
|---|---|---|
| Atomicity | All of the operations happen, or none of them do | The write-ahead and undo logs |
| Consistency | Constraints and invariants still hold afterwards | Constraints, foreign keys, triggers, and your own logic |
| Isolation | Concurrent transactions do not see each other's half-finished work | Locks and MVCC |
| Durability | Once committed, it survives a crash | The log, plus fsync |
Two things to be clear about early. ACID is a property of a transaction, not of a system — so "we need ACID across our microservices" is a sentence that usually has not been thought through. And inside one database all four are cheap and reliable; the moment an operation spans two databases, or a database and a broker, none of them stretches across the gap on its own. R2 is what that costs, R8 is the property you quietly lose first.
CAP and PACELC
CAP is usually mis-stated as "pick two of three". Partitions are not something you pick — the network will drop messages whether or not you planned for it. The real statement is narrower: when a partition happens, you choose between consistency and availability. Keep answering and risk disagreeing, or stop answering and stay correct.
PACELC covers the normal day. Partitions are rare, and CAP says nothing about the rest of the time, when the network is working. PACELC reads as a sentence: if there is a Partition, choose Availability or Consistency; Else — no partition — choose Latency or Consistency. That second half is the one you feel every day: keeping copies in agreement means waiting for other machines to confirm each write, and that wait is latency even when nothing is broken.
Where the main options sit:
| Approach | During a partition | With no partition |
|---|---|---|
| Two-phase commit | C — participants block rather than risk disagreeing | C — two round trips and locks held across the network |
| Saga with compensation | A — services keep serving and converge afterwards | L — no cross-service coordination in the hot path |
| Outbox + idempotent consumers | A — commit locally, the message catches up | L — publishing is asynchronous |
R2 works through why that makes 2PC the expensive corner.
Synchronous and asynchronous
Not a style choice — it decides who waits, who knows about whom, and how failure spreads.
Synchronous means the caller blocks until it gets an answer: an HTTP or gRPC call. The caller must know the callee, both must be up at the same instant (temporal coupling), and the answer changes what the caller does next.
Asynchronous means the sender hands the message to a broker or queue and carries on. The two never have to be up simultaneously, the broker absorbs bursts, and adding a second receiver costs the sender nothing.
| Synchronous | Asynchronous | |
|---|---|---|
| Who waits | The caller, for the whole chain | Nobody |
| Coupling | Caller knows the callee, both must be up | Sender knows only the topic |
| A slow dependency | Becomes your latency, then your outage | Becomes queue depth, which you can see and shed |
| Failure spread | Cascades upward through callers | Contained; work waits in the queue |
| Backpressure | Threads and connections pile up | Lag is measurable, consumers scale out |
| Best for | You need the answer to continue — a balance check, a fraud verdict | Telling others what happened, or work that can finish later |
The trap to name: asynchronous does not mean "no answer" — it means the answer arrives as another message, so somebody has to correlate it, time it out, and decide what to do when it never comes. That bookkeeping is the real cost, and most of R4 and R5 is about paying it well.
Commands and events
The distinction that most bad event-driven designs get wrong.
A command says "please do this." An event says "this happened."
You send a command to one specific service, and it can refuse or fail. You announce an event to whoever is listening, and nobody can refuse it, because it is already true in the publisher's database.
| Command | Event | |
|---|---|---|
| What it says | Do this | This happened |
| Tense | Imperative, about the future | Past, a fact |
| Who receives it | One named service | Anyone subscribed, unknown to the publisher |
| Can it be refused? | Yes | No — it already happened |
| Adding a new listener | You edit the sender | You change nothing |
The test when you are unsure: if nobody subscribed, would publishing it still be correct? For an event, yes — the fact stands. For a command, no — the work would simply not happen.
And the point people miss: transport is orthogonal to semantics. Putting "debit the account" on a queue gives you an asynchronous command, not an event. A webhook is an event delivered over HTTP. Choose the meaning first, then the transport. R4 takes this further, into why events are the thing that must never be lost.
Loss and duplicates: the asymmetry that decides every design
Duplicates are recoverable. Loss is not.
A duplicate arrives, the receiver recognises it, and throws it away. Cleaned up in one line of logic.
A lost message leaves no trace. Your side is committed and correct. The other side simply never heard. Nothing in either system knows anything is missing, and you find out days later from a reconciliation break or a customer.
Given a forced choice between the two failure modes, you pick duplicates every time. That single preference explains why at-least-once is the default, why retries are aggressive, and why every consumer has to be built to see the same thing twice.
What "throw it away" actually costs to build. Recognising a duplicate means remembering what you have already handled, and there are three usual implementations, in increasing order of strength:
- A
UNIQUEconstraint, which is the strongest because the database arbitrates. Insert the message ID or business key; a conflict is the duplicate check, and it is atomic with the work in the same transaction. This is the inbox table in R4 and the idempotency key in R3. - A conditional write in a key-value store — DynamoDB's
attribute_not_exists, or RedisSET key NX— where the store you already run does the arbitration. - A distributed cache of keys, typically Redis with a TTL. Fast and cheap, but it is a cache: an eviction or a failover can lose a key, and losing a key means the duplicate gets processed. Fine as a first-line filter in front of a durable check, dangerous as the only check for money.
The rule that decides between them: the deduplication record must commit atomically with the effect it protects. If the two can diverge, you have moved the problem rather than solved it.
The principles that follow
Everything above collapses into a short list. These are the sentences to have ready, because each one is a defensible position rather than a fact to recall.
- Prefer duplicates over loss, always. A duplicate is recognised and dropped in one line of logic. A lost message leaves no trace on either side and surfaces days later. Given a forced choice between the two failure modes, take the one you can clean up.
- Therefore design for at-least-once delivery. Not as a compromise — as the deliberate choice that follows from principle 1. Retries are aggressive by default, and every consumer is built to see the same thing twice.
- Therefore make effects idempotent, not deliveries unique. You cannot make the network deliver once. You can make the second delivery change nothing.
- Keep the atomic part inside one database. Where two writes must agree, move the second one into the first system rather than trying to coordinate across both.
- Fail locally where you can. A failure a component retries itself never becomes a distributed problem. Escalate only when the component genuinely cannot recover alone.
- Unknown is not failed. A timeout is an absence of information. Do not report success, do not report failure, and do not blindly retry — record it and resolve it by comparing records.
- Never order by wall-clock timestamp across machines. Order by a sequence the system itself issues.
- Verify continuously; do not assume. Correctness is not asserted at write time, it is confirmed afterwards from independently produced records.
If you only keep one, keep the first. Principles 2, 3 and 8 are all consequences of it, and most of this pack is the engineering required to make it true.
The system we will keep coming back to
Rather than describe failures in the abstract, the whole pack uses one small topology. It is deliberately ordinary — a fan-out, some parallel work, a join, and an endpoint — because every failure worth understanding shows up in a graph this simple.
flowchart LR S(["start"]) --> A["1.1"] S --> B["1.2"] A --> T["2"] T --> C["3.1"] T --> D["3.2"] T --> E["3.3"] C --> F["4.1"] D --> F E --> G["4.2"] F --> Z(["Done"]) G --> Z
Four features of it do all the work later:
startfans out into two independent branches. 1.1 leads onward; 1.2 is a dead end — work that must happen but feeds nothing downstream. That asymmetry is what makes partial completion possible.- 2 is a fan-out point. One inbound message becomes three outbound ones, which is why a replay aimed at one consumer inevitably reaches all three.
- 4.1 is a join. It needs both 3.1 and 3.2 before it can proceed, so a single missing input leaves it waiting indefinitely while looking perfectly healthy.
Donecan be read as an external system — a payment rail, a partner API — which is where the interesting failures stop being yours to fix.
Before reading further, play with it. The panel below runs ten failure scenarios on this exact graph, each narrated step by step. Watch scenario 1 to see the shape of the happy path, then jump to 7 and 8 — both end with a system that reports itself entirely healthy and is not. Everything this pack argues for exists because of what those two show. You can pause at any point and step through with Prev and Next.
waiting processing complete failed duplicate, dropped compensated being checked never arrived
Ten scenarios on one topology. Each is a failure this pack goes on to answer, and together they are the argument for everything after.
| # | Scenario | What it shows | Section |
|---|---|---|---|
| 1 | Straight through | The path you design for, and the only one that fits on a whiteboard | — |
| 2 | Retry inside 1.1 | A failure the component absorbs itself never becomes a distributed problem | R3 |
| 3 | 1.2 needs a restart | One branch reaches Done while another is stuck — and the restart re-enters work that already succeeded | R3, R5 |
| 4 | Replay from 2 | You cannot replay to one consumer; duplicates arrive at the others, and only idempotency saves you | R3, R4 |
| 5 | Unrecoverable at 3.3 | No retry helps, so the causal chain is walked backwards and undone deliberately | R5 |
| 6 | The compensation fails | The honest answer: park it, alert, and hand a human the context — never fail quietly | R5 |
| 7 | A message is silently lost | No error anywhere, two databases internally consistent and disagreeing with each other | R4, R6 |
| 8 | Unknown outcome at the boundary | The one ambiguity messaging cannot resolve: you cannot retry, cannot fail it, cannot see it | R3, R6 |
| 9 | Reconciliation finds both breaks | The independent pass that turns "probably fine" into a number | R6, R7 |
| 10 | Two writers, and the fence | A lease expires while its holder is alive, and the resource rejects the stale write | R8 |
Scenarios 7 and 8 are the ones worth watching twice. Both end with a system that looks entirely healthy and is not — which is the whole reason the second half of this pack exists.
With those in mind, the rest of this section works through the first half of the chain: why exactly-once delivery is impossible, what you get instead, how anything stays ordered when no two machines agree on the time, and which failure mode you deliberately choose to live with. R2 picks it up at atomicity.
The two generals problem: why that third row is empty
Two armies on opposite hills must attack together. They can only communicate by messenger, and the messenger might be captured. The first general sends "attack at dawn." Did it arrive? He needs an acknowledgement. The acknowledgement might also be captured — so the second general needs an acknowledgement of the acknowledgement. There is no number of messages that ends this.
You have lived this without the armies. You text someone something that matters — "I've transferred the money, go ahead and pay the vendor" — from a train with one bar of signal, and no read receipts. Nothing comes back. Did the message fail to send? Did it arrive and their "sounds good" die on the way back? Send it again and they might pay twice. Leave it and they might never pay at all. You cannot tell from where you are standing, and no further message makes the uncertainty go away.
Your services are those generals. A service sends a request, the receiver processes it, and the response is lost. The sender now cannot distinguish two situations:
- the receiver never got it, or
- the receiver got it, did the work, and the reply died on the way back.
Whatever the sender does next is wrong in one of those two cases. Retry, and you might do the work twice. Give up, and you might not do it at all. The ambiguity cannot be removed — it can only be moved somewhere you can deal with it.
Ordering without a shared clock
A related question, and the one senior interviewers reach for next: if two machines disagree about the time, how does anything agree on what happened first?
Wall clocks cannot answer it. NTP drift, leap seconds and virtualised clocks mean two servers' timestamps can differ by more than the interval you are trying to order. Sorting events by created_at across services is a bug that looks like a design.
The answer is logical clocks, which order events by causality rather than by time. A Lamport clock is a counter each node increments on every event and sends with every message; the receiver takes the maximum of its own and the incoming value, plus one. That gives a total order consistent with causality — if A caused B, A's number is lower — though equal-ish numbers do not prove independence. Vector clocks carry one counter per node, which is heavier but can also tell you when two events were genuinely concurrent rather than ordered, which is what conflict detection in Dynamo-style stores needs.
In practice you rarely implement either by hand. You inherit their descendants: a Kafka partition offset is a per-partition Lamport clock, an etcd revision is a cluster-wide one, a database version column is a per-row one, and the sequence field on an event in R4 exists for the same reason. Spanner's TrueTime takes the other route — it bounds clock uncertainty with atomic clocks and GPS, then waits out the interval, which is why a hyperscaler can offer external consistency and you cannot.
The practical rule: never order by timestamp across machines. Order by a sequence the system itself issues, and treat wall-clock time as a human-readable annotation.
Identity without a coordinator: generating unique IDs
A payment request lands on one of forty instances of the payment service. Before anything is written, the payment needs a name — the ID the ledger rows, the outbox event, the rail message and the customer's receipt will all carry. On one database that name is free: an auto-increment column hands out the next number. Spread the writes across shards and regions and that counter becomes a coordinator — every insert waits on it, it is a single point of failure, and in a multi-region setup it puts a cross-region round trip on every write.
So the problem is the same one as the previous section, from the other side. Machines that do not share a clock also do not share a counter, and the ID has to be minted locally without asking anyone.
What a good ID has to be
| Property | Why it matters |
|---|---|
| Unique without coordination | Any instance can mint one, in any region, with no network call |
| Compact | It sits in every row, every index, every foreign key and every message; 64 bits fits a BIGINT, 128 does not |
| Roughly time-ordered | New rows append at the right edge of a B-tree instead of landing on random pages |
| High throughput | Minting must never be the bottleneck on a hot write path |
The third property is the one people underrate. A random key inserts into a random leaf page of the index, so the working set is the whole index: page splits, cache misses, write amplification. A time-ordered key always inserts near the newest page, which stays in memory. On an append-heavy table like a ledger, that is the difference between an index that keeps up and one that does not.
The options
| Option | Size | Time-ordered? | Coordination | Notes |
|---|---|---|---|---|
| DB auto-increment | Small | Yes | One database | Works only on a single database; the basis of ticket servers and multi-primary tricks where each primary hands out a different slice of numbers |
| UUID v4 | 128 bit | No | None | Purely random, so trivially safe — but large, and every insert lands on a random index page |
| UUID v1 | 128 bit | Partly | None | Timestamp plus MAC address; leaks which machine minted it and depends on clock sync |
| KSUID | 160 bit | Yes, to the second | None | A 4-byte timestamp in front of random bytes; sortable, but bigger still |
| ULID | 128 bit | Yes | None | Timestamp then randomness; lexicographically sortable and index-friendly. UUIDv7 is the standardised version of the same idea |
| Snowflake | 64 bit | Yes, to the millisecond | Only to assign machine IDs | Compact, time-ordered, very high throughput |
Read the table as two families. Random IDs (v4) buy perfect independence and pay for it in size and index locality. Time-prefixed IDs (ULID, UUIDv7, KSUID, Snowflake) put the timestamp first so that the ID sorts roughly by creation, and fill the rest with something that keeps two IDs from the same instant apart — randomness in ULID, a machine number and a counter in Snowflake.
The Snowflake layout
Snowflake is the one interviewers ask you to draw, because the whole design fits in one line.
| 1 sign | 41 timestamp (ms since custom epoch) | 10 machine id | 12 sequence |
| Field | Bits | What it buys |
|---|---|---|
| Sign | 1 | Always 0, so the ID is positive and sorts correctly as a signed long |
| Timestamp | 41 | Milliseconds since an epoch you choose — about 69 years of range from that date |
| Machine ID | 10 | 1,024 workers; often split 5 bits datacenter + 5 bits machine, giving 32 machines in each of 32 datacenters |
| Sequence | 12 | 4,096 IDs per millisecond per machine; resets to zero every millisecond |
Uniqueness comes from the combination, not from any one field: two IDs from different machines differ in the machine bits, and two IDs from the same machine in the same millisecond differ in the sequence. The sequence is what makes the scheme safe, not the clock. The timestamp only decides which bucket of 4,096 you are in.
Try the generator below. Mint several IDs quickly and watch the sequence field climb within a single millisecond, then reset when the millisecond ticks over; notice that the timestamp bits are the only part that moves the ID forward across time.
Tuning the bit split
The 1/41/10/12 split is a default, not a law. The bits are a budget, and you move them to where your system needs them.
| Your situation | Move bits | Effect |
|---|---|---|
| Far fewer than 1,024 machines | From machine ID to sequence | Each extra sequence bit doubles per-machine throughput |
| Coarser time ordering is acceptable | From timestamp to sequence or machine | More throughput or more workers, but each bit taken from the timestamp halves the lifespan |
| Many regions, few machines in each | Re-split the 10 machine bits | More datacenter bits, fewer machine bits per datacenter |
The custom epoch is part of the same budget: start it at your launch date rather than 1970 and the 69 years start counting from when they are useful.
The trap: the clock goes backwards
A Snowflake generator reads the wall clock — the thing the previous section told you not to trust.
If NTP steps the clock back by a few milliseconds, the generator re-enters milliseconds it has already used, and the same timestamp-plus-sequence pairs come out again. That is a duplicate primary key, and on a payment table it is either a failed insert or, worse, two payments that look like one.
The defences are simple, and worth naming:
- Remember the last timestamp you used. If the clock now reads earlier, refuse to mint, or wait until it catches up. Never quietly continue.
- Exhaust the sequence, then wait. If 4,096 IDs are used inside one millisecond, block until the next millisecond rather than wrapping the counter.
- Make machine IDs genuinely unique. Two workers sharing a machine ID will collide on every millisecond they share. Assign them deliberately — never from something that two pods can both pick. The next two subsections cover how.
How machines get their IDs
Everything above assumed each generator already knows its 10-bit machine ID. That number is the one piece of coordination Snowflake cannot avoid, so the real design question is who hands it out, and how you know no two live workers hold the same one.
There are five ways to answer it, and they trade operational simplicity against how well they survive autoscaling.
| Approach | How a worker gets its ID | Survives autoscaling? | Failure that bites |
|---|---|---|---|
| Static configuration | An environment variable or config file per instance | No — every new instance needs a human or a script | A copied config file gives two instances the same ID |
| Derive from infrastructure | Low bits of the private IP, or a Kubernetes StatefulSet ordinal (pay-7 → 7) |
Yes, within limits | IPs get reused, and low bits collide across subnets; ordinals only exist on StatefulSets |
| Coordination-service lease | Claim a node in ZooKeeper or a key in etcd that disappears when the session or lease ends | Yes | Another cluster to run; still need a cooldown before an ID is reused |
| Database registration or lease | Insert a row on start-up (a new ID per restart), or lease one row out of a fixed pool | Yes | Pool exhaustion if IDs are never reclaimed; stale owners if leases are not fenced |
| Shard as the machine ID | The logical shard that stores the row supplies the bits, and the ID is generated inside the database | Yes — shards do not autoscale | Ties the ID to the sharding scheme; resharding must keep logical shard numbers |
The systems that describe theirs publicly cover most of the table:
| System | What it does |
|---|---|
| Twitter Snowflake (the original) | Workers registered in ZooKeeper; on start-up a worker checked that no one else held its ID and sanity-checked its clock against its peers |
| 41 bits of time, 13 bits of logical shard ID, 10 bits of sequence, generated by a PL/pgSQL function inside each PostgreSQL shard — the shard is the machine ID | |
| Sonyflake | Defaults the 16-bit machine ID to the low 16 bits of the private IP address; time is in 10 ms units to stretch the range |
| Baidu UidGenerator | A WORKER_NODE table with an auto-increment key: every start-up inserts a row and takes the new number, so IDs are never reused. That burns IDs fast, which is why it gives the worker field about 22 bits and counts time in seconds |
| Meituan Leaf | Persistent sequential nodes in ZooKeeper give each host a stable worker ID, cached on local disk so a ZooKeeper outage does not stop a restart; the host also reports its time and refuses to start if its clock is behind what it last reported |
| Discord | Publishes the layout (timestamp from its own epoch, worker, process, increment) but not how workers are assigned |
The interviewer then asks three follow-ups, and each one maps to a row:
- "What stops two instances getting the same worker ID?" — The assignment has to be a single atomic decision in one place: a conditional write, a ZooKeeper node, a unique constraint. Anything an instance works out on its own (a random number, a hash of the hostname) can collide.
- "When can a worker ID be reused?" — Not the moment its owner dies. Only after the old owner can no longer be minting with it, plus a margin longer than any clock skew, so the new owner cannot re-enter milliseconds the old one used.
- "You run on ECS Fargate or plain autoscaling groups — there are no ordinals and the IPs are recycled." — Then derived IDs are out, and you need a lease: from ZooKeeper or etcd if you run one, or from the database you already have.
If none of that is worth it, change the ID, not the coordination: UUIDv7 or ULID need no machine ID at all, at the cost of 128 bits instead of 64.
The line to say: "The machine ID is the only coordination in Snowflake. I would lease it, not configure it — from etcd if we already run it, otherwise from a lease table in the database we already have — with a cooldown before reuse and a worker that stops minting if it cannot renew. If 128 bits is acceptable, UUIDv7 removes the problem."
The database lease table, in depth
For a payment service that already runs on Postgres, the lease table is usually the right answer: no new infrastructure, one SQL statement to claim, one to renew. It is the same lease R8 builds for a distributed lock, with one difference — instead of one named lock, it is a pool of 1,024 leases, one per worker ID, and a starting task takes whichever one is free.
The schema
CREATE TABLE worker_lease (
worker_id smallint PRIMARY KEY CHECK (worker_id BETWEEN 0 AND 1023),
owner text, -- task ID, NULL when free
epoch bigint NOT NULL DEFAULT 0, -- +1 on every claim
expires_at timestamptz NOT NULL DEFAULT '-infinity'
);
INSERT INTO worker_lease (worker_id)
SELECT g FROM generate_series(0, 1023) AS g; -- seeded once, never deleted
Three design choices carry the whole scheme:
| Choice | Why |
|---|---|
| One row per possible ID, seeded up front | Claiming becomes an UPDATE of an existing row, so the primary key itself guarantees there are never two rows for worker 7 |
| An epoch that only goes up | It identifies this particular tenancy of the ID. A renewal must present the epoch it was given, so an old owner can never renew a lease someone else now holds |
Expiry judged by the database's now() |
Every task is measured against one clock. A task with a fast or slow clock cannot decide on its own that a lease has expired |
Claiming
UPDATE worker_lease
SET owner = :task, epoch = epoch + 1,
expires_at = now() + interval '30 seconds'
WHERE worker_id = (
SELECT worker_id FROM worker_lease
WHERE expires_at < now() - interval '10 seconds' -- expired AND cooled down
ORDER BY expires_at
LIMIT 1
FOR UPDATE SKIP LOCKED)
RETURNING worker_id, epoch;
ORDER BY expires_attakes the row that has been free the longest, so a just-released ID is the last to be reused.FOR UPDATE SKIP LOCKEDis what makes concurrent start-ups safe and fast: if twenty tasks start together, each locks a different row instead of queuing behind one.- No row returned means the pool is exhausted. The task must fail start-up loudly. Falling back to a random or default worker ID is exactly how duplicates are born.
Renewing, and fencing yourself
The task renews every 10 seconds — a third of the TTL, so two renewals can fail before the lease is in danger:
UPDATE worker_lease
SET expires_at = now() + interval '30 seconds'
WHERE worker_id = :id AND owner = :task AND epoch = :epoch;
-- 1 row: still mine
-- 0 rows: someone else owns this ID now — stop minting, claim a new one
A renewal that succeeds even after the lease has technically expired is still safe: a matching epoch proves nobody claimed the ID in the gap.
The hard case is not a task that dies — it is a task that pauses and wakes up.
A long GC pause or a network partition stops renewals while the process keeps running. R8 solves this for locks with a fencing token that the protected resource checks. Here there is no such resource: IDs are minted in memory, and nothing downstream can reject a write because it carries an old epoch. So the worker has to fence itself:
- Before sending each renewal, note the time on the monotonic clock (
System.nanoTime(), never wall time). - When the renewal succeeds, set a minting deadline of that start time plus 20 seconds — comfortably inside the 30-second TTL.
- Every mint checks the deadline. Past it, minting throws until a renewal succeeds again.
Because the deadline is measured from before the request was sent, the worker always stops minting before the database could consider the lease expired, however long the request or the pause took. The cooldown then adds a further 10 seconds before anyone else can claim the ID. Together they guarantee that the old and new owners of worker 7 are never both minting, and never minting into the same milliseconds.
Why the cooldown exists
Expiry is judged on the database clock, but the IDs are stamped with each task's own clock. If the old owner's clock ran 3 seconds fast, its last IDs carry timestamps 3 seconds in the future. A new owner that started minting the instant the lease expired could produce those same timestamps under the same worker ID. The cooldown must be longer than the worst clock skew you tolerate between tasks, and that skew limit should be enforced — a task whose clock is more than a few seconds off from the database's now() at claim time should refuse to start.
What happens when the database is down
| Situation | Behaviour |
|---|---|
| Running task, database briefly unreachable | Keep minting until the deadline; renewals resume when it returns. IDs need no database call per ID, so a short outage costs nothing |
| Running task, outage longer than the deadline | Minting stops. That is the correct, safe failure — the write path needs the same database anyway |
| New task starting during an outage | Cannot claim, so cannot start. Autoscaling stalls, running capacity is unaffected |
| Graceful shutdown | Stop minting, then release: UPDATE … SET owner = NULL, expires_at = now() WHERE worker_id = :id AND owner = :task AND epoch = :epoch. The cooldown still applies |
Size the pool so it never runs dry: 1,024 IDs must exceed peak running tasks plus everything sitting in expiry and cooldown after a deployment or a crash loop. Alert when free IDs fall below 10%.
Step through the full lifecycle below — the claim, a race between two start-ups, renewal, a crashed task, a paused one that wakes up as a zombie, and a graceful release. Watch the epoch column: it is the thing that turns the zombie's renewal into zero rows.
The Spring sketch
@Component
public class WorkerIdLease implements SmartLifecycle {
private static final long SAFE_NANOS = Duration.ofSeconds(20).toNanos(); // TTL is 30 s
private final JdbcTemplate jdbc; // CLAIM_SQL, RENEW_SQL, RELEASE_SQL: the statements above
private final String task = System.getenv().getOrDefault("TASK_ID", UUID.randomUUID().toString());
private final ScheduledExecutorService timer = Executors.newSingleThreadScheduledExecutor();
private volatile int workerId = -1;
private volatile long epoch;
private volatile long mintUntil; // System.nanoTime() deadline
private volatile boolean running;
public WorkerIdLease(JdbcTemplate jdbc) { this.jdbc = jdbc; }
@Override public void start() {
claim(); // fail start-up if the pool is empty
timer.scheduleAtFixedRate(this::renew, 10, 10, TimeUnit.SECONDS);
running = true;
}
private record Lease(int id, long epoch) {}
private void claim() {
long sent = System.nanoTime();
Lease got = jdbc.query(CLAIM_SQL,
rs -> rs.next() ? new Lease(rs.getInt(1), rs.getLong(2)) : null, task);
if (got == null) throw new IllegalStateException("no free worker ID");
epoch = got.epoch();
mintUntil = sent + SAFE_NANOS;
workerId = got.id(); // published last: minting can start
}
private void renew() {
long sent = System.nanoTime();
try {
if (workerId >= 0 && jdbc.update(RENEW_SQL, workerId, task, epoch) == 1) {
mintUntil = sent + SAFE_NANOS;
} else {
workerId = -1; // lost it: never resume with the old ID
claim();
}
} catch (RuntimeException e) {
// database down or pool empty: keep the old deadline, minting stops on its own,
// and the next tick tries again (an escaping exception would cancel the schedule)
}
}
/** Called by the Snowflake generator on every mint: one volatile read and a clock check. */
public int workerIdForMinting() {
int id = workerId;
if (id < 0 || System.nanoTime() - mintUntil > 0) throw new WorkerIdUnavailableException();
return id;
}
@Override public void stop() {
running = false;
int id = workerId;
workerId = -1; // stop minting first
timer.shutdownNow();
if (id >= 0) jdbc.update(RELEASE_SQL, id, task, epoch);
}
@Override public boolean isRunning() { return running; }
}
Give the renewal a statement timeout shorter than the renewal interval, so a hung connection cannot hold the scheduler past the next tick. When the worker ID changes after a lost lease, the generator starts a fresh sequence space — the new ID makes the old millisecond buckets irrelevant.
The same pattern on DynamoDB or etcd
| Store | Claim | Renew | What changes |
|---|---|---|---|
| DynamoDB | Pick a random worker ID and PutItem with ConditionExpression: attribute_not_exists(owner) OR expires_at < :cutoff; on a conditional failure try another |
UpdateItem conditioned on owner = :me AND epoch = :e |
There is no server clock — :cutoff comes from the caller's clock, so the cooldown must also cover skew between callers |
| etcd | A transaction that creates /workers/7 only if it does not exist, attached to a lease with a TTL |
Lease keep-alive | Expiry is handled for you, and the key's revision serves as the epoch |
| ZooKeeper | An ephemeral node per worker ID | Session heartbeats | The node vanishes when the session ends; you still need your own cooldown before reuse |
The defensible position: lease worker IDs from a pool in the database you already run, claim with one conditional update judged on the database clock, renew at a third of the TTL with an epoch check, stop minting on a local monotonic deadline before the lease can expire, and wait out a cooldown longer than clock skew before any ID is reused.
How this ties back to ordering
A time-ordered ID is tempting to use as an ordering key. Resist it. Within one machine, Snowflake IDs strictly increase. Across machines they are ordered by each machine's own clock, which is exactly the ordering the previous section ruled out. The timestamp inside an ID is for index locality and rough sorting, not for deciding what happened first. Where order matters — events on one payment, entries in one account — use a sequence the system issues, as principle 7 says.
IDs, idempotency keys and payments
Three different things get called "the ID" in a payment flow, and conflating them is a real bug.
| What it names | Who creates it | When | |
|---|---|---|---|
| Payment ID | The payment, once it exists | The server, locally (Snowflake, ULID, UUIDv7) | When the record is first written |
| Idempotency key | The client's intent to make one payment | The client, or derived deterministically | Before the first attempt, and reused on every retry |
| Ordering sequence | Position within a stream or an entity | The system — a partition offset, a version column | On every change |
The part people miss: an idempotency key is not an ID you generate on arrival. If the server mints a fresh payment ID on every attempt and treats that as the deduplication key, every retry becomes a new payment. The key has to exist before the first request leaves the client, so that the retry carries the same one. R3 covers how keys are scoped, stored and derived.
Two more consequences for the design:
- Sequential keys are kind to one B-tree and cruel to a range-sharded table. Every new write lands on the newest range, so one shard runs hot while the rest sit idle. R10 covers choosing a shard key that does not inherit this.
- Time-ordered IDs leak. A Snowflake or ULID reveals when it was created, a v1 UUID reveals the machine, and consecutive numbers reveal your volume. For identifiers you hand to customers or partners, decide whether that matters before you expose the internal key.
The defensible position: payment IDs are 64-bit Snowflake-style or 128-bit time-ordered IDs, minted inside the service with no central coordinator; idempotency keys come from the client and survive retries; and ordering comes from a sequence the system issues, never from the timestamp inside an ID.
Where the story continues
Each beat of the narrative above becomes a section of its own.
| # | Topic | What the section covers | Why it sits here |
|---|---|---|---|
| R0 | Foundations | What a distributed system can and cannot promise: the three delivery guarantees, the difference between delivery, processing and effect, ordering without a shared clock, and minting unique IDs without a coordinator. | Nothing later can be argued without this vocabulary. Every mechanism in the pack is a response to a limit named here. |
| R1 | The building blocks | An overview that follows one payment request through every part, then five parts: stateful and stateless services, API design, what a connection is and how a request travels from the kernel through Tomcat to your code, gateways and how they differ from reverse proxies and load balancers, how clients and services talk in real time (polling, server-sent events, WebSockets, webhooks), and the architecture styles that arrange them: N-tier, monolith to microservices, broker versus service bus, and event sourcing. | The parts every later diagram is drawn with. The synchronous and asynchronous distinction above becomes concrete here, before the pack starts asking what goes wrong between those parts. |
| R1.1 | Connections and request handling | Connections, sockets and ports; one request through the kernel and Tomcat; every thread and connection pool; sizing them; overload; virtual threads. | First, because pools, timeouts and the first scaling wall (R11, R13, R14) all build on it, and the parts after it assume it. |
| R1.2 | Where state lives | Stateful, stateless and hybrid services; sticky sessions versus a shared session store. | Every later design assumes any instance can serve any request. |
| R1.3 | Gateways | Reverse proxy, load balancer and API gateway; what a gateway does and costs; how they are stacked. | The front door every design round draws first. |
| R1.4 | The API contract | Resources, methods, status codes, pagination, versioning, the error model; polling, SSE, WebSocket and webhooks. | The contract the rest of the pack's retries and idempotency keys are written against. |
| R1.5 | Architecture styles | N-tier; monolith, modular monolith and microservices; broker versus ESB; event sourcing. | How the parts above are arranged, before the pack asks what goes wrong between them. |
| R2 | Atomicity across machines | Why all-or-nothing stops being free once work spans systems; two-phase commit, XA, TCC, and the availability cost that rules them out. | It comes first in the correctness argument because it is the thing you cannot have. R3, R4 and R5 only make sense as workarounds for its absence. |
| R3 | Idempotency | Making a retried operation take effect once: key scoping and storage, phases and recovery points, safe replay. | Retries are the only recovery mechanism available, and retries produce duplicates. You must neutralise duplicates before you can safely build anything that retries. |
| R4 | Outbox, relay and inbox | Committing the record of an event with the state change, publishing it at-least-once, and dropping duplicates at the receiver. | It depends on R3: the outbox deliberately buys at-least-once delivery, which is only survivable because the consumer is already idempotent. |
| R5 | Sagas and compensation | Undoing work across services when a failure is final: compensations, the point of no return, and what to do when the undo itself fails. | Follows R4 because the steps of a saga are usually the messages R4 makes reliable. It is also the last preventive mechanism — after this, prevention runs out. |
| R6 | Reconciliation | The record you keep (double-entry, append-only), then detecting divergence that nothing prevented: matching, break classes, break age — and how replicated stores reconcile themselves with read repair, anti-entropy and Merkle trees. | Placed immediately after the preventive chain, because its whole justification is that R3, R4 and R5 are never complete. |
| R7 | Building a reconciliation system | The six-stage pipeline, the data model, and the controls that make auto-repair safe at scale. | Separated from R6 so the concept is settled before the engineering. This is the follow-up question when an interviewer likes the R6 answer. |
| R8 | Coordination | Consensus and Raft, leader election, and distributed locks: leases, fencing tokens, and the implementations worth naming — plus why isolation is the ACID property you actually lose. | Atomicity was R2 to R5; this is the second property that does not survive the network. It needs R3's lease pattern and R6's reconciliation as its fallback, and consensus is what the lock services themselves stand on. |
| R9 | Messaging | Queues against pub/sub, ordering by partition and message group in Kafka and SQS, rebalances, and exactly-once semantics inside a broker. | The transport under R4 and R5. It comes after them so you meet the mechanics once you already know what they are for, and it opens the data and messaging part of the pack. |
| R10 | Storing data | Choosing a store, replication, partitioning and sharding, resharding, consistent hashing and Bloom filters. | Where state actually lives, and the first place the sequential IDs from R0 come back to bite as a hot shard. Every later decision about performance and scale assumes a store has been chosen. |
| R11 | Read and write performance | What to do when the database is the bottleneck: indexes, query shape, pagination, batching, hot rows, read replicas, CQRS and connection pooling. | Follows R10 because you tune the store you chose. It comes before caching because the cheapest fix is usually inside the database you already run. |
| R12 | Caching | When to cache and what, the read and write strategies, TTLs and eviction, and the races between cache and database. | Last in the data part because a cache is a second copy of data, so everything R6 says about divergence applies to it — and it is only worth adding once R11's cheaper fixes are spent. |
| R13 | Scale and multi-region | What breaks first as volume grows, how capacity is reasoned about, and where writes are homed across regions. | Everything above is correct at low volume. This is where those designs meet load, and where partitioning quietly removes the need for R8's locks. |
| R14 | Resilience and traffic control | Load balancing, timeouts, retries, circuit breakers, bulkheads, backpressure, rate limiting and scatter-gather. | Scale multiplies the number of dependencies that can be slow at once. It sits after R13 because these are the controls that keep one slow part from taking the rest down — and every retry here is only safe because of R3. |
| R15 | Observability, SLOs and incidents | Golden signals, SLOs, availability and latency budgets, correlation across async hops, the business metrics that outrank system metrics, and incident practice. | You cannot operate any of the above without knowing when it silently broke — and its headline alert, reconciliation lag, comes straight from R6. |
| R16 | Zero-downtime change | Expand-contract, strangler fig, shadow running, and the payments-specific deployment rules. | All of this has to keep running while you change it. It closes the operations part because it presupposes the schemas, consumers and sagas that earlier sections introduced. |
| R17 | Infrastructure design walkthroughs | A rate limiter, an API gateway, a distributed scheduler and a key-value store, each designed end to end. | The first design rounds, placed straight after the mechanisms so they read as the mechanisms assembled. Each one is R1, R8, R10 and R14 built into a single component — the platform any payments system runs on. |
| R18 | Stateful real-time servers | WhatsApp, Google Docs, a stock broking platform and Zoom: state that must live in one server's memory — connections, a live document, an account's risk — and how to route to it, own it, persist it and fail it over. | The product rounds open with the problem R1.2 told you to avoid. They are grouped by engineering concern rather than industry, starting with state that cannot leave the server, because the later sections reuse its one-owner idea. |
| R19 | Reservations and contended inventory | BookMyShow, Amazon and Uber: never selling the same seat, unit or driver twice — atomic claims, holds that expire, a payment in the middle, and stampedes. | Follows R18 because a broker blocking margin is already a reservation. Puts R2's try–confirm–cancel, R3, R5 and R8's conditional updates under contention. |
| R20 | Large content: storing, syncing and delivering bytes | Dropbox and Netflix: metadata split from bytes, chunks named by their hash, sync through an ordered journal, and delivery from the edge. | After the small, strict state of R18 and R19, the opposite pressure: objects so big that moving the bytes is the problem. R10 and R12 at exabyte scale. |
| R21 | High-volume data flow: fan-out and fan-in | Instagram, live scores and notifications as three kinds of fan-out, and Datadog as their mirror image: millions of hosts into one monitoring platform. | Last of the general rounds: billions of tiny events, where the shape of the flow and its skewed tail decide the design — and R15 seen from the inside as the system that ingests everyone else's telemetry. |
| R22 | Lookup and discovery | A URL shortener, typeahead, proximity search and a web crawler: read-heavy lookups won by choosing the key and precomputing the answer. | The common warm-up questions, last of the general rounds: they reuse caching (R12), key-value storage (R17) and fan-in pipelines (R21), and end with the crawler that builds the index. |
| R23 | Low-level design: principles and patterns | How to run an object-oriented design round, SOLID, and the 23 classic patterns with Java examples. | A different round with a different bar — classes rather than boxes. It sits after the HLD material because many patterns (Strategy, Observer, Command, State) are the same ideas at class level. |
| R24 | Low-level design case studies | ATM, elevator, snake and ladder, chess, Blackjack, Splitwise, meeting scheduler, Stack Overflow, car rental and airline management. | R23 applied, ordered from state machines to concurrency, game rules, and finally money and bookings — where R19's hold-confirm-release reappears at class level. |
| R25 | Rails and ISO 20022 | How each rail differs on speed, reversibility and batching, and the message families you will be asked to name. | The payments domain begins here. It arrives once the general mechanisms and designs are settled, so the rails read as constraints on them rather than as trivia. |
| R26 | Cutoffs, kick-off and warehousing | Business date versus value date, clearing windows, start-of-day and end-of-day jobs, and holding payments until their moment. | The one concern not driven by requests at all. It also reuses nearly everything: idempotent jobs, leases, reconciliation, and the rails from R25. |
| R27 | Payment processing | The regulatory checks, the account types, and what book, inbound and outbound payments actually do — activity by activity and posting by posting. | After the rails and the calendar, before the payments round: this is the domain those mechanisms exist to serve, and R28 assumes all of it. |
| R28 | The JPMC senior manager round | Three systems designed end to end — Venmo, UPI, FX and cross-border — plus the wider question bank and how to structure 45 minutes. | Last, because it is the round this pack is built for and it uses everything: the mechanisms of R0 to R16, the design habits rehearsed in R17 to R22, and the domain of R25 to R27. |
The pack falls into seven parts. Part I, the foundations (R0–R1), is the vocabulary and the parts: what can and cannot be promised, and the services and APIs every later diagram is drawn with. Part II, correctness across machines (R2–R8), is one argument, and reading it in order is the point: atomicity is unavailable, so you accept duplicates (R3) in order to eliminate loss (R4), and you undo by compensation rather than rollback (R5). R6 and R7 exist because none of that is ever complete, and R8 is the other property you lost — isolation. Part III, data and messaging (R9–R12), is where events travel and where state lives: the broker, the store, how to make it fast, and the cache in front of it. Part IV, scale and operations (R13–R16), is what running the result actually takes — volume, failing dependencies, knowing when it silently broke, and changing it without stopping it. Part V, the general design rounds (R17–R22), is where those mechanisms first get used: infrastructure first, then the product systems an interviewer reaches for outside payments, grouped by the engineering concern they share — stateful servers, reservations, large content, high-volume data flow, and lookup. Part VI, low-level design (R23–R24), prepares the object-oriented round: principles, patterns and ten case studies. Part VII, payments (R25–R28), turns the general mechanisms into rails, cutoffs and postings, and ends with the payments round itself.
What to say in the interview
"Exactly-once delivery isn't available — the two generals problem rules it out — so I design for at-least-once delivery and exactly-once effect, and I reconcile the residue. That single choice explains the rest: duplicates are recoverable and loss isn't, so I accept duplicates deliberately and then make them harmless."