When two hold the lock  / field guide
Practitioner field guide · 2026-09-19

When two hold the lock

Every mainstream distributed lock ships with a written admission that it cannot guarantee mutual exclusion. This guide reads those admissions where they live, in the repositories: code comments, rejected pull requests, design records and incident trackers from Kubernetes, etcd, CockroachDB, GitLab, HashiCorp, Redis and Apache. What comes out is a usable rule set for when a lock is enough, when you need fencing, and why the industry's real safety mechanism is exit(1).

29 primary artefacts 10 organisations 6 incident records Evidence through September 2026 Read: ~25 min
01

The territory

Several processes, any of which can stall, crash or lose its network at any moment, must agree that at most one of them performs a given action at a time. That is the whole problem, and it is harder than it reads.

10/42
Writes lost in a 60-second test that trusted etcd locks without fencing
2
Simultaneous leaseholders for one CockroachDB range, with committed writes irrecoverably lost
25%
Chance a racing leader election puts the wrong version in charge during a 3-node control-plane upgrade
15 s
Default leader-lease duration in every Kubernetes control plane, renewed on a 10 s deadline

The surprising thing this dig turned up is not that distributed locks can fail. It is that the people who build them say so, in writing, in the artefact itself, and have for a decade. The package comment of the leader election library inside every Kubernetes control plane opens with: "This implementation does not guarantee that only one client is acting as a leader (a.k.a. fencing)" [1]. etcd's documentation states "Actually, the lease mechanism itself doesn't guarantee mutual exclusion" [8]. Consul's session documentation calls its locks "advisory mechanisms for mutual exclusion" and describes its main safeguard as "not a bulletproof method" [13]. Redis's own Redlock page walks through a replica failover and prints, in bold, "SAFETY VIOLATION!" [15]. These are not blog critiques. They are the vendors' and maintainers' own words, sitting next to the code people depend on.

So the practical question for an architect is not "which lock is safe". None of them is, alone, and every implementation says so. The question is what production systems actually do about it, and the repositories record three answers: push a monotonic token check into the resource being protected (fencing); make the deposed holder die before it can do harm (self-fencing, up to and including kernel watchdog reboots); or accept occasional double execution and design the work to tolerate it. Sections 2 to 4 trace who does which, and what happened when the machinery itself broke.

Scope, and an honest constraint. This guide covers lease-based mutual exclusion and leader election between processes in a datacenter: lock services, leases, fencing, and the failure modes of each. It does not cover consensus-protocol internals, database transaction isolation, or application idempotency design except where teams use it in place of a lock. One constraint shaped the corpus: this session's network reached code hosts only, so the famous blog-side of this argument (Martin Kleppmann's 2016 fencing post, Salvatore Sanfilippo's reply, the Jepsen etcd report, the Chubby paper, and the Roblox, GitHub and Cloudflare outage write-ups) is cited here only through the repository artefacts that quote, link or implement it. Those artefacts turn out to be the more durable record: the debate's conclusions were committed to the repos, with attribution, by the projects themselves [9].

Figure 1 · The anatomy of a lease-based lock, and where the guarantee stops

Coordination store (quorum or single node)

renew before
deadline

watch or poll

TTL expires on the
store's clock, not yours

writes: unfenced in most
deployments

Lease record
holder id, TTL

Holder
renew loop

Waiters

Shared resource

Coordination store (quorum or single node)

renew before
deadline

watch or poll

TTL expires on the
store's clock, not yours

writes: unfenced in most
deployments

Lease record
holder id, TTL

Holder
renew loop

Waiters

Shared resource

Every implementation in this corpus has this shape. The store can order its own records; the unguarded arrow is the write to the resource, which is exactly where exclusion matters. Reconstructed from client-go, etcd's docs and GitLab's ExclusiveLease.
Diagram source
02

How it is actually built

Across ZooKeeper, etcd, Consul, Kubernetes, Patroni, Redis, DynamoDB and CockroachDB, the same five components recur. Two of them are routinely missing, and those two are where the incidents live.

Figure 2 · The reference architecture, with the two components most deployments omit

Protected resource

Holder process

Lock service

grant lease +
monotonic number

writes carry token

Quorum-replicated store
(ZooKeeper, etcd, Consul, Lease API)

Session or lease
TTL, holder identity

Renew loop
renews at 1/2 to 2/3 of TTL

Loss handler
exit, demote, stop work

Watchdog / self-fence
kernel reset if stop fails

Fencing check
reject token lower than last seen

Data

Protected resource

Holder process

Lock service

grant lease +
monotonic number

writes carry token

Quorum-replicated store
(ZooKeeper, etcd, Consul, Lease API)

