A Snowflake ID is sold as collision-free with zero coordination. It coordinates with other machines for free and with its own past not at all — and under a liveness probe, Twitter's "refuse to generate an id" produces 390 duplicates where doing nothing produces 400. A study guide for snowflake.py.
next_id(now) never reads a clock, the module has no imports at all — not even time — and a tilNextMillis block is an exception the caller resolves by advancing the simulation's own counter — so a 50 ms stall costs no real seconds and the whole demo runs in under a second, byte-identically on every machine. The one RNG (§6.8 only) is seeded. No dependencies, stdlib only. Every transcript below was captured on macOS 26.5.2 (Darwin 25.5.0, arm64 Apple Silicon), Python 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3].
cd unique-id-generator
python3 demo.py # the aha (§6) — about half a second
python3 test_snowflake.py # pins every number this page claims
This toy is a Snowflake-style unique ID generator: the thing that hands out primary keys in a system with no central sequence. One 64-bit integer, three fields:
The mechanism it teaches is not the bit packing — that is one shift and one mask. It is the question the packing quietly leaves open: two IDs from the same machine in the same millisecond differ only in the sequence counter, and the sequence counter is only correct because the generator remembers which millisecond it was last in. That memory is self.last_ts, it lives in RAM, and every "no coordination needed" claim rests on it.
Three policies for a clock that goes backwards, all implemented and all priced against one trace:
blind — no defence; hand out an old millisecond again.refuse — raise until the clock catches up. This is what Twitter's original IdWorker does.clamp — ts = max(now, last_ts); keep issuing under the last millisecond used, spending sequence numbers instead of time.The shortest version of the result:
from snowflake import BLIND, REFUSE, CLAMP, run
for policy in (BLIND, REFUSE, CLAMP):
print(policy, run(policy, fail_after=10).dupes)
# blind 400
# refuse 390
# clamp 0
fail_after=10 is a liveness probe: ten consecutive failures and the supervisor replaces the process. That one line is the whole toy.
By the end you should be able to derive 400 and 390 from the trace, say which line makes clamp immune to the probe, explain why persisting the generator's memory is still not enough, and work out how much clock error your own throughput can absorb before the generator stalls.
You need a primary key. A database sequence gives you one, and it gives you a round trip and a single point of failure with it. A random UUID avoids both and costs you 128 bits and index locality — random keys scatter inserts across a B-tree instead of appending to one hot leaf. Snowflake IDs are the answer that says: 64 bits, no round trip, and sorted enough that inserts stay local.
The competing goals that make more than one design defensible:
next_id that talks to another machine, or to disk, is a latency and an availability problem. The entire selling point is that the ID comes out of local state.These pull against each other in exactly one place. Coordination-free means "decided from local state". Local state means "lost on restart". And a restart is not a rare event — it is what a healthy production system does automatically, in response to failures, which is the loop this toy closes.
| Concept | Where it's used in the toy | Link |
|---|---|---|
| Bit packing and field masks | pack / unpack; the mask is why machine 1025 is machine 1 |
Discord: snowflakes |
| Wall clock vs. monotonic clock ⭐ | Wall.read(t) — true time t is the simulation's, the generator only sees the wall |
chrony: makestep |
| NTP step vs. slew ⭐ | step_at / step_ms: one discontinuous jump backwards |
chrony: makestep |
| Sequence as an intra-millisecond tie-break ⭐ | the now == self.last_ts branch of next_id |
RFC 9562 §6.2, monotonicity |
tilNextMillis / spin-to-next-ms |
Spin, raised by _overflow, resolved by run |
Twitter IdWorker.scala |
| Liveness probe / failure threshold ⭐ | run(fail_after=K): K consecutive refusals and the process is replaced |
Kubernetes: probes |
| Process state vs. configuration ⭐ | _spawn: machine_id survives a restart, last_ts does not |
MongoDB ObjectId |
| Leap smear | why production clocks mostly don't step, which is why this bug is rare and awful | Google: leap smear |
Four rows carry the result. The generator's whole defence lives in comparing a wall clock reading against remembered state; an NTP step is what makes that comparison fire; a liveness probe is what deletes the state mid-defence; and the split between configuration and process state is what decides whether a restart is harmless or catastrophic.
One ID, and the two variables that decide whether it is unique:
And the trace the whole page runs on — one machine, 8 IDs/ms, and one 50 ms step backwards at true time 100:
Everything in §6 is a different answer to "what should the generator do during that middle stretch, and what happens if it dies in there."
class Snowflake:
def __init__(self, machine_id, epoch=0, ts_bits=41, mid_bits=10,
seq_bits=12, rewind=BLIND, overflow=SPIN):
assert 1 + ts_bits + mid_bits + seq_bits == 64
self.machine_id = machine_id
self.epoch = epoch
self.ts_bits, self.mid_bits, self.seq_bits = ts_bits, mid_bits, seq_bits
self.seq_mask = (1 << seq_bits) - 1 # 4095 slots per ms at 12 bits
self.mid_max = (1 << mid_bits) - 1 # 1023 at 10 bits
self.rewind = rewind
self.overflow = overflow
# The amnesia. Everything above came from config and survives a
# restart; these two are the state, and they do not.
self.last_ts = -1
self.seq = 0
self.refusals = self.spins = self.borrowed = 0
The comment is the argument of the whole page, so it is worth being precise about why the line above it and the line below it are different. machine_id comes from a config file, an environment variable, a pod ordinal — some external authority that remembers. last_ts comes from the last call to next_id and lives nowhere else. Both are equally load-bearing for uniqueness. Only one of them survives kill -9.
self.last_ts = -1 is the specific line that makes a fresh process believe whatever the clock tells it. Every result in §6.3 and §6.4 is that line.
def next_id(self, now):
"""`now` is a wall-clock reading in ms, supplied by the caller.
Returns (id, ts); raises Exhausted or Spin when a policy says so.
"""
if now < self.last_ts:
# The clock went backwards. Somebody has to decide what that means.
if self.rewind == REFUSE:
self.refusals += 1
raise Exhausted("clock moved backwards by %d ms"
% (self.last_ts - now))
if self.rewind == CLAMP:
now = self.last_ts # pin to the highest ts ever issued
# BLIND: fall through and cheerfully reuse an old millisecond
if now == self.last_ts:
self.seq = (self.seq + 1) & self.seq_mask
if self.seq == 0: # wrapped: 4096 IDs spent in this ms
return self._overflow(now)
else:
self.seq = 0
self.last_ts = now
return self.pack(self.last_ts, self.seq), self.last_ts
Taking the clock as an argument is the design decision that makes this toy possible: now is data, so a 50 ms NTP step is a subtraction in the caller rather than a mocked syscall, and the boundary cases (now exactly equal to last_ts) are reachable on purpose instead of by luck.
Three things about the strictness of <. First, clamp is written as an assignment to a local now, not to self.last_ts — the generator does not believe the clamped time, it just refuses to go below it, and the very next line handles the resulting equality naturally. Second, the else branch resets seq to 0, which is what makes an ID reproducible: same millisecond, same starting sequence, same ID. That is the reason duplicates happen, and §6.6 deletes the line to prove it.
Third, and least obvious: now == self.last_ts is itself a defence, and it is the one nobody names. A rewind that lands exactly on the millisecond the generator is already in never reaches the backwards branch at all — it takes the increment path and keeps counting. That single == is why a 1 ms rewind costs zero duplicates under every policy including blind (§6.7), and why refuse refuses rate × (rewind − 1) requests rather than rate × rewind.
def _overflow(self, _now):
if self.overflow == BORROW:
self.borrowed += 1
self.last_ts += 1 # a millisecond that has not happened
self.seq = 0
return self.pack(self.last_ts, self.seq), self.last_ts
self.spins += 1 # tilNextMillis, as an exception
self.seq = self.seq_mask
raise Spin(self.last_ts + 1)
Twitter's version blocks here in a while loop until System.currentTimeMillis moves on. Blocking is untestable and slow, so the toy raises Spin(until) and makes the caller advance time — same semantics, no seconds spent, and the stall becomes a number the demo can print instead of a delay a reader has to believe. self.seq = self.seq_mask before raising is the important line: the generator stays wrapped, so a retry that somehow lands in the same millisecond increments back to 0 and re-raises rather than handing out sequence 0 twice.
borrow is the third answer nobody implements: issue a millisecond that has not happened. It never stalls, and it pays by letting timestamps run ahead of the clock — measured at 49 ms ahead in §6.7.
class Wall:
"""wall(t) = t, except from `step_at` onwards, where it jumps back `step_ms`.
`t` is the simulation's own monotonic millisecond counter -- true time,
which no process can read. The generator only ever sees `read(t)`, so an
NTP step backwards is one subtraction rather than a mocked syscall.
"""
def __init__(self, step_at=None, step_ms=0):
self.step_at, self.step_ms = step_at, step_ms
def read(self, t):
if self.step_at is not None and t >= self.step_at:
return int(t - self.step_ms)
return int(t)
Two clocks, and the distinction between them is the entire experimental apparatus. t is true time — a float, monotonic, known only to the simulation. read(t) is what a process gets from System.currentTimeMillis: an integer, and a lie for a 50 ms window. Keeping both lets the transcript say "at true t=101.125 the wall read 51", which is the sort of sentence you cannot write from inside a real process at all.
def _spawn(cfg, store, start_rule, cls):
"""A fresh process. Config survives; `last_ts` is whatever startup says.
RAM is the real generator: a new process starts at -1 and believes the
clock. RESUME and SKIP are the durable variants -- see the commentary for
why the obvious one of the two is still wrong. `cls` exists so a demo can
run a one-line variant of the generator down the identical trace.
"""
gen = cls(**cfg)
if start_rule == RESUME and store["hwm"] >= 0:
gen.last_ts = store["hwm"]
elif start_rule == SKIP and store["hwm"] >= 0:
gen.last_ts = store["hwm"] + 1
return gen
A restart is modelled as cls(**cfg) — the config dictionary is reapplied verbatim, and nothing else crosses. That is the honest model: the pod spec still says machine_id=7, and the heap is gone. The two durable rules poke last_ts from outside rather than living in __init__, which keeps them visibly external to the mechanism; a generator that reads a store at startup is a different thing from a generator, and §6.4 is about what that difference costs.
while True:
w = wall.read(t)
try:
ident, _ts = gen.next_id(w)
except Exhausted:
records.append((t, w, None, REFUSED))
consecutive += 1
if fail_after and consecutive >= fail_after:
borrowed += gen.borrowed
gen = _spawn(cfg, store, start_rule, cls)
restarts += 1
restart_log.append((t, w))
consecutive = 0
t_free = t
break
except Spin as spin:
nxt = wall.reaches(spin.until, t)
stalled += nxt - t
t = nxt
continue
consecutive is a liveness probe in one variable: count failures, reset on success, replace the process at the threshold. Kubernetes' own example sets failureThreshold: 3 and its docs put it plainly — "if a container fails its liveness probe more times than the configured tolerance, the kubelet restarts that container."
Note what the except Exhausted branch does not do: retry. A refusal is a failed request, it is recorded as one, and the caller moves on. That matters because it means the refusal is visible to the supervisor, which is the only reason §6.3 happens at all. A generator whose failures are swallowed by a retry loop three layers up is a generator whose defence never triggers the probe — and, per the same argument, never gets fixed either.
The trace, throughout: machine 7, 8 IDs/ms for 200 ms — 1600 requests offered — and one 50 ms NTP step backwards at true t=100 ms.
241 milliseconds is 69.68 years, so a scheme anchored at Twitter's epoch of 1288834974657 ms (2010-11-04) overflows its timestamp field on 2080-07-10. Note the shape of that number: it is a property of the bit budget, and every bit you move to another field costs you half of it.
The aliasing is the same arithmetic seen from the machine field. machine_id & 1023 is a mask, not a check, so the 1025th machine you provision is silently the 1st, and the two of them will hand out identical IDs forever with no error anywhere. Both facts are pinned by test_snowflake.py::forty_one_bits_of_milliseconds_runs_out_in_2080 and ::the_machine_field_is_masked_not_checked.
Derive the 400. The wall clock re-covers milliseconds 50 through 99 — 50 milliseconds of the past — and the generator, having reset seq to 0 on each apparently-new millisecond, replays the identical sequence in each: 8 IDs/ms × 50 ms = 400. The very first collision is the whole story in one line: ID 209743872 is (ts=50, machine=7, seq=0), and it was issued at true t=50.000 and again at true t=100.000, 50 ms apart, by a generator that never saw an error.
Derive the 392. Not 400, and the difference is instructive. Refusals run while the wall reads strictly below last_ts = 99, which is true from true t=100 up to but not including t=149, where the wall reads exactly 99 again. That is 49 ms at 8/ms = 392, and 1600 − 392 = 1208 issued. The general form is rate × (rewind − 1) — verified at rewinds of 2, 5, 10 and 50 ms in ::refuse_buys_zero_duplicates_with_392_refusals. The missing millisecond is the == branch from §5 quietly doing its job.
So far this reads as a clean ranking: blind is broken, refuse is principled and expensive, clamp is free. That ranking survives exactly as long as nobody is watching the process.
Nobody restarts a process at a random instant. They restart it because it started failing. Add a liveness probe — K consecutive failures and the supervisor replaces the process — and the replacement's last_ts is -1.
refuse bought ten IDs.
The closed form is dupes = 400 − K, exact at every K, and the demo derives the K=10 row:
Read 400 − K again, because the algebra is the point. A refusal is one request that did not become a duplicate. The policy converts exactly K of them, and then the Kth one triggers the restart that deletes the state making it correct. Everything after that is blind. The defence's total lifetime value is K IDs, whatever K is, and K is a number in a YAML file that nobody chose with this in mind. test_snowflake.py::the_supervisor_turns_the_refusal_into_400_minus_k holds it at K ∈ {1, 2, 3, 5, 10, 25, 50, 100, 200, 390, 391, 392}; at K = 393 there aren't 393 refusals to be had, no restart fires, and the count drops back to 0.
The asymmetry in the table is the sharp bit. refuse is the only policy that causes its own restart: it converts a clock problem into a stream of request failures, and a stream of request failures is exactly what a supervisor is built to act on. clamp never refuses, so no value of K can fire — its restarts column is 0 0 0 0 0 — and blind never refuses either, so the probe cannot help it. A defence that signals failure has enrolled the platform in its own destruction; a defence that stays quiet is invisible to the platform in both directions.
Restart the process at a chosen instant instead — a deploy at true t=120, 20 ms into the rewound window — and compare three startup rules.
Row A (last_ts = -1) is the real generator, and it makes refuse and clamp indistinguishable: 240 duplicates each, because the replacement believes the rewound clock and reissues wall milliseconds 70 through 99 — 30 ms × 8 = 240. Whatever the old process had decided about the clock died with it.
Row B is the obvious fix — write the high-water mark down, read it back at startup — and it is still wrong: 167 duplicates under clamp. The derivation is in the transcript. The old generator did not merely reach millisecond 99; it spent 168 sequence slots inside it (8 genuine requests, then 20 ms of clamped ones at 8/ms). A replacement that resumes at 99 with seq = 0 walks straight back over 1 through 167. The unit of uniqueness is not the millisecond; it is the (ms, seq) pair, and a durable record of only half the pair is a durable record of nothing.
The refuse column of row B is the same error at a smaller size: 7. That generator refused everything through the window rather than clamping, so it had spent only seq 0–7 in ms 99, and the replacement reissues 1 through 7.
Row C (last_ts = hwm + 1) skips the recorded millisecond outright and reaches 0 — but only with clamp; row C with blind is still 400, because rule C fixes the restart and blind was never defending in the first place. And the price of row C is a durable write on the ID path, at which point the "zero coordination" that justified the whole design is gone. That is the honest end of this thread: you can have coordination-free IDs, or you can have IDs that survive a restart during a clock rewind, and the toy cannot find you a third option.
< vs <=Widening the comparison is inert for clamp — clamping now to last_ts when they are already equal is a no-op — and catastrophic for refuse: issued collapses from 1208 to 150. Derive the 150: the variant refuses any request that shares a millisecond with the previous one, so at 8 IDs/ms only the first request of each millisecond survives. That is 100 IDs over the first 100 ms, then nothing at all until the wall recovers at t=150, then one per millisecond for the remaining 50: 100 + 50 = 150.
The same edit is worth nothing and worth everything depending on which policy is switched on, which is a decent argument for the toy's shape: policies as data, one trace, and the differences read straight off the table.
Delete self.seq = 0 from the new-millisecond branch and the counter runs free across milliseconds. The rewind now costs zero duplicates with no clock defence whatsoever — blind goes from 400 to 0 — because the replayed millisecond 50 gets sequence numbers 400-something instead of 0 through 7.
That result is real and it is not a fix, which the demo then demonstrates rather than asserts:
A free-running counter is not safe across a restart, it is phase-shifted. The old process's sequence is rate × t mod 4096; the replacement's starts at 0. They agree whenever the restart sits a whole number of 4096 / 8 = 512 milliseconds after the step, and at R − rewind = 512 exactly, every remaining millisecond of the window duplicates: (800 − 712) × 8 = 704. Neighbouring restarts, 4 ms either side, cost nothing at all. A safety property that depends on a modular coincidence is not a safety property, and in the 200 ms headline trace the coincidence is simply unreachable — which is how a bug like this stays hidden.
clamp never stalls for free. It absorbs a rewind by spending sequence numbers, so how much rewind a generator can survive is a bit budget question, not a clock question.
80 IDs/ms is free and 81 stalls. Every request in the 50 ms window is pinned to one millisecond — the last one issued before the step — and that millisecond had already spent its own rate slots, so the requirement is rate × (rewind + 1) ≤ 4096. 51 × 80 = 4080 fits; 51 × 81 = 4131 does not, and the generator blocks for 0.432 ms waiting for a millisecond it has run out of room in. Held from the other side, at a fixed 100 IDs/ms, a 39 ms rewind is free and 40 ms stalls (100 × 40 = 4000 ≤ 4096 < 4100).
Which turns the bit split into a different question than it is usually presented as:
Same 50 ms rewind, same 100 IDs/ms, four ways to spend the 63 bits. Moving one bit from the machine id to the sequence takes a 10.040 ms stall to 0.000 and halves the fleet cap. So 41/10/12 is not "1024 machines at 4096 IDs/ms" — it is "1024 machines that can absorb 40 ms of clock error at 100 IDs/ms". The sequence field is sized by your clock discipline as much as by your throughput, and nobody writes it down that way.
borrow opts out of the trade entirely and pays elsewhere: 0 stall, 0 duplicates, and timestamps running 49 ms ahead of the wall clock. IDs from the future sort before events that haven't happened, which is a different bug in a different system.
Where the whole effect vanishes:
A rewind of one millisecond is free for everybody, including the generator with no defence at all. The step lands on the millisecond the generator is already in, now == last_ts holds, and the increment branch handles it — so blind duplicates nothing and refuse refuses nothing. Duplicates are rate × rewind only from 2 ms up. Sub-millisecond clock error, the kind a slewing NTP daemon actually produces, is not a Snowflake problem at all; this whole page is about the step, and steps are rare, which is precisely why the defence against them is untested in every system that has one.
The other half of the sales pitch, measured rather than assumed:
With perfectly synchronised clocks and no rewind at all, 0.2% to 2.2% of ID pairs come out in the wrong order, and every single error is under one millisecond — 0.999 ms at worst, because sub-millisecond ordering is simply not represented in the ID. One machine inverts nothing, ever. "Roughly sortable, where roughly means ±1 ms" is true, cheap, and the least interesting fact about the design; it is in the demo so that it can be dismissed with a number rather than a shrug.
Time as an argument, not a syscall. next_id(now) is slightly awkward to call and buys three things that this toy cannot do without: the demo runs in half a second instead of sleeping through a 50 ms stall; the output is byte-identical on every machine (::the_same_trace_gives_byte_identical_records); and the exact boundary — a rewind that lands precisely on last_ts — is reachable on purpose, which is where §6.7's vanishing point came from. A toy that called time.time() could not have found it.
tilNextMillis as an exception, not a loop. Blocking would be one line shorter and untestable. Raising Spin(until) moves the waiting into the caller, where it becomes a measured stall in milliseconds — which is what makes the 80-vs-81 boundary a table instead of a stopwatch.
One machine, not a fleet. The machine id is the part of Snowflake that gets all the attention and it is the boring part: it comes from config, it never changes, and it makes cross-machine collisions impossible by construction (as long as nobody provisions the 1025th machine). Simulating a fleet would have added parameters and taught nothing the single generator doesn't. Every interesting failure here is a generator colliding with its own past.
Sortability was measured and rejected as the aha. §6.8 is what remains of a headline the backlog originally proposed. It is true — which is exactly why it makes a bad toy. Nothing is surprising in a result that matches the marketing.
Three rewind policies, not one. Shipping only clamp would have hidden the result: the page's argument is comparative, and needs refuse present in working order to show that a worse-behaved generator produces fewer duplicates under a supervisor. blind is not a strawman either — it is what you get when you write the obvious version, and it is the baseline that makes "it bought 10" a sentence.
A liveness probe, rather than a random restart. A restart at a random instant is a coincidence and reads as one. A restart caused by the refusals is a mechanism, and it closes the loop between the defence and the harm. fail_after is eight lines inside run and it is the reason the toy exists.
No UUIDv7 implementation, though it is the obvious rival. RFC 9562 puts 48 bits of Unix ms at the front and 74 bits of randomness behind, so a restart re-randomises rather than resuming — the failure in §6.4 is structurally impossible. It also costs 128 bits, and its own §6.2 guidance for the monotonic-counter variants is that an implementation "MAY reuse the previous timestamp and increment the previous counter" on a rollback, which is clamp under another name. Implementing it would have doubled the toy to re-derive one conclusion.
No base-62 encoding, no ZooKeeper machine-id assignment, no threads. Each is a real part of a production ID service and none of them changes a number on this page.
IdWorker is synchronized; this is single-threaded. The real generator holds a lock across the whole of nextId, which is why the sequence counter can be a plain integer and why a busy service serialises ID generation on one monitor. Everything on this page survives the simplification because a lock does not give the process a memory that outlives it — but a shared generator across threads is where the real 4096/ms ceiling starts to bite."Clock moved backwards. Refusing to generate id for %d milliseconds" and logs "clock is moving backwards. Rejecting requests until %d." — that is refuse, in production, from 2010, and §6.3 is a claim about it. It also splits its 10 machine bits into 5 datacenter + 5 worker, which changes nothing here.makestep's threshold and update limit are both satisfied, typically at startup. Google's leap smear exists for the same reason: spread a leap second over 24 hours so that no clock ever goes backwards. This toy's Wall is one hard step because that is the case worth studying, not the common one.ObjectId shows the design that makes §6.4 impossible. Its 12 bytes are 4 bytes of seconds, 5 bytes of per-process random value — regenerated when the process restarts — and a 3-byte counter initialised randomly. It spends 40 bits to make a restart produce a new identity rather than a resumed one. That is the same money Snowflake spends on 10 bits of machine id and 41 bits of millisecond, allocated by someone who took restarts seriously.fsync — per millisecond at least, and on the ID path — which is the round trip the whole design existed to avoid. Real systems that need it (a Flake-style generator with a persisted clock, or a database sequence with a cached block) pay it in a batch: reserve a range, fsync once, hand out from memory, and lose the tail of the range on a crash.CLOCK_MONOTONIC plus a wall-clock offset taken once at startup, which makes now < last_ts unreachable within a process — and leaves §6.4 exactly where it is, because a new process takes a new offset from the same wrong wall clock.Answer before expanding. Every answer is derivable from the source, and each one was verified by running it.
With a probe set to failureThreshold: 3 — the Kubernetes default — how many duplicate IDs does the refuse policy produce on this trace, and how many does it prevent?
397 produced, 3 prevented. dupes = 400 − K with K=3. The three refused requests are the only ones the policy converts; the third triggers the restart at true t=100 + 2/8 = 100.250, and every request from t=100.375 to t=150 collides: (150 − 100.375) × 8 = 397. Run run(REFUSE, fail_after=3).dupes.
The clock steps back by exactly 1 ms instead of 50. Which policy wins?
None of them — they are indistinguishable, because there is nothing to defend against. The step takes the wall from 100 to 99, which is exactly last_ts, so now < self.last_ts is false and the now == self.last_ts branch increments the sequence as it would have anyway. blind produces 0 duplicates, refuse refuses 0 requests (§6.7). The first millisecond of any rewind is absorbed by the equality branch, which is also why refuse refused 392 rather than 400 in §6.2.
Your service does 200 IDs/ms on one machine with the standard 41/10/12 layout. What is the largest clock rewind clamp absorbs without stalling?
19 ms. The budget is rate × (rewind + 1) ≤ 4096, so rewind ≤ 4096/200 − 1 = 19.48, and rewind is an integer number of milliseconds. Measured: 18 and 19 ms stall 0.000 ms, 20 ms stalls 0.520 ms, 21 ms stalls 1.520 ms. Going to a 41/9/13 layout doubles it to 39 ms and halves your fleet cap to 512.
Three replicas behind a load balancer, all reading MACHINE_ID=7 from the same config map. What breaks, and when does the first duplicate appear?
Immediately, with no clock error required at all. Each replica keeps its own last_ts and seq, so all three reset seq to 0 on the same millisecond and hand out (ts, 7, 0), (ts, 7, 1), … in parallel — three fresh generators called with next_id(1000) return the identical ID 4194332672, three times. The machine id is the only thing separating two generators, and it is separating nothing here. This is the same failure as §6.1's machine_id=1025 aliasing to 1, arrived at through a deployment mistake rather than an arithmetic one — and it is why a real system allocates machine ids from a service instead of a config file.
Why does the persisted high-water mark leave 167 duplicates and not 168, or 240?
Because the replacement's first ID in millisecond 99 is seq = 1, not seq = 0. It starts with last_ts = 99 (the stored mark) and seq = 0; the first call finds now == last_ts and increments before packing, so it issues seq 1. The old process had used seq 0 through 167 in that millisecond — 8 genuine plus 20 ms of clamped requests at 8/ms — so the replacement replays 1 through 167, which is 167 IDs. Seq 0 is the one it skips. It is not 240 because that is row A, where the replacement believes the rewound clock and replays 30 whole milliseconds instead of one.
You keep refuse but wrap it in a retry loop, so callers never see an error. Does the 400−K result go away?
Yes, and it is replaced by something worse in a different way. With no failures escaping, the probe never fires, so refuse stays at 0 duplicates (the K=0 column) — it becomes clamp with the availability of a brick wall. The 392 refusals become 392 stalled callers: the first refusal is at true t=100.000 and the wall does not read 99 again until t=149.000, so that caller waits 49 ms, and the last one (t=148.875) waits 0.125 ms. The result to take away is not "refusing is bad", it is that a defence built on process state must not signal failure to anything that can restart the process. Making the failure invisible is one way to satisfy that; not failing at all — clamp — is the cheaper way.
The free-running sequence produced 0 duplicates on the 200 ms trace, including across a restart. Is it safe?
No, it is lucky. The counter is rate × t mod 4096; a replacement starts at 0, so the two collide when the restart sits a whole multiple of 4096/rate = 512 ms after the clock step. The 200 ms trace is too short to contain such an offset. Given a 1000 ms trace with a 200 ms rewind at t=600, a restart at t=712 (712 − 200 = 512) reissues wall milliseconds 512–599 in full: 704 duplicates, while restarts at 708 or 716 cost nothing (§6.6). It also destroys within-millisecond ordering and wraps every 512 ms at this rate.
Every link below was fetched and confirmed live when this was written.
IdWorker.scala, tag snowflake-2010 — the original, and short enough to read in full. nextId is synchronized, the epoch constant is 1288834974657L, the machine field is 5 datacenter bits + 5 worker bits, and the backwards-clock branch throws with the message quoted in §8. §6.3 is a claim about this file.clamp, standardised.ObjectId — 4 bytes of seconds, 5 bytes of per-process random value regenerated on restart, 3 bytes of counter. The design that spends bits to make §6.4 impossible.makestep — why a backwards step is rare: chronyd "will cause the system to gradually correct any time offset, by slowing down or speeding up the clock", and steps only under the conditions this directive sets. Read it as the probability distribution behind §6.failureThreshold: 3 in the docs' own example is the K in §6.3.Elsewhere in this repo, distributed-lock is the other toy where correctness rests on a clock nobody controls, and failure-detector is the other one where the mechanism watching for failure is the thing that causes it.