Task · SFL-0014

coordinate: k8s_lease has no epoch fence - a stale leader can still regress a shared file checkpoint store

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

Description

What

The durable epoch fence added for #47 is wired only for the file_lease coordinator. The k8s_lease coordinator gets the lagging boolean fence and nothing else, so a coordinate.type: k8s_lease + state.store: file deployment on shared storage has no write-time fencing at all.

Composition root, src/sf2loki/app.py:

K8sLeaseCoordinator has no epoch to wire: its fencing surface is is_leader (src/sf2loki/coordinate/k8s_lease.py:186), check_fence (k8s_lease.py:189) and holder (k8s_lease.py:203) — no counterpart to file_lease.py:143 def epoch. The module never reads or writes spec.leaseTransitions; _Lease (k8s_lease.py:57-90), _LeaseBody (k8s_lease.py:93-106) and _RealLeaseAdapter._from_v1_lease (k8s_lease.py:494-513) carry only holder/renew_time/duration/resource_version.

Consequence in the store. With _epoch_fn is None, FileCheckpointStore.commit_many takes the cache path at src/sf2loki/state/file_store.py:222-226: _ensure_loaded() (loads the document once, file_store.py:159-164) → self._cache.update(items)_flush(). _flush (file_store.py:166-190) serialises the whole cache and os.replaces the file with no compare-and-swap. delete() mirrors it (file_store.py:238-248). So a commit from an instance whose cache predates another leader’s writes silently rewrites every key in the document, not just the keys in items. The epoch-fenced path (file_store.py:274-296) is what re-reads fresh and rejects stored > mine — and it is unreachable here.

The dual-writer window is real for this coordinator:

Nothing rejects or warns about the combination:

#47’s body cited k8s_lease.py as part of the same defect, but the epoch work landed after the coordinator (0ec2a18 k8s lease 2026-07-02 14:15 → 73101d7 epoch wiring 17:37) and covered file_lease only. #47 is closed with no comments recording the k8s path as descoped, and no test pins the current behaviour (set_epoch appears only in tests/state/test_file_store.py and tests/test_statecmd.py; tests/test_app_integration.py:389 asserts only the missing-extra ConfigError).

Why it matters

Two replicas with coordinate.type: k8s_lease sharing state.store: file on an RWX (NFS/EFS) volume:

  1. Leader A’s last successful Lease renew is at T0; it then loses API-server connectivity (or stalls the event loop past lease_duration).
  2. Standby B observes the Lease’s resourceVersion unchanged for lease_duration on its own monotonic clock, takes over, and commits advanced watermarks into the shared state file.
  3. A stays is_leader == True until its next renew tick at or after T0 + lease_duration — up to a full renew_interval after B’s takeover, longer across a stall. Every commit A makes in that window passes check_fence, then _flush (file_store.py:166-190) rewrites the entire document from A’s pre-takeover cache, wiping B’s advanced watermarks for all keys, including sources A is not even running.
  4. B (or the next leader after a restart/demote, which re-reads the file) resumes from the regressed watermarks and re-queries and re-pushes everything since them.

No data is lost (commit still follows a successful push), but the re-ingest is unbounded by the documented one-lease-duration window, costs Salesforce API calls and Loki ingest, and produces duplicates outside Loki’s per-stream reject window. There is no error, no log warning and no doctor FAIL anywhere in the path, so an operator has no signal that the topology they configured is unfenced — while the equivalent file_lease topology is fenced and the s3/gcs stores get ETag/generation CAS for free.

Proposed approach

Two parts; part 2 alone is acceptable as a stop-gap but part 1 is the actual fix.

1. Give K8sLeaseCoordinator an epoch and wire it. spec.leaseTransitions is the canonical monotonic takeover counter in coordination.k8s.io/v1 (client-go’s leaderelection increments it whenever holderIdentity changes), so it needs no new storage:

2. Close the silent-misconfiguration hole. Add a startup check that coordinate.type in {file_lease, k8s_lease} with state.store: file is a shared-storage topology the operator has to mean: at minimum a loud WARN log in App.build, plus a doctor check that reports the coordinator/state-backend pairing (extend _check_coordinator, doctor.py:596-606, or add a sibling check) so sf2loki doctor surfaces k8s_lease + file explicitly rather than passing silently.

Known trap to document with the fix. The persisted __fence_epoch__ (file_store.py:19) outlives the Lease. Deleting and recreating the Lease resets leaseTransitions to 0/absent, after which every legitimate new leader is rejected by _commit_many_epoch_fenced (file_store.py:284-292) forever. file_lease has the same hazard if the lease file is deleted. The recovery is deleting the reserved key (src/sf2loki/statecmd.py:35 already documents __fence_epoch__ as internal bookkeeping) — state it in docs/deployment/high-availability.md’s fencing section and in docs/deployment/state.md.

Docs to update alongside: docs/deployment/high-availability.md:104-110 (the epoch mechanism is no longer file_lease-specific) and high-availability.md:88 (keep the s3/gcs recommendation, but say what happens with file on shared storage).


Imported from GitHub issue #98 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 == 98)' 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