Task · SFL-0018

cli: `sf2loki state` lock refusal never fires under a non-noop coordinator - the live leader silently reverts an operator's checkpoint repair

Status
To Do
Labels
followup, phase-5
Milestone
Correctness & data-integrity hardening
Updated
2026-08-14

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.

Two distinct clobber shapes on the daemon side, depending on whether set_epoch is installed:

  1. coordinate.type: file_lease + state.store: file (the documented HA pairing) - epoch_source is wired at app.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:292 reloads its watermark from the store each cycle (served from _cache), and app.py:505-510 commits 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 _cache simply 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.
  2. coordinate.type: k8s_lease + state.store: file (a shared RWX volume; not the documented pairing, but nothing forbids it) - epoch_source stays None (app.py:955-960), so the daemon’s commit path is file_store.py:222-226: _ensure_loaded (one cached read at startup) + _cache.update(items) + _flush() writes the whole stale document. That reverts unrelated state set values and resurrects state 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?”.

  1. In statecmd._run, after load(config_path) succeeds and before build_store, inspect cfg.coordinate.type. When it is not "noop" and force is 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 --force bypasses the check.
  2. When cfg.coordinate.type == "file_lease", enrich that message with live lease facts: read cfg.coordinate.file_lease.path (same JSON shape parsed at coordinate/file_lease.py:327-361: holder, expires_at, epoch) and, when the document parses and expires_at is 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 instantiating FileLeaseCoordinator (its _read is private and its constructor pulls in run-loop state).
  3. Apply the refusal to all three subcommands, matching the existing flock semantics (show refuses today under noop, and a show against a live leader reports values that may be mid-flight). Keep --force as the single escape hatch for both the flock and this check, and update its help text at cli.py:160-164, cli.py:172-176, cli.py:184-188.
  4. Docs:
    • docs/deployment/state.md:33 - correct the file row: the flock only guards coordinate.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 --force as 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.
  5. Optional hardening, separable: reject state.store: file with coordinate.type: k8s_lease in config.py, or wire epoch_source for k8s_lease too, so the whole-document-from-stale-cache rewrite at file_store.py:222-226 cannot 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

Definition of Done

References

View the source file on GitHub