TLA+: the fencing protocol and the cached lock¶
Fencing is the part of EtcFS where a bug is silent, rare, and destroys data: two nodes writing to the same arena at the same time corrupts a filesystem in a way no read path can detect after the fact. Fault injection samples interleavings; a model checker enumerates them. This is the one component where that difference is worth the work.
The subject is the protocol described in
Fencing Generation Protocol,
Self-Fencing Watchdog and
External Fencing Controller
— not the implementation. The gap between the spec and pkg/fencing is closed
by review; the spec is the design's argument, not a proof about the Go code.
Two specifications, one per layer of exclusion. specs/Fencing.tla is the
fencing protocol, described below. specs/CachedLock.tla is the layer above
it — the per-inode lock key, kept across operations rather than taken and
released per operation, and the three caches that live under it — described in
The cached lock. Run both with:
make test-tla # 2-node models, ~30s
DEEP=1 make test-tla # adds the 3-node model, several minutes
scripts/test/tla-check.sh asserts the outcome each configuration is
supposed to have — several of them are deliberately broken, and must still
produce a counterexample, or the invariant they exist to exercise would have
no teeth.
What is modelled¶
Constants bound the model to a finite state space: a set of nodes, a set of
arenas, a ceiling on the generation counter, and four switches —
FencerMode (reliable / unreliable / none), GuardEnabled,
ReleaseNeedsFencer and FenceChecksIncarnation — that turn the protocol's
layers on and off independently.
Arena ownership is modelled twice:
| Variable | Meaning |
|---|---|
owner[a] |
what etcd records — the arena:<node>/<id> key |
holds[n] |
what the node believes it owns and is writing to |
A partitioned node cannot be told its arena was taken away from it, so these
diverge, and that divergence is the hazard the whole protocol exists to
prevent. Modelling ownership as one authoritative variable would have made
NoDoubleWriter true by construction and proved nothing.
Two further details of the real system are load-bearing and are modelled exactly:
startGenis cached at process startup and never re-read. A write's guard compares the generation the node started with against what etcd records now. This is not an optimisation: a write that re-read the generation would read the already-bumped value and CAS it against itself, and every post-fence write would succeed.- A restarting node re-adopts the arenas it still owns.
Allocator.Reconstructreadsarena:<node>/and adopts every record still present, so a restarted node does not come back empty — it comes back owning whatever etcd still says it owns.NodeRestarttherefore setsholds[n] = {a : owner[a] = n}, not{}. - A fence re-checks the incarnation it started against.
fenceNodecaptures the create-revision offence_pending:<node>when it begins, and re-checks it before each of the three irreversible steps — severing device access, bumping the generation, releasing arenas — abandoning the attempt if the node has come back in the meantime.Membership.grantAndRegisterdrops that intent as a node re-registers, so a returning node invalidates any fence still in flight for the departure it recovered from, without waiting for the 30 s reconciliation sweep.FenceChecksIncarnationmodels exactly this. The check is on incarnation, not liveness: a node that departs, restarts, and departs again is absent at both ends while being a different node in between, and a liveness-only check cannot tell the two apart.
Properties¶
Safety (invariants):
NoDoubleWriter— no two nodes have a write path to the same arena at the same time. The property the whole design exists for.StaleWriteRejected— no node commits metadata after its generation has been bumped out from under it.NoWedgedNode/NoHealthyNodeSevered— a node that is healthy and holds a live membership lease is never left cut off from the device or unable to make its cached generation match the cluster's.ReleasedArenaHasNoLiveWriter— an arena returned to the free pool has no node still writing into it.GenerationMonotone— a node's recorded generation never decreases.
Configurations¶
| Configuration | Incarnation check | Guard | Fencer | Expected |
|---|---|---|---|---|
Fencing |
on | on | reliable | no counterexample |
Fencing3Nodes |
on | on | reliable | no counterexample |
FencingNoFencer |
on | on | none (single-signal) | no counterexample |
FencingUnreliableFencer |
on | on | unreliable | no counterexample |
FencingGuardIsBackstop |
off | on | reliable | no counterexample (only StaleWriteRejected checked) |
FencingNoIncarnationCheck |
off | on | reliable | breaks NoHealthyNodeSevered |
FencingNoGuard |
off | off | reliable | breaks StaleWriteRejected |
FencingArenaBug |
on | on | none, but reclaims arenas anyway | breaks ReleasedArenaHasNoLiveWriter |
FencingDepartureNotQuiescent |
on | on | reliable, departure announced without stopping | breaks ReleasedArenaHasNoLiveWriter |
Three of these are worth reading past the pass/fail column:
FencingGuardIsBackstop is the three-layer argument, checked rather than
argued. It runs with the incarnation check off — the fence ordering
deliberately broken — and the generation guard on, checking only
StaleWriteRejected. Over 1.2 million states, no node ever commits metadata
after being fenced: the guard holds even when the layer above it fails, which
is the design's central claim about why it has three independent layers and
not one.
FencingDepartureNotQuiescent is what makes the departure ordering
load-bearing. A node that leaves on purpose is not fenced, which is safe only
because it has already stopped serving before it gives anything back. This
variant publishes the same departure marker from a node that returned its
arenas but is still running and still believes it owns them — and the arena
check the controller performs cannot see it, because the records are clean.
TLC breaks ReleasedArenaHasNoLiveWriter at the departure itself: the arena is
in the free pool while its previous owner can still write to it.
The mutation was chosen after the obvious one proved nothing. Announcing a departure while still recorded as owning arenas is caught by the controller's own check, and a model of it passes — which is the right outcome, and the reason that variant is not the one checked in. The property worth a model is the one the bookkeeping cannot supply.
FencingArenaBug is the deliberate arena leak, checked rather than
assumed. FencingNoFencer passes: in single-signal mode nothing severs a
node, but nothing hands its arena on either, so there is never a second
writer. FencingArenaBug flips only that one decision — reclaim the arena in
single-signal mode anyway — and ReleasedArenaHasNoLiveWriter breaks
immediately. The leaked space documented in
the controller
is buying exactly what it claims to buy.
FencingNoIncarnationCheck is why the check has to be on incarnation, not
liveness. A liveness-only version — require the node's membership key to
still be absent before bumping and before releasing — looks sufficient but
is not: a node severed in one epoch can restart, re-claim an arena, and have
its lease expire again, passing a liveness check while being a different
incarnation than the one that was severed. The counterexample for this
configuration is one such trace; the fix that closes it is the incarnation
check described above.
Symmetry is checked to be sound here: the same configuration without the
SYMMETRY declaration reaches the same verdict, worth confirming because
symmetry reduction is unsound for some temporal properties.
What is deliberately not modelled¶
- Real time. TLA+ has no clock, so the watchdog's 2–3× lease-TTL margin becomes "may fire at any point after the lease is lost". The bound is measured by the chaos suite; the spec checks the ordering.
- etcd itself. Transactions are assumed linearizable and CAS atomic. Those are the assumptions the design rests on, stated as such; Porcupine checks the daemon's own use of them against recorded histories.
- Two controllers fencing one node concurrently. The per-node
fence_claimlease serialises them andFenceBumpLostCASmodels the CAS losing, but two fully interleaved fence sequences for one node are not enumerated. A claim expiring under a live fencer is the case that would reach it.
Results¶
| Configuration | Result |
|---|---|
Fencing |
pass, 127,126 states |
Fencing3Nodes |
pass, 11,664,975 states |
FencingNoFencer |
pass, 86,185 states |
FencingUnreliableFencer |
pass, 1,206 states |
FencingGuardIsBackstop |
pass, 1,328,303 states |
FencingNoIncarnationCheck |
counterexample found, as expected |
FencingNoGuard |
counterexample found, as expected |
FencingArenaBug |
counterexample found, as expected |
FencingDepartureNotQuiescent |
counterexample found, as expected |
.github/workflows/ci.yml's test-tla job runs the 2-node models on every
push and pull request.
The cached lock¶
Caching an inode's lock made three things possible that a per-operation lock did not: serving an inode's metadata from a snapshot, letting the kernel cache its data pages, and acknowledging writes out of RAM before publishing them. All three rest on one sentence — a node holding the key excludes every peer from that inode, so nothing it has cached can go stale underneath it — and the spec exists to check the obligations that sentence creates when it stops being true.
Modelled: acquiring the key and the snapshot read under it; a write acknowledged into the buffer; a read the kernel may cache; the flush and its comparison on the node's own key; the recall, in the order the release runs in (publish, invalidate the kernel's pages, delete the key); the lock session expiring, which deletes the key in etcd while the node goes on believing it holds it; and the node noticing, on its next operation, that the key it cached was written under a session it no longer has.
The properties:
NoTwoHolders— at most one node passes the test it itself applies before operating on the inode. That test is the interesting part: not "is my session alive" but "is the key I cached still written under the session I have now". A dead session is replaced lazily by the next acquisition on any inode, so liveness goes true again while this key — written under the previous lease and deleted with it — is already gone.NoPublishWithoutLock— nothing is published by a node that does not hold the key, whether it never had it or lost it mid-buffer.NoLostAckedWrite— a recall may not drop writes this node has already acknowledged. A crash may lose them; a peer asking for the inode may not, and the flush before the yield is what separates the two.NoStalePages— no kernel page survives a key the node knows it gave up.ViewMatchesTruth— what a node believes the inode is equals what etcd records, plus whatever that same node has buffered. This is the property the metadata cache and both data caches all rest on, and no other spec names it.OrphanFreed— a lock key that no node's cache names does not stay that way. This is the one liveness property here, and it is liveness because the state it forbids breaks no safety invariant: a key held by nobody makes at most one node the holder, publishes nothing, and strands no pages. What it does is make the inode unusable to the entire cluster, since acquiring needs the key free and every release starts from a node that believes it holds one. The window itself cannot be designed away — a create's transaction can commit with its reply lost, and no protocol over a lossy reply can promise otherwise — so what is checked is that it ends.
Each broken variant takes exactly one guard away:
| Configuration | What it takes away | Expected |
|---|---|---|
CachedLock |
nothing | no counterexample, 3,900 states |
CachedLockNoLeaseIdentity |
the cached key is trusted while any session is alive | breaks NoTwoHolders |
CachedLockNoFlushKeyCheck |
the flush's comparison on this node's own lock key | breaks NoPublishWithoutLock |
CachedLockNoRecallFlush |
the flush a recall does before yielding | breaks NoLostAckedWrite |
CachedLockNoInvalidate |
the kernel page invalidation before yielding | breaks NoStalePages |
CachedLockStaleSnapshot |
dropping the metadata snapshot with the key | breaks ViewMatchesTruth |
CachedLockKeepsCacheOnKeyLoss |
dropping every cache when the key is found gone | breaks NoStalePages |
CachedLockOrphanedKey |
nothing; the create's lost-reply window with the discard in place | no counterexample, 9,000 states |
CachedLockNoOrphanDiscard |
deleting a lock key the create minted and never cached | breaks OrphanFreed |
The last two are the ones the create-time lock brought with it. A lock taken by
the transaction that creates the inode is an ordinary Acquire in this model —
it asserts the same empty blocking range, and the snapshot it seeds is the
record that transaction just wrote — so it needed no new action. The lost reply
did: it is the third way that transaction can end, and the only one that leaves
a key behind with nothing naming it. Those two configurations check a temporal
property rather than an invariant, so they run under OrphanSpec, which adds
weak fairness on the discard, and without SYMMETRY, under which TLC's liveness
checking is not sound.
Batched key releases needed nothing at all. This is a one-inode model, and
giving up several keys in one transaction is this model's Recall once per
inode: what a shared delete adds is atomicity between inodes, which no
property here constrains, and every step that has to happen before a key is
yielded still happens per inode in the implementation.
NoStalePages is stated against the node's own belief rather than against the
holder test, and deliberately: between a session expiring in etcd and the node
observing it, the node still thinks it holds the inode and its pages are still
there. That window is real, bounded by the lock session's TTL, and the same
window the metadata snapshot has.
Not modelled, beyond what the fencing spec already leaves out: the want-key and the hold time, which only ever delay a recall, so omitting them admits every behaviour they would have allowed and more — that remains true of the adaptive hold, which changes how long the delay is and not that it is a delay; the crash case, where unflushed writes are legitimately lost, which belongs to Porcupine's extent model because it can see which node died and which writes were fsynced; and a second inode, since every action and every invariant is about one inode and nothing relates one to another.
Next¶
Model two concurrent fence sequences for one node.