Task · SFL-0013

ha: an epoch-fenced stale leader shuts the whole process down (exit 0) instead of demoting to standby

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

Description

What

App._run_pipeline (src/sf2loki/app.py:1227-1238) absorbs StateFenceError and returns normally, documented as “a leadership transition, not a fatal crash — the coordinator drives the move back to standby”. The pipeline task therefore completes cleanly, and the done callback misclassifies that completion as a finite run:

# src/sf2loki/app.py:1168-1182
def _on_pipeline_done(task: asyncio.Task[None], run_stop: asyncio.Event) -> None:
    if task.cancelled():
        return
    exc = task.exception()
    if exc is not None:
        crash.append(exc)
        stop.set()
        return
    # Clean completion while we never asked it to stop means the sources
    # exhausted on their own (a finite run) -> shut down. ...
    if not run_stop.is_set():
        stop.set()          # <-- GLOBAL stop event

run_stop is only ever set by _stop_acquisition (src/sf2loki/app.py:1281-1287), reached from on_lose (src/sf2loki/app.py:1198-1206) — i.e. only after the coordinator itself has noticed the loss. There are two fences, and only one of them satisfies that precondition:

Propagation path, with nothing swallowing or retrying the error: Pipeline._commit (src/sf2loki/app.py:497-509) → _flush_consumePipeline.run re-raises the consumer exception (src/sf2loki/app.py:284-290) → _drain_with_grace (src/sf2loki/app.py:596-602) → absorbed at src/sf2loki/app.py:1237. The file store has no commit-retry wrapper and OrgView forwards verbatim (src/sf2loki/state/org_view.py:65-68).

With crash empty, App.run returns without raising (src/sf2loki/app.py:1224-1225) and cli.main returns 0 (src/sf2loki/cli.py:269-270).

Reproduced with a stub pipeline raising StateFenceError under a coordinator that holds leadership until the global stop fires: the process logs checkpoint commit fenced — leadership lost; standing by, then leadership lost — standing by, and App.run() returns cleanly — a full shutdown with exit code 0 while claiming to stand by.

Note that for a long-running service the not run_stop.is_set() branch is otherwise only reachable when there are no sources at all (Pipeline.run returns immediately at src/sf2loki/app.py:256), so the fence-absorbed completion is in practice the main way that branch fires.

Why it matters

file_lease HA pair on a shared NFS/EFS state volume. Leader A’s lease renewal starts failing (NFS write errors, a stall, or a VM pause); _hold tolerates renewal failure for a full ttl before surrendering (src/sf2loki/coordinate/file_lease.py:286-300), while standby B takes over at ttl expiry and bumps the epoch to N+1. Inside that overlap A’s consumer flushes an in-flight batch, and the commit is epoch-fenced (stored N+1 > mine N) — the fence working exactly as designed. Instead of demoting to standby and waiting to re-acquire, A’s whole process shuts down.

Consequences:

No data loss: the batch already landed in Loki and the new leader owns the checkpoints, so semantics stay at-least-once. The defect is loss of redundancy plus a misleading exit code.

Proposed approach

Distinguish “fenced” from “finite sources exhausted” so the callback does not set the global stop for a fence-absorbed completion.

  1. Make the fenced case explicit rather than indistinguishable from a clean return. Either:
    • have _run_pipeline (src/sf2loki/app.py:1227-1238) record the fence on the per-acquisition state (e.g. current["fenced"] = True, or a fenced: list[bool] closure alongside crash at src/sf2loki/app.py:1165) and have _on_pipeline_done skip stop.set() when that flag is set; or
    • re-raise a private marker (class _Fenced(Exception)) that _on_pipeline_done recognises before the crash.append branch and treats as a leadership transition: no crash.append, no stop.set(). Whichever shape, reset the flag per acquisition so a later fence-free finite run still shuts down.
  2. On the fenced path, demote immediately rather than waiting up to renew_interval for on_lose: drop the leader gauge (self._metrics.leader.set(0)), mark not-ready (self._health.set_not_ready("standby")) and invalidate the cached checkpoint document (self._pipeline.reset_state()). The pipeline is already fully torn down at that point (Pipeline.run cancels its producers in its finally, src/sf2loki/app.py:424-427), so nothing can commit against the invalidated cache. on_lose repeats all three idempotently when the coordinator catches up, so this is additive.
  3. Leave the crash path and the finite-run path unchanged: a non-fence exception must still append to crash, set the global stop, and exit nonzero.
  4. Do not “fix” this by exiting nonzero on a fence — that still drops the replica out of the pair for a restart cycle. Demote-in-place is the intent stated in _run_pipeline’s docstring.
  5. Add a short note to the Fencing section of docs/deployment/high-availability.md (around line 96-108) stating that a fenced commit demotes the replica to standby in place and never terminates the process.

Imported from GitHub issue #97 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 == 97)' archive/issues-dump.json).

Additional evidence (parallel review lanes)

There is also a non-racy route to the same exit: at src/sf2loki/coordinate/file_lease.py:271-287, a stale leader whose lease read and verify re-read both fail with _LeaseReadError falls through to self._write(now, self._epoch) and keeps _is_leader = True indefinitely — on_lose never fires, run_stop is never set, and every subsequent commit is epoch-fenced by the new leader’s document, so the process reliably logs “checkpoint commit fenced — leadership lost; standing by” and then fully exits with code 0. That blind-rewrite path is tracked separately in #96; this issue owns the fence-to-demotion (not fence-to-exit) behaviour in app.py.

The genuine demotion path is unaffected and must stay that way: src/sf2loki/coordinate/file_lease.py:174-177 sets _is_leader = False and then awaits on_lose, which reaches run_stop.set() (src/sf2loki/app.py:1281-1287 via app.py:1198-1201) with no intervening await, so the boolean fence can never fire with run_stop unset — only the epoch fence can.

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