The problem
Many systems need exactly one node to do something: run the scheduler, own a shard, accept writes for a partition, or be the active NameNode. Nodes crash and networks partition, so "exactly one" has to recover on its own. The standard tool is a lease: a lock that expires unless it is renewed. When the leader dies, the lease runs out and someone else takes over.
Leases alone are not safe. A leader can check that it holds the lease and then freeze: a GC pause, a VM migration, a swapped-out page, a slow disk. By the time it runs again the lease has expired, a successor is active, and the old leader sends its write anyway. The fix (Chubby's "sequencers", popularised by Kleppmann's critique of Redlock) is a fencing token: a number that increases with every change of ownership and that the protected resource checks.
Where it shows up: Kubernetes controller-manager/scheduler (coordination.k8s.io/Lease), HDFS NameNode HA (quorum journal epochs), Kafka controller epochs, etcd and ZooKeeper election recipes, Google Chubby, and any cron or singleton worker that must not run twice.
Design
LeaseClient A ──acquire/renew──► LeaseStore (authority on ownership, issues tokens 1,2,3...)
LeaseClient B ──acquire/renew──►
│ write(token, ...)
▼
FencedStorage (remembers highest token seen, rejects anything lower)lease.js exports:
LeaseStore:acquire(holder, ttl),renew(holder, token, ttl),release(holder, token). A new owner always receivestoken + 1, including the same node coming back after its own lease expired, because someone else may have held the lease in between. Tokens never go down, even after a release.LeaseClient: a candidate state machine driven bytick(). Its local checkisLeader()makes no network call.FencedStorage:write(token, k, v)andfence(token). A token equal to the highest seen is accepted (the same leader keeps writing); a lower one throwsStaleTokenError.ManualClock,DriftingClock(injectable clocks with rate drift and offset) andmulberry32(a seeded PRNG).
Invariants and the timing argument
- Token monotonicity: each ownership change in the store gets exactly the next integer.
- Storage safety: once storage has seen token n, no write with a token below n ever lands. This holds whatever the clocks do. It is the real safety net.
- Belief safety: at most one client has
isLeader() === trueat any real instant, provided relative clock drift stays withinmaxDriftRate. The client:
• starts its countdown when it sends the request, not when the reply arrives. The store started the TTL later than that, so network delay only shortens the client's view.
• acts only forsafeWindow = ttl * (1 - maxDriftRate) - safetyMarginon its own clock. If that clock runs slow by rate r, the window lastssafeWindow / (1 - r)real ms, which is at mostttlwheneverr ≤ maxDriftRate.
• renews afterrenewFraction(1/3) of the window, so it survives a couple of lost renewals. - Clock offset does not matter: only durations on a monotonic clock are used, never wall-clock timestamps compared across machines.
DriftingClockin the demo runs B's clock 42 s ahead to show this.
The seeded chaos test runs 3 clients with random drift inside the bound and random pauses of up to 1 s, and asserts invariant 3 at every step. If you set maxDriftRate and the margin to 0, the same test fails, so the margin is doing real work.
Election barrier
Storage can only reject token 1 after it has seen token 2. If the new leader has not written yet, a zombie write with token 1 still gets in (the test shows this). The fix is for the new leader to call storage.fence(token) as its first act, the way HDFS makes a new NameNode bump the epoch on a quorum of JournalNodes before it writes.
Complexity
All operations are O(1) time and space. Memory is O(ownership changes) only because history and events are kept for inspection. Failover takes at most ttl (plus one tick), or one tick after a voluntary resign().
Failure modes & trade-offs
- TTL length: a short TTL gives fast failover but false failovers under GC or network jitter. A long TTL means longer unavailability when the leader really dies. Kubernetes defaults to 15 s lease / 10 s renew deadline / 2 s retry.
- Drift beyond the bound breaks belief safety, as the demo's -20% case shows. Storage safety still holds, which is why fencing is not optional.
- Resources that cannot check tokens (a third-party API, an email being sent) cannot be fenced. You need idempotency keys or a design that accepts at-least-once effects.
- The lease store must itself be consistent. A token counter backed by one replicated Raft log (etcd) or ZAB (ZooKeeper) is fine. A single Redis without replication is a single point of failure. Redlock across independent Redis nodes does not produce monotonic tokens at all.
- Store restarts must never reuse tokens. Persist the counter, or derive it from a replicated revision number.
How production systems do it
- etcd:
LeaseGrant/LeaseKeepAlivewith a server-side TTL. Theconcurrency.Electionrecipe writes a key attached to the lease, and the key's create revision (globally monotonic) works as the fencing token. Transactions can comparemod_revisionso a write only applies if leadership has not changed. - ZooKeeper: ephemeral sequential znodes tied to the session timeout. The lowest sequence number leads. The zxid or znode version serves as the token.
- Chubby: lock holders obtain a sequencer (lock name, mode, generation) and pass it to servers, which verify it. There is also a lock-delay for clients that do not check sequencers.
- Kubernetes client-go
leaderelection: aLeaseobject updated with optimistic concurrency (resourceVersion) and aleaseTransitionscounter. The docs say it relies on bounded clock skew and does not fence the controlled resources. - HDFS QJM: each new active NameNode obtains a higher epoch. JournalNodes reject edits from lower epochs. This is textbook storage-side fencing.
- Raft (etcd, CockroachDB, TiKV): terms act as fencing tokens for log replication. Leader leases on top allow local reads, and they depend on the same bounded-drift argument used here.
Extension exercises
- Put the
LeaseStorebehind a simulated network with delay and loss, and check that sending time, not receive time, is what keepsisLeader()safe. - Make
FencedStoragea replicated store: a write succeeds only if a majority of replicas accept its token. Then partition one replica. - Implement etcd-style compare-and-swap writes (
if leaderKey.createRevision == myToken) instead of a highest-seen check, and compare the two. - Add a lease-delay mode like Chubby's: after an expiry, the store refuses new owners for an extra period, which protects resources that cannot check tokens.
- Measure false failovers against TTL and renewFraction under a GC-pause distribution (for example, lognormal), and plot availability against failover time.