Session or lease
TTL, holder identity

Renew loop
renews at 1/2 to 2/3 of TTL

Loss handler
exit, demote, stop work

Watchdog / self-fence
kernel reset if stop fails

Fencing check
reject token lower than last seen

Data

Solid boxes appear in every system in the corpus. Dashed boxes are the ones the incident record says matter most: resource-side validation exists in CockroachDB and Kafka because those projects own both the lock and the resource; the watchdog is Patroni's answer to a stop path that can itself fail.
Diagram source

The store is quorum-replicated in every correctness-oriented system: ZooKeeper's sequence nodes [14], etcd's revisioned keyspace, Consul's sessions, and the Kubernetes Lease object, which is etcd wearing an API. Redis is the deliberate outlier: its own documentation shows that a single instance with asynchronous replication loses the safety property the moment a failover promotes a replica that never saw the lock key, which is the race Redlock's five independent masters exist to close [15]. The store can always order its own records. Ordering yours is the open part.

The lease is a TTL measured in physical time, and every project that thought about it carefully says the same uncomfortable thing: the clock that expires the lease is not the clock of the process holding it. etcd: "Both of the server and client measures passing of time with their own clocks. It allows a situation that the server revokes the lease but the client still claims it owns the lease" [8]. Consul adds that TTL is only a lower bound; expiry may arrive late, and clients "should be aware of clock skew issues" [13]. The DynamoDB lock client goes furthest and stores no absolute time at all: an observer starts its own timer and expires a lock only after watching the record sit unchanged for a full lease duration, so "even if two different machines disagree about what time it is, they will still avoid clobbering each other's locks" [16]. Note what that buys: immunity to clock offset, not to a paused holder.

The renew loop converges on the same shape everywhere because the arithmetic is the same: renew comfortably before expiry, and give up before someone else can have taken over. Kubernetes defaults to a 15-second lease with a 10-second renew deadline [3]; Patroni loops every 10 seconds against a 30-second TTL [11]; CockroachDB's original per-range leases lasted 9 seconds and renewed at 7.2 [10]. The client-go comment states the design consequence plainly: tolerance to clock-rate skew is exactly the ratio of LeaseDuration to RenewDeadline, so the person setting two YAML fields is choosing how fast one node's clock may run relative to another before safety is gone [1].

The loss handler is where implementations diverge most, and section 3 covers the argument. The fencing check is the component with three names and one meaning. etcd's docs draw the genealogy in a single paragraph: Chubby called it a sequencer, Kleppmann called it a fencing token, etcd calls it the revision number, and ZooKeeper's zxid plays the same role [9]. CockroachDB implements it as a per-node epoch: a lease is valid only while its epoch matches the node's liveness record, renewal is a conditional put, and an epoch bump revokes every lease the node held at once [10]. Kafka implements it broker-side: the latest producer with a transactional id "fences" all previous instances, which then receive a fatal ProducerFencedException [17]. The pattern in who has fencing is hard to miss: it exists where one project owns both the lock and the resource. Where the resource is yours (a Postgres table, an S3 bucket, a third-party API), the token is offered by etcd and ZooKeeper, surfaced by neither client-go nor GitLab's ExclusiveLease [1][19], and enforced by nobody but you.

03

The decisions that matter

Four forks in the road, each with a recorded argument behind it and a condition that flips the answer.

When the lease is lost mid-work, does the process die or carry on?

Chosen
  • Crash immediately. Kubernetes merged "Scheduler should terminate on loosing leader lock" in three days in 2019, matching the controller manager [5].
  • HBase region servers shut down by design on ZooKeeper session expiry, "so that it stops serving data that may already be assigned elsewhere" [18].
  • Patroni demotes Postgres the moment the leader-key update fails [12].
Rejected
  • Staying alive to rejoin the election in-process. PR #54605 proposed exactly that in 2017 and was closed unmerged with a NAK [6].
  • The reviewer's reason is the sharpest sentence in this corpus: "without fencing we need to exit(1) as fast as possible."
Flips when
  • The resource itself fences. A deposed Kafka transactional producer can survive because the broker rejects it [17]; a deposed scheduler has no such backstop, so death is the fence.

One expiring lease per resource, or one epoch per node?

Chosen
  • CockroachDB moved to epoch-based leases in its 2016 RFC: leases name an epoch instead of an expiry, and stay valid while the node's single liveness record does [10].
  • Renewal became one conditional put per node instead of one Raft write per range.
Rejected
  • Keeping per-range expiration leases. The stated reason is arithmetic: 9-second leases renewed at 7.2 seconds across "a table with 10,000 ranges" is renewal traffic that scales with data, not with machines [10].
