Description
What
Under coordinate.type: file_lease with state.store: file, the fence epoch is tracked in two places that are never reconciled:
- The lease file is the only input to the epoch the winning acquirer takes:
new_epoch = (lease.epoch if lease is not None else 0) + 1(src/sf2loki/coordinate/file_lease.py:207), adopted intoself._epochafter the verify re-read (file_lease.py:226). _readreturnsNonefor a missing lease (file_lease.py:339-340), for corrupt/unparseable content (file_lease.py:343-348), and coerces a non-intepochfield to0(file_lease.py:359-360). All three collapse to “absent”, so the next acquire starts again at epoch 1.- The state document independently persists the high-water epoch under
__fence_epoch__(src/sf2loki/state/file_store.py:19, written at:295) and rejects any commit carrying a lower one:if stored is not None and mine is not None and stored > mine: raise StateFenceError(file_store.py:288-292; the same check guardsdeleteatfile_store.py:260-264). app.pywires the two together for the file-lease topology only (epoch_source = lambda: file_lease.epoch,app.py:948;set_epoch(epoch_source),app.py:966-968).
Nothing reads __fence_epoch__ back into the coordinator. __fence_epoch__ appears in src/ only in state/file_store.py and in statecmd.py’s reserved-key filter, so a lease file that is deleted, re-provisioned, hand-edited badly, or truncated to garbage silently rewinds the coordinator’s epoch below the value already durably recorded in the state document.
Reproduced against current main (acquire at epoch 7 → commit writes __fence_epoch__: "7" → delete the lease file only → next acquire wins with epoch 1 and is_leader == True, so the boolean fence at file_lease.py:125-136 passes → the first commit raises StateFenceError: stale leader (epoch 1) rejected: state file ... was already advanced to epoch 7 by a newer leader). A lease file containing non-JSON produces the same epoch 1.
Scope: file_lease + state.store: file only. k8s_lease never gets an epoch source (app.py:948 is the sole set_epoch wiring) and the S3/GCS stores use ETag/generation CAS instead of the epoch key.
Why it matters
The instance that is fenced is the sole legitimate leader — it holds the lease and nothing else is running. Consequences:
_commit(app.py:498-513) has no handler, soStateFenceErrorpropagates out ofpipeline.run._run_pipelineabsorbs it as a leadership transition (app.py:1237-1238) and returns cleanly;_on_pipeline_donethen sees a clean completion withrun_stopunset and callsstop.set()(app.py:1181-1182). The process shuts down and exits 0 on the first checkpoint commit after acquiring.- Each restart re-acquires and bumps the epoch by exactly one, so ingestion is down for
(stored_epoch - 1)cycles of at least one leasettlplus startup. A long-lived HA pair that has failed over many times has a correspondingly large__fence_epoch__, turning a few minutes into an effectively permanent outage. Under arestart: on-failurepolicy (exit 0 is not a failure) the process never comes back at all. - Every fenced cycle re-ingests the events pushed before the rejected commit, since the batch lands in Loki before the commit.
- The documented recovery paths are blocked.
state set/state deleteboth refuse__fence_epoch__(src/sf2loki/statecmd.py:63,:181,:207; pinned bytests/test_statecmd.py:473and:517), so the only way out is hand-editing the state JSON — and nothing indocs/deployment/high-availability.mdmentions the failure mode or the fix.
Deleting the lease file is a recognised operational event in this project’s own history: issue #50 named “lease-file deletion (operator cleanup)” as its trigger. #50’s fix (_read raising _LeaseReadError on transient OSError, _hold treating absence as contested) and #47’s fix (the epoch token) landed together in 6e6ec77 and were never reconciled with each other.
Proposed approach
Seed the winning epoch from the maximum of the lease’s epoch and the state document’s persisted epoch, so the fence stays globally monotonic even when the lease is lost. Raising the epoch is always safe: it is strictly greater than both observed values, so it cannot let a genuinely stale leader through, and two standbys racing an expired lease still compute the same value and are still resolved by the existing rename + verify-read discipline.
- Add a fresh-read accessor to the file store, e.g.
FileCheckpointStore.persisted_epoch() -> int | None, returningint(self._read_file_fresh().get(_EPOCH_KEY))(orNonewhen absent/unset). It must bypass_cachefor the same reason_commit_many_epoch_fenceddoes (file_store.py:274-283). - Add an optional
epoch_floor: Callable[[], int | None] | None = Noneconstructor argument toFileLeaseCoordinator. In_acquire, replacefile_lease.py:207with a floor-aware derivation:base = max(lease.epoch if lease is not None else 0, epoch_floor() or 0)thennew_epoch = base + 1. Evaluate the floor at each acquire, not once at startup, so a standby promoted after another instance advanced the document still clears it. A raising/failing floor callable must be logged and treated as0(never fatal — leader election must not depend on the state backend being readable). - Wire it in the composition root next to the existing epoch plumbing (
app.py:937-948): passepoch_floor=getattr(state, "persisted_epoch", None)(duck-typed, exactly likeset_fence/set_epoch/commit_manyelsewhere), so a non-file store simply supplies no floor. - Log at WARNING when the floor exceeds the lease epoch — that is the signal the lease file was lost or rewritten, and it is worth surfacing.
- Add the operator escape hatch and document it: allow
sf2loki state set __fence_epoch__ <n>when--forceis passed (statecmd.py:181,:207), and add a short recovery note todocs/deployment/high-availability.md’s fencing section describing the symptom (stale leader (epoch N) rejectedon a sole leader) and the fix.
Imported from GitHub issue #99 on 2026-08-14, when this repo migrated from GitHub Issues to Backlog.md. The original issue has been deleted; its verbatim body, labels and comments are preserved in archive/issues-dump.json (jq '.[] | select(.number == 99)' archive/issues-dump.json).
Filed from the 2026-07-30 full-repo audit (11 finder lanes + adversarial verification per finding).
Acceptance Criteria
- #1
FileLeaseCoordinatoraccepts an optionalepoch_floorcallable and derives the winning epoch asmax(lease_epoch, floor) + 1in_acquire(replacingsrc/sf2loki/coordinate/file_lease.py:207). - #2
FileCheckpointStore.persisted_epoch()reads__fence_epoch__fresh from disk (never from_cache) and returnsNonewhen the file or key is absent. - #3
app.pypasses the store’spersisted_epochas the floor forcoordinate.type: file_lease, duck-typed sos3/gcsstores (no such method) keep working unchanged. - #4 Test: state doc carries
__fence_epoch__ = "7", lease file absent →_acquireyields epoch 8 and a subsequentcommitsucceeds (this is the regression test for the reproduction above; it fails before the fix withStateFenceError). - #5 Test: state doc carries
__fence_epoch__ = "7", lease file present but corrupt/non-JSON →_acquireyields epoch 8, not 1. - #6 Test: lease epoch 9 with a stored epoch of 3 → new epoch is 10 (the lease still wins when it is ahead; the floor only ever raises).
- #7 Test: a floor callable that raises is logged and treated as absent — acquisition still succeeds with
lease_epoch + 1. - #8 Test:
epoch_floor=None(or a store with nopersisted_epoch) reproduces today’s derivation exactly; existing epoch tests intests/coordinate/test_file_lease.py(:97-106,:344-358,:404-429) stay green. - #9 Test:
state set __fence_epoch__ N --forcesucceeds and writes the key; without--forceit still exits non-zero with the “reserved” message (tests/test_statecmd.py:473,:517updated accordingly). - #10
docs/deployment/high-availability.mdfencing section documents the symptom, the automatic reconciliation, and the--forcerecovery command. - #11
just gategreen.
Definition of Done
- #1 just gate is green (ruff check + ruff format –check + mypy src + pytest) — run it, don’t assert it
- #2 just gen-config run and its output committed, if config.py changed (CI drift gate fails otherwise)
- #3 committed straight to main with a conventional-commit message, and pushed