Description
What
sf2loki state show|set|delete relies on the file store’s sidecar flock to detect a running daemon, but the daemon does not take that flock in any HA deployment, so the guard is dead code exactly where it is most needed.
statecmd.py:110builds the store withexclusive_lock=not force(flock ON unless--force).app.py:929builds the daemon’s store withexclusive_lock=cfg.coordinate.type == "noop"(issue #49: a real coordinator is the exclusivity mechanism, and a process-lifetime flock crash-loops a promoted standby). Underfile_lease/k8s_lease,file_store.py:117-120returns beforeos.open, so the<state file>.locksidecar is never created or held.- Consequently the CLI’s flock acquisition succeeds uncontested,
StateFileLockErroris never raised, and the refusal branch atstatecmd.py:117-126is unreachable. The promise made atstatecmd.py:12-21,docs/deployment/state.md:33(“state show/set/deleterefuses to run while the daemon holds the lock”) anddocs/reference/cli.md:113does not hold in the topologydocs/deployment/high-availability.md:86-87prescribes for the file backend:coordinate.type: file_leasewithstate.store: fileon a shared NFS/EFS export - i.e. precisely the case where the state file is reachable from an operator box while the leader is live. - The epoch fence does not substitute for the missing guard.
statecmdkeeps__fence_epoch__inside the store’s_cache(it is filtered only from theshowlisting,statecmd.py:63,160), so its whole-document flush writes the epoch back unchanged and the daemon’s epoch-CAS check atfile_store.py:288-292never trips. - No other layer catches it:
cli.py:243-250passes only config path/key/value/forceintostatecmd, andconfig.pyhas no validator rejectingstate.store: filealongside a non-noop coordinator.
Two distinct clobber shapes on the daemon side, depending on whether set_epoch is installed:
coordinate.type: file_lease+state.store: file(the documented HA pairing) -epoch_sourceis wired atapp.py:948,966-968, so_commit_many_epoch_fenced(file_store.py:283-297) re-reads the file fresh and merges only the keys it is committing. Unrelated operator edits survive; the repaired key does not.eventlog_objects_source.py:292reloads its watermark from the store each cycle (served from_cache), andapp.py:505-510commits that source’s own token after every successful push, so the leader’s next push for the repaired key writes the poison-derived value straight back over it. Where the wedged source emits nothing and therefore commits nothing, the daemon’s_cachesimply stays stale and the repair does not take effect until some unrelated key’s commit refreshes the cache. Either way the repair is unreliable on a live leader, and neither side logs anything.coordinate.type: k8s_lease+state.store: file(a shared RWX volume; not the documented pairing, but nothing forbids it) -epoch_sourcestaysNone(app.py:955-960), so the daemon’s commit path isfile_store.py:222-226:_ensure_loaded(one cached read at startup) +_cache.update(items)+_flush()writes the whole stale document. That reverts unrelatedstate setvalues and resurrectsstate deleted keys.
The object-store backends are unaffected: their ETag/generation CAS surfaces a live concurrent writer as StateStoreConflictError, which statecmd.py:127-132 reports actionably.
Test coverage does not cover the HA case: tests/test_statecmd.py:546-620 simulate “the daemon is running” with a default FileCheckpointStore (exclusive_lock=True), which only models coordinate.type: noop.
Why it matters
docs/deployment/state.md is the poison-checkpoint runbook, reached from the sf2loki ingest lag high / sf2loki no recent Loki push alerts, and it is the tool of last resort for a wedged watermark. Nothing in the runbook tells the operator to stop the leader first - the flock refusal was the mechanism meant to enforce that. On a file_lease HA pair the operator runs sf2loki state set eventlog_objects:LoginEvent <good-watermark>, gets sf2loki: set eventlog_objects:LoginEvent = <good-watermark> and exit 0, and within one push interval the leader has written the poison value back. The source stays wedged, the alert keeps firing, and the operator’s mental model says the checkpoint was already repaired - so the next escalation step is taken against a false premise. Under k8s_lease with a file store the blast radius is wider: an unrelated key’s delete reappears and other keys regress, producing unexplained duplicate re-ingestion windows.
Proposed approach
Make statecmd coordinator-aware, since the flock can no longer answer “is a daemon running?”.
- In
statecmd._run, afterload(config_path)succeeds and beforebuild_store, inspectcfg.coordinate.type. When it is not"noop"andforceis false, print an explicit refusal to stderr and return_OPERATION_ERROR_EXIT_CODE:- state that the daemon does not hold the state-file lock under a coordinator (so the lock cannot protect this command), that the leader must be stopped (or the whole pair) before repairing checkpoints, and that
--forcebypasses the check.
- state that the daemon does not hold the state-file lock under a coordinator (so the lock cannot protect this command), that the leader must be stopped (or the whole pair) before repairing checkpoints, and that
- When
cfg.coordinate.type == "file_lease", enrich that message with live lease facts: readcfg.coordinate.file_lease.path(same JSON shape parsed atcoordinate/file_lease.py:327-361:holder,expires_at,epoch) and, when the document parses andexpires_atis in the future, name the current holder and expiry (“holder=<id>is renewing the lease, expires at<ts>”). An absent, corrupt, or expired lease still refuses, but says the lease looks stale so no leader is likely live. Reuse a small local parser or expose a module-level read helper rather than instantiatingFileLeaseCoordinator(its_readis private and its constructor pulls in run-loop state). - Apply the refusal to all three subcommands, matching the existing flock semantics (
showrefuses today undernoop, and ashowagainst a live leader reports values that may be mid-flight). Keep--forceas the single escape hatch for both the flock and this check, and update its help text atcli.py:160-164,cli.py:172-176,cli.py:184-188. - Docs:
docs/deployment/state.md:33- correct thefilerow: the flock only guardscoordinate.type: noop; under a coordinator the CLI refuses on the coordinator instead, and repairs require stopping the leader.docs/deployment/state.md- add a short “repairing checkpoints in an HA pair” step to the runbook: stop the leader (and the standby, or it takes over and re-wedges), repair, restart.docs/reference/cli.md:113,123- restate--forceas bypassing both the exclusive lock and the HA refusal.docs/deployment/high-availability.md- cross-reference from the shared-state section (around line 81-91) that live checkpoint repair is not safe on a running leader.
- Optional hardening, separable: reject
state.store: filewithcoordinate.type: k8s_leaseinconfig.py, or wireepoch_sourcefork8s_leasetoo, so the whole-document-from-stale-cache rewrite atfile_store.py:222-226cannot reach a shared file. Note in the change which option was taken and why.
Imported from GitHub issue #102 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 == 102)' archive/issues-dump.json).
Filed from the 2026-07-30 full-repo audit (11 finder lanes + adversarial verification per finding).
Acceptance Criteria
- #1
statecmd._runrefusesset/delete/showwith a non-zero exit whencfg.coordinate.typeisfile_leaseork8s_leaseand--forcewas not passed; the message names the coordinator type and says the leader must be stopped. - #2
--forcestill performs the operation under a non-noop coordinator (both the flock bypass and this new refusal), unchanged from today’s--forcepath. - #3
coordinate.type: noopbehaviour is byte-identical to today: the flock refusal atstatecmd.py:117-126remains the only guard, and existingtests/test_statecmd.py:546-620pass unmodified. - #4 For
file_leasewith a readable, unexpired lease document, the refusal message includes the lease holder andexpires_at; with an absent/corrupt/expired lease it still refuses but says the lease looks stale. - #5 Test:
state setagainst a config withcoordinate.type: file_leaseandstate.store: filereturns exit 1 and leaves the state file byte-identical (pins that no write happened). - #6 Test: the same config with
--forcewrites the key (pins the escape hatch). - #7 Test:
state showandstate deleteundercoordinate.type: k8s_leaseboth refuse without--force. - #8 Test: the refusal message contains the holder id and expiry parsed from a hand-written
file_leaselease JSON with a futureexpires_at, and the stale-lease wording for one with a pastexpires_at. - #9 Regression test pinning the mechanism this issue exists for: a
FileCheckpointStore(path, exclusive_lock=False)standing in for an HA daemon does not blockstatecmd’s flock acquisition - assertrun_state_setreturns 0 today, and 1 after the fix, against that same fixture. - #10
docs/deployment/state.md:33, the new HA repair step,docs/reference/cli.md:113,123and thedocs/deployment/high-availability.mdcross-reference all land; no doc still claims the lock refusal protects an HA deployment. - #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