Flips when
  • You hold few locks, or have no node-liveness authority to anchor the epoch to. The 2024 advisory below also shows the seam this migration created is itself a failure site [20].

Figure 3 · The decision tree the corpus adds up to

merely expensive

fatal (corruption,
double payment)

yes

no

yes

no

Double execution:
expensive or fatal?

Single lock with TTL
plus idempotent work.
Accept rare duplicates.

Can the resource check
a token or condition?

Fence: monotonic token
checked on every write
(revision, epoch, zxid)

Can the holder be
killed reliably?

Self-fence: exit on loss,
watchdog for the stop path,
lock-delay for stragglers

Redesign: move the write
into a store that can
do a conditional update

merely expensive

fatal (corruption,
double payment)

yes

no

yes

no

Double execution:
expensive or fatal?

Single lock with TTL
plus idempotent work.
Accept rare duplicates.

Can the resource check
a token or condition?

Fence: monotonic token
checked on every write
(revision, epoch, zxid)

Can the holder be
killed reliably?

Self-fence: exit on loss,
watchdog for the stop path,
lock-delay for stragglers

Redesign: move the write
into a store that can
do a conditional update

Terminal nodes are actions. The left branch is the efficiency case Redis's own documentation carves out; the right branches are the correctness designs recorded in etcd's docs, Kafka and Patroni.
Diagram source
DecisionChosenRejectedBecauseEvidence
Lock store for correctness workQuorum store (ZooKeeper, etcd, Consul)Single Redis with replica failoverAsync replication loses the key on promotion; the vendor's own page marks it "SAFETY VIOLATION!"Redis docs
Guarding a resource you ownToken or epoch checked in the write pathTrusting lease possession"The lock feature of etcd itself cannot be used for protecting external resources"etcd why.md
Guarding a resource you cannot changeExit on loss, watchdog behind it, lock-delay in frontGraceful async cleanupDemotion "may fail to happen" under OOM, pauses or slow shutdown; Chubby-style delay covers stragglers "while not a bulletproof method"Patroni, Consul
Who wins an electionAn arbiter picks the candidate (coordinated election)Symmetric race, first renewer winsRacing elections pick arbitrary versions: ~25% skew violation per 3-node upgrade, near-certainty on rollbackKEP-4355
When the lock store is unreachableDemote every primary (safety first)Keep running on stale leases"It is impossible to distinguish" store outage from partition; the failsafe carve-out requires every member to acknowledge the primaryPatroni failsafe
Waking up waitersWatch one predecessor nodeBroadcast on releaseWaking every waiter stampedes the store: "this is important to avoid the herd effect"ZooKeeper recipe
One mechanism, three names

Chubby's sequencer, Kleppmann's fencing token and etcd's revision number are the same idea, and etcd's documentation says so explicitly, crediting all three in one paragraph. CockroachDB's liveness epoch and Kafka's producer epoch are the same idea again, shipped inside the write path. If a design review uses different words for these, it is one concept: a number that only goes up, checked by the thing being written to.

04

What broke in production

The incidents in this corpus group into three classes: the paused holder that outlives its lease, the lock service that develops the same disease itself, and the detector that cannot tell split brain from Tuesday.

"etcd's locks aren't actually locks: like all 'distributed locks' they cannot guarantee mutual exclusion when processes are allowed to run slow or fast, or crash, or messages are delayed, or their clocks are unstable, etc." Kyle Kingsbury (aphyr), etcd issue #11457, December 2019 [7]

Figure 4 · Class 1: the paused holder, with and without a fence

Storage with versioncheckClient BLock serviceClient AStorage with versioncheckClient BLock serviceClient Astop-the-world pauselonger than TTLacquire locklease granted, revision 33TTL passes, leaserevokedacquire locklease granted, revision 34write with token 34accepted, high-water mark now34write with token 33 (still believes it holds the lock)rejected, 33 is below 34
Storage with versioncheckClient BLock serviceClient AStorage with versioncheckClient BLock serviceClient Astop-the-world pauselonger than TTLacquire locklease granted, revision 33TTL passes, leaserevokedacquire locklease granted, revision 34write with token 34accepted, high-water mark now34write with token 33 (still believes it holds the lock)rejected, 33 is below 34
The exact scenario etcd committed as an executable example after the Jepsen report: a stop-the-world pause outlives the TTL, and only the storage-side version check stops the stale write. Source: etcd lock example.
Diagram source
Source

The leader that never learned it lost

AssumptionA deposed leader will be told it was deposed.
What happenedIn a Postgres HA setup built on Kubernetes leader election, renewal errors left the old leader looping: "it still believes it is the leader since OnStoppedLeading() is never called."
Blast radiusTwo nodes acting as primary until manual intervention; reported April 2016, in the library's first year.
FixCallback semantics fixed; the durable fix was cultural: components now die on loss rather than trusting callbacks (see PR #81306).
Design ruleThe loss path is the safety mechanism, so it must be as simple as the acquire path. A callback that can be skipped is not a loss path; process exit is.
Source

The lock that returned after its lease died

AssumptionIf the lock RPC returns success, you hold the lock.
What happenedetcd's lock waited for the previous holder, but "the client does not check the current lease status. So when the lock operation returns, the lease might be already expired." A Jepsen test lost 10 of 42 successfully completed writes in 60 seconds.
Blast radiusAny caller treating lock acquisition as exclusion; found in test, matching the class of the CockroachDB production loss below.
FixA lease-validity recheck before returning, plus PR #11490: documentation and an executable fencing example, not an API that makes the unsafe path impossible.
Design ruleTreat "acquired" as a hint that expires. The only durable fact is a token the resource checks on every write.
Postmortem

The lock service's own zombie leader

AssumptionThe lock service is the one component immune to the stale-leader problem it exists to solve.
What happenedAn etcd leader stuck in fdatasync on degraded storage was replaced, but its lease-manager component still thought it was primary and kept revoking leases: every lock holder in the cluster was deposed at once by a node that was no longer leader.
Blast radiusAll leases in a 3-node Kubernetes etcd cluster on Portworx storage; reported February 2023.
FixPR #16822, "Ignore old leader's leases revoking request", merged for etcd 3.6: an epoch check inside the lock service itself.
Design ruleMass lease expiry is a distinct failure mode with a distinct blast radius. Jitter your TTLs, and alarm on the rate of lease revocations, not only on individual losses.
Postmortem

Two leaseholders, real lost writes

AssumptionA lease transfer plus promotion preserves the single-writer invariant.
What happenedUnder sustained disk slowness, a CockroachDB lease's "expiration time ... can move back in time during this promotion", so "the range can have two leaseholders". Intent resolution on the slow node then silently dropped committed writes.
Blast radiusProduction releases v22.2 through v24.1.0; multi-range transactions "could be irrecoverably lost" where no secondary index allowed repair. Disclosed October 2024.
FixPatch #123442 closing the expiration regression; backported to three maintenance lines.
Design ruleThe seam between two lease types is a lease bug factory. If you migrate lease mechanisms, the promotion step needs the same monotonicity proof as the leases themselves.
Source

The deposed scheduler that kept scheduling

AssumptionLosing the leader lock stops the work the lock was protecting.
What happenedUntil August 2019 the Kubernetes scheduler kept "watching pod, node objects and allowing the pod to be scheduled" after losing its lease, racing the new leader and producing "multiple bind failures as the individual scheduler will have a stale cache".
Blast radiusHA control planes with more than one scheduler; wasted binds and scheduling churn rather than data loss.
Fixklog.Fatalf on lease loss, merged in three days, aligning with the controller manager.
Design ruleWire the loss handler to the work, not to the election. If the work continues when the lease does not, the lock was decoration.
Postmortem

The split-brain alert that mostly isn't

Assumption"More than one instance in read-write mode" means split brain.
What happenedGitLab's public incident tracker records the double-primary alert firing on its Patroni fleet in 2022, 2025 and twice in 2026. Of the four recent records, at least three were false positives: an in-progress change, a freshly provisioned v17 cluster, and a leftover node mis-grouped by monitoring labels after a rolled-back upgrade.
Blast radiusPaging noise at severity 4; no data loss recorded in any of the five incidents.
FixMonitoring label surgery per incident; the observable itself (count of read-write instances) is unchanged.
Design ruleThe split-brain observable must be provisioning-aware, or every migration will page you and the real event will be greeted with a silence rule. Derived count, from the tracker's own records.

Figure 5 · Class 2: the lock service deposing everyone at once

Holder NHolder 1New store leaderOld store leaderHolder NHolder 1New store leaderOld store leaderstuck in fdatasyncon slow disklease manager stillbelieves it is primaryevery holder exits at once,by designwins electionkeep-alive (stale connection)revoke expired leases (all ofthem)lease gonelease gone
Holder NHolder 1New store leaderOld store leaderHolder NHolder 1New store leaderOld store leaderstuck in fdatasyncon slow disklease manager stillbelieves it is primaryevery holder exits at once,by designwins electionkeep-alive (stale connection)revoke expired leases (all ofthem)lease gonelease gone
etcd issue #15247: the deposed store leader's lease manager kept acting as primary. Everything downstream did the safe thing and died, which is its own outage. Source: etcd #15247.
Diagram source

What is missing from this catalogue matters as much as what is in it. Nobody in this corpus published an incident where a fencing check existed and failed; the losses happen where the check is absent or where the lease machinery itself regressed. And no public record here describes application data corrupted through GitLab's unfenced ExclusiveLease pattern, despite a decade of production use across thousands of callers. Read that second absence carefully: it is either evidence that TTL leases plus idempotent job design absorb the zombie window in practice, or evidence that this failure is silent and unattributed when it happens. Both readings argue for the same design: make double execution cheap, because you will not reliably detect it.

05

Numbers you can plan against

The TTLs the industry actually runs, and the measured costs of getting this wrong. Every value carries its source and date.

MetricValueAtContextAs ofSource
Leader lease duration / renew deadline / retry15 s / 10 s / 2 sKubernetesDefaults for every control-plane component election2026-09defaults.go
Tolerated clock-rate skewLeaseDuration ÷ RenewDeadlineKubernetes60 s / 30 s tolerates one clock running 2× another2026-09client-go comment
Default lock-session TTL60 setcdconcurrency.Session behind Mutex and Election2026-09session.go
Leader TTL / loop / watchdog margin30 s / 10 s / 5 sPatroniWatchdog fires 5 s before TTL; set margin to -1 for ttl÷2 if you need the hard guarantee2026-09watchdog.rst
Post-invalidation lock-delay15 s default, 0–60 sConsulChubby-inspired stall before a lost lock is re-acquirablev1.8 docssessions doc
Per-range lease / renewal point9 s / 7.2 sCockroachDBThe pre-2016 design; renewal traffic at 10,000 ranges motivated epoch leases2016-02RFC 20160210
Redlock reference shape5 masters, 10 s validity, 5–50 ms per-node timeoutRedisPlus a crashed node must stay down longer than max TTL2026-09Redis docs
Writes lost trusting the lock alone10 of 42 in 60 setcd (Jepsen test)Healthy-looking cluster, process pauses, no fencing2019-12etcd #11457
Skew violation odds, racing election~25% per upgradeKubernetes3-node control plane; "almost a certainty" on rollback2024KEP-4355
Exposure window of the two-leaseholder bugv22.2 → v24.1.0CockroachDBRoughly two years of releases; fixed in three maintenance lines2024-10a131639
Read these carefully

The defaults are measured from source and will drift with releases; the Jepsen loss figure is from a deliberately adversarial test, not steady-state operation; the 25% is the KEP authors' own estimate for one specific topology. Two quantities nobody in this corpus publishes: the real-world frequency of zombie-holder windows in healthy fleets, and the p99 duration of stop-the-world pauses in the runtimes doing the holding. Your GC logs are the only source for the second one, and it is the number your TTL has to beat.

06

The evidence wall

Every source behind this page, graded. The full ledger with copied quotes ships alongside as sources.md. This corpus is repository artefacts only; the absence of blog, paper and talk tiers is a network constraint of the research session, not a judgement of the material.

Postmortem CockroachDB2024-10

Technical Advisory a131639: two leaseholders, lost writes

A lease expiration that "can move back in time" during promotion let one range have two leaseholders; committed multi-range writes "could be irrecoverably lost" across two years of releases.

Carry forwardMonotonicity of lease expiry is an invariant to test, especially at the seam between lease mechanisms.
github.com/cockroachdb/docs · advisories/a131639
Postmortem etcd2023-02

Issue #15247: stuck leader revokes every lease

A production report: an etcd leader blocked in fdatasync was replaced but kept revoking leases as if primary, deposing every lock holder at once. Fixed by "Ignore old leader's leases revoking request" in 3.6.

Carry forwardThe lock service needs fencing internally too. Alarm on cluster-wide lease revocation rate.
github.com/etcd-io/etcd · issue 15247
Postmortem GitLab2022–2026

The recurring double-primary incidents

Five dated incident records for "more than one postgres instance in read-write mode" on the production Patroni fleet. At least three of the recent four were false positives from provisioning and monitoring-label changes.

Carry forwardCount of writable primaries is the right observable, but it must be provisioning-aware or it trains responders to silence it.
gitlab.com/gitlab-com/gl-infra/production · #6795 et seq.
Source Kubernetes2015–

client-go leaderelection: the disclaimer in the package comment

"This implementation does not guarantee that only one client is acting as a leader (a.k.a. fencing)", plus the exact clock-rate-skew tolerance your two timeout fields buy you.

Carry forwardRead the package comment of any lock library before the API docs; the honest sentence is usually there.
github.com/kubernetes/client-go · leaderelection.go
Source Kubernetes2016-04

Issue #23731: split-brain in the election client

A production user's report from the library's first year: the deposed leader never received OnStoppedLeading and kept acting as primary for a Postgres HA system.

Carry forwardTest the loss path with fault injection; it runs rarely and is the entire safety story.
github.com/kubernetes/kubernetes · issue 23731
Source Kubernetes2018-08

Issue #67651: overlapping leaders under stress

A 10,000-candidate stress test recorded overlapping leadership terms ("[DIRTY RECORD]") in the same library, at the concurrency extreme.

Carry forwardContention level is a safety parameter, not only a performance one.
github.com/kubernetes/kubernetes · issue 67651
Source Kubernetes2019-08

PR #81306: scheduler dies on losing the lock

Merged in three days. Before it, a deposed scheduler kept binding pods from a stale cache; after it, loss of lease is process death, matching the controller manager.

Carry forwardBind the lease to the process lifecycle, not to a flag the work loop consults.
github.com/kubernetes/kubernetes · PR 81306
Source Kubernetes2017-11

PR #54605, closed unmerged: the argument for exit(1)

Proposed letting a deposed leader stay alive and rejoin. NAKed: standbys exist, restarts re-acquire, and "without fencing we need to exit(1) as fast as possible".

Carry forwardThe rejected PR records the design intent better than the docs: exit is the fence.
github.com/kubernetes/kubernetes · PR 54605
Decision record Kubernetes2023–2025

KEP-4355: Coordinated Leader Election

The recorded case against symmetric racing elections: ~25% chance per 3-node upgrade of a version-skew violation, near-certain on rollback, plus lease flip-flop. An arbiter now picks the candidate; beta at v1.33.

Carry forwardWhen candidates are not interchangeable, "who wins" is policy and needs an arbiter, not a race.
github.com/kubernetes/enhancements · KEP-4355
Source etcd / Jepsen2019-12

Issues #11456 and #11457: the Jepsen findings, filed upstream

The lock RPC could return success on an expired lease; and the general claim that no distributed lock guarantees exclusion, with 10/42 writes lost in a 60-second test.

Carry forward"Acquired" is a past-tense statement about the store, not a present-tense statement about you.
github.com/etcd-io/etcd · issues 11456, 11457
Decision record etcd2020-03

PR #11490 and why.md: the project's considered answer

etcd answered Jepsen with documentation, a genealogy (Chubby's sequencer, Kleppmann's fencing token, etcd's revision) and an executable GC-pause demo, keeping the API and stating "the lock feature of etcd itself cannot be used for protecting external resources".

Carry forwardLeases are an optimisation for reducing aborted requests; the exclusion lives in version validation.
github.com/etcd-io/etcd · learning/why.md
Source etcd2020-03

The executable fencing example

Two programs and an etcd cluster reproduce the paused-holder race on demand, then show the storage-side version check rejecting the stale write.

Carry forwardRung 3 of the build ladder, already written for you by the maintainers.
github.com/etcd-io/etcd · learning/lock
Decision record CockroachDB2016-02

RFC 20160210: epoch-based range leases

A production fencing design in full: a monotonic per-node epoch anchors every lease, renewal is a conditional put, and an epoch bump revokes everything the node held. Motivated by renewal traffic at 10,000 ranges.

Carry forwardFencing scales better than expiry: one liveness record per node instead of one timer per resource.
github.com/cockroachdb/cockroach · RFCS/20160210
Vendor Zalando / Patronicurrent

watchdog.rst and dcs_failsafe_mode.rst

The layered defence for a resource that cannot fence: demote on lease-update failure, kernel watchdog for when demotion itself fails, and a documented residual window in the default margin. Failsafe mode trades some of this for availability, gated on unanimous member acknowledgement.

Carry forwardEvery layer of self-fencing has a stated hole; know which one you are accepting.
github.com/zalando/patroni · docs/watchdog.rst
Vendor HashiCorp / Consulv1.8 docs

Sessions: advisory locks and the Chubby-inspired lock-delay

Locks are "advisory mechanisms"; TTL expiry is a lower bound that can arrive late; and the lock-delay stalls re-acquisition for 15 seconds by default so a still-live former holder can notice, "while not a bulletproof method".

Carry forwardLock-delay converts a safety gap into bounded unavailability; it is a mitigation, not a proof.
github.com/hashicorp/consul · sessions.mdx
Vendor Rediscurrent

Distributed Locks with Redis (the Redlock page)

Documents the failover race as a safety violation, specifies Redlock's five-master design and timing assumptions, requires crashed nodes to stay down past the TTL, and links both sides of the 2016 Kleppmann/antirez debate.

Carry forwardThe vendor's own page carves out the efficiency case; use it to classify your use case honestly.
github.com/redis/docs · distributed-locks.md
Vendor Apache ZooKeeperbranch-3.9

The lock recipe

Sequence nodes plus a watch on the single predecessor: no polling, no timeouts on the client path, and one wake-up per release, "important to avoid the herd effect".

Carry forwardQueue in the store, watch one node. Broadcast wake-ups are a self-inflicted stampede.
github.com/apache/zookeeper · recipes.md
Vendor AWS Labs2017–

DynamoDB lock client README

Leases with no absolute timestamps anywhere: an acquirer watches the record's GUID stay unchanged for a full lease duration on its own clock before expiring it.

Carry forwardRelative-time leases remove clock offset from the threat model; pauses remain.
github.com/awslabs/amazon-dynamodb-lock-client
Source Apache Kafkacurrent

ProducerFencedException

Broker-side fencing in one sentence of javadoc: the latest producer with a transactional id fences all previous instances, whose next request fails fatally.

Carry forwardWhen the resource does the fencing, the deposed holder needs no heroics; it just gets told no.
github.com/apache/kafka · ProducerFencedException.java
Vendor Apache HBasecurrent

Troubleshooting: the Juliet Pause

Long GC pauses ("GC of Death") expire the ZooKeeper session, and the region server then shuts itself down "by design ... so that it stops serving data that may already be assigned elsewhere".

Carry forwardSuicide-on-expiry predates the fencing debate and remains the fallback where writes cannot be validated.
github.com/apache/hbase · troubleshooting
Source GitLabcurrent

Gitlab::ExclusiveLease

The application-tier pattern at scale: a Redis key with TTL, a UUID guard on release, no fencing token surfaced to callers, used across the codebase as "a cheap alternative to using SQL queries and updates".

Carry forwardThis is the efficiency lock in its natural habitat; pair it with idempotent work, not with trust.
gitlab.com/gitlab-org/gitlab · exclusive_lease.rb
Source Kubernetes / etcd2026-09

The numbers files

component-base defaults (15 s / 10 s / 2 s) and etcd's defaultSessionTTL (60 s): the TTLs most of the industry actually runs, measured from source rather than quoted from docs.

Carry forwardYour TTL must exceed your worst observed pause plus renewal jitter; start from these and measure.
etcd session.go · k8s defaults.go
07

Build a miniature, then productionise it

Six rungs from a toy lease to the operational surface. The crossing from toy to real is rung 3, and etcd's maintainers have already written it for you.

A lease and two workers

One Redis or etcd instance, a key with a 10-second TTL, two workers competing with set-if-absent plus a unique holder id, release guarded by that id (the shape of GitLab's ExclusiveLease).

Done when: two workers run for an hour and the log shows exactly one holder at a time.  Teaches: acquire, renew, guarded release.

Manufacture the zombie

SIGSTOP the holder for longer than the TTL, then SIGCONT. Watch the second worker acquire while the first resumes and finishes its critical section anyway. Log both writes with timestamps.

Done when: you have a log line proving two holders wrote inside one guarded window.  Teaches: the pause-expiry race is trivial to hit on demand, and invisible unless you log for it.

Add the fence

Run etcd's own executable example: a storage process that records the highest lock revision seen and rejects lower ones, with a GC-pause client. Then rebuild rung 2 with a token column and a conditional update in your own store.

Done when: the resumed zombie's write is rejected with a version mismatch, as in the example's output.  Teaches: exclusion lives at the resource; the lock only queues.

Leader election with a hard loss path

Wire client-go leader election (or an etcd session) so that lease loss calls exit, not a cleanup routine. Kill the elected leader's network and measure the gap between old-leader exit and new-leader start against LeaseDuration.

Done when: failover time matches the lease arithmetic and no work happens between depose and exit.  Teaches: why PR #81306 chose Fatalf, and what the 15 s default costs in downtime.

Break the lock service

Three-node etcd; pause the leader's disk (or the process) mid-load and watch lease behaviour across the election, replaying issue #15247's shape. Add jitter to session TTLs and an alert on cluster-wide revocation rate.

Done when: you can state the blast radius of a store leader stall in holders deposed per second.  Teaches: the store is a lease holder too, with the same disease.

The operational surface

Add the double-primary detector (count of writable holders per lock, GitLab's observable), make it provisioning-aware, and write the runbook: what pages, what auto-remediates, and which false-positive classes you already know about.

Done when: a cluster rebuild does not page anyone and a forced double-primary pages within one TTL.  Teaches: detection is a design problem of its own, and the GitLab record shows where it goes wrong.

08

Keep hunting

The queries that found this material, adapted for reuse. The common trick: search for the admission, not the feature.

Admissions in code and docs

  • "does not guarantee that only one client is acting"
  • "cannot be used" mutual exclusion lease site:github.com
  • "not a bulletproof" OR "advisory" lock sessions docs
  • fencing token revision zxid epoch sequencer

Incident records living on code hosts

  • gitlab.com/gitlab-com/gl-infra/production issues: "split brain"
  • repo:etcd-io/etcd is:issue lease revoked leader stuck
  • <vendor>/docs advisories "lease" OR "leaseholder"
  • site:github.com issues "still believes it is the leader"

The argument, recorded in PRs

  • repo:kubernetes/kubernetes is:pr is:closed is:unmerged leaderelection
  • "exit(1)" OR Fatalf "leader" lost lease pull request
  • jepsen is:issue repo:<org>/<repo>

Docs that moved: fetch at the old ref

  • raw.githubusercontent.com/<org>/<repo>/<old tag or PR commit>/<path>
  • repo:<org>/<repo> path:docs/RFCS lease
09

References

  1. Kubernetes, client-go tools/leaderelection package comment kubernetes/client-go, in tree since 2015, current master. Checked 2026-09-19.
  2. cevian, "Split-Brain bug in leaderelection client", kubernetes #23731 GitHub issue, 2016-04-01. Checked 2026-09-19.
  3. Kubernetes, component-base leader-election defaults kubernetes/kubernetes, current master. Checked 2026-09-19.
  4. yue9944882, "Client-go leader election can potentially split brain", kubernetes #67651 GitHub issue, 2018-08-21. Checked 2026-09-19.
  5. ravisantoshgudimetla, "Scheduler should terminate on loosing leader lock", kubernetes PR #81306 Merged 2019-08-15. Checked 2026-09-19.
  6. skyline09, "fix leaderelection: renew lease shouldn't exit", kubernetes PR #54605 Closed without merging, 2017-11-22. Checked 2026-09-19.
  7. Kyle Kingsbury (aphyr), "Document that locks aren't really locks", etcd #11457; and #11456 GitHub issues, 2019-12-16. Checked 2026-09-19.
  8. etcd, "Notes on the usage of lock and lease", Documentation/learning/why.md Merged by PR #11490, 2020-03-05; cited at the merged commit. Checked 2026-09-19.
  9. mitake, "RFC Documentation: enhance description of lock and lease", etcd PR #11490; executable example in Documentation/learning/lock/ Merged 2020-03-05. Checked 2026-09-19.
  10. Ben Darnell and Spencer Kimball, RFC "Node-level mechanism for refreshing range leases" cockroachdb/cockroach docs/RFCS, 2016-02-10. Checked 2026-09-19.
  11. Patroni, "Watchdog support", docs/watchdog.rst zalando/patroni, current master. Checked 2026-09-19.
  12. Patroni, "DCS Failsafe Mode", docs/dcs_failsafe_mode.rst zalando/patroni, current master. Checked 2026-09-19.
  13. HashiCorp, Consul internals: Sessions hashicorp/consul, at tag v1.8.0 (2020-06). Checked 2026-09-19.
  14. Apache ZooKeeper, "Recipes and Solutions" (locks) apache/zookeeper, branch-3.9. Checked 2026-09-19.
  15. Redis, "Distributed Locks with Redis" redis/docs, current main. Checked 2026-09-19.
  16. AWS Labs, Amazon DynamoDB Lock Client README awslabs/amazon-dynamodb-lock-client, current master. Checked 2026-09-19.
  17. Apache Kafka, ProducerFencedException javadoc apache/kafka, current trunk. Checked 2026-09-19.
  18. Apache HBase, Troubleshooting (ZooKeeper session expiry, "Juliet Pause") apache/hbase, current master. Checked 2026-09-19.
  19. GitLab, Gitlab::ExclusiveLease gitlab-org/gitlab, current master. Checked 2026-09-19.
  20. GitLab production tracker: split-brain incident records #6795 (2022-04-08), #20767 (2025-10-25), #22394 (2026-06-27), #22502 (2026-07-13) gitlab-com/gl-infra/production. Checked 2026-09-19.
  21. aaronjzhang, "All leases are revoked when the etcd leader is stuck...", etcd #15247 GitHub issue, 2023-02-06; fixed by PR #16822. Checked 2026-09-19.
  22. Kubernetes, KEP-4355 Coordinated Leader Election kubernetes/enhancements; beta, latest-milestone v1.33. Checked 2026-09-19.
  23. Cockroach Labs, Technical Advisory a131639 cockroachdb/docs, 2024-10-08. Checked 2026-09-19.
  24. etcd, client/v3/concurrency/session.go (defaultSessionTTL) etcd-io/etcd, current main. Checked 2026-09-19.