myxce/.kiro/specs/ha-commit-durability/tasks.md

13 KiB

Implementation Plan

TDD is mandatory (project rule): write the failing test in testsuite/ before the C change (red → green → refactor). Re-verify HA changes against raft/raft.tla and the src/controller/CLAUDE.md Protocol Verification Checklist. Most core tasks edit the same correctness-critical files (persist.c, commit.c, ha.c, cluster.c) and are therefore sequential by design; only genuinely file-disjoint analysis is marked (P).

  • 1. Foundation: durability verification seam and deterministic reproduction

  • 1.1 Add a configurable flush-cadence override

    • Parse a --wal-sync-ms controller flag (following the existing --sync-timeout-ms convention) into the persistence config before persistence starts; default behavior unchanged.
    • Observable: starting a controller with --wal-sync-ms <N> sets the flush cadence to N; omitting it preserves the current default.
    • Requirements: 3.1
    • Boundary: DurabilityConfig
  • 1.2 Add the test-only durability-verification hook

    • Add an XCE_TESTING-gated seam that lets a test assert an acknowledged operation is recoverable after an immediate ungraceful termination; compiled out of production builds.
    • Observable: in a test build the hook is callable and the production build links without it.
    • Requirements: 3.2, 3.3
    • Boundary: DurabilityTestSeam
  • 1.3 Drive a deterministic durability reproduction from the soak harness

    • Extend test-commit-durability.sh with a deterministic mode that widens the batching window / uses the seam and kills a durable contributor in-window.
    • Observable: on the current (unfixed) build the deterministic mode reports DATA LOSS (RED), proving bug-661 without relying on race timing.
    • Requirements: 3.1, 3.2
    • Depends: 1.1, 1.2
  • 2. Durable commit mechanism (acknowledgment ⇒ majority persistence)

  • 2.1 Introduce durable-LSN tracking via a group-commit flusher

    • Repurpose the background fsync thread from a timer into a demand-driven flusher: an append signals it; it performs one fdatasync over all pending appends, advances a durable-LSN watermark, and wakes the commit evaluation.
    • Guard against lost wakeups: the signal is level-triggered on the appended-vs-synced gap so an entry is never considered durable before its fdatasync (gating item).
    • Observable: a unit test shows one fdatasync advances the durable-LSN past several appended LSNs under load, and the durable-LSN never exceeds the synced tip.
    • Requirements: 1.1, 1.6
    • Boundary: DurableCommit
  • 2.2 Count self toward the commit majority only when durable

    • Change the commit-majority evaluation so the local member's self-contribution is capped at the durable-LSN watermark, not the appended tip; clamp the watermark on WAL truncation.
    • Observable: a unit test shows committed_lsn does not advance over an entry until that entry is durable locally, and the self-cap equals the durable-LSN.
    • Requirements: 1.2, 1.4
    • Boundary: DurableCommit
  • 2.3 Pin standby persist-before-acknowledge with a regression test

    • Add a regression test asserting the standby apply path fdatasyncs a replicated entry before sending its acknowledgment.
    • Observable: the test fails if the standby ACK is emitted before the durable write and passes against the current behavior.
    • Requirements: 1.3
    • Boundary: DurableCommit
  • 2.4 Reconcile the dormant synchronous-append commit path

    • The synchronous-append path advances the commit position directly on majority acknowledgment and is built on the premise that the append already fsync'd inline — a premise group-commit removes. Confirm it has no production caller and delete it, or route it through the same durable-majority evaluation so it can never advance the commit position on a non-durable entry.
    • Observable: a code search shows no path advances the commit position on majority ACK without the durable precondition; the dormant path is removed or re-routed, and a test guards against a future caller reintroducing an ACK-without-durability commit.
    • Requirements: 1.1, 1.2
    • Boundary: DurableCommit
    • Depends: 2.1
  • 3. Recovery integrity (no silent truncate; durable floor)

  • 3.1 Return a recovery integrity verdict and mark a truncated member non-promotable

    • Make WAL replay distinguish a clean log end from a corruption-stopped end (CRC chain break / mismatch / short read) and surface that verdict; a corruption-truncated recovery is never reported as a clean recovery and sets a member-level "recovery-truncated" flag that the promotion gate (4.3) consumes.
    • Observable: a unit test injecting a CRC break at the first entry yields a TRUNCATED verdict and sets the non-promotable flag, while an intact log yields CLEAN; an empty first-boot WAL is CLEAN (not corruption).
    • Requirements: 2.1
    • Boundary: RecoveryIntegrity
  • 3.2 Pin the fail-loud vs fence boot policy as a decision with a verification table

    • Pin the concrete policy (gating item, decision only — no behavior change yet): a truncated member fences from leadership at boot, attempts catch-up from a healthy peer, and terminates loudly only on a bounded failure to reach a committed-consistent state — never a silent indefinite fence. Encode the decision as a table of input states → expected outcome.
    • Observable: the policy and its decision table are recorded, and skeleton tests assert each row's expected outcome (initially failing), establishing the contract 3.3 and 4.3 implement.
    • Requirements: 2.2, 2.4
    • Boundary: RecoveryIntegrity
  • 3.3 Implement loud FATAL on bounded-unrecoverable recovery

    • Implement the FATAL branch of the 3.2 policy: when a recovery-truncated member cannot reach a committed-consistent state within the bounded catch-up window, terminate loudly with the truncation boundary logged; a reachable healthy peer never triggers FATAL.
    • Observable: the 3.2 decision-table rows for the FATAL/no-FATAL cases pass; logs show the truncation boundary on FATAL.
    • Requirements: 2.4
    • Depends: 3.1, 3.2
    • Boundary: RecoveryIntegrity
  • 3.4 Re-derive the durable floor from the recovered WAL tip

    • On recovery, publish the member's durable floor as the highest intact replayed LSN so later advertisement is backed by durable state rather than a volatile in-memory position.
    • Precision: this floor is the durable last-log index (WAL tip), used by the Raft freshness gate — not the commit position; the post-election safe-commit point continues to come from voter replies, not from this floor.
    • Observable: a unit test shows that after a truncating restart the published durable floor equals the highest intact LSN (the WAL tip), not a stale higher in-memory value or the commit position.
    • Requirements: 2.3
    • Boundary: RecoveryIntegrity
  • 4. Promotion fence and durable-backed freshness

  • 4.1 (P) Pin the freshness-floor formula and re-verify election safety and liveness

    • Define the exact reconciliation of the durable-derived freshness floor against the existing in-memory floor — the durable quantity is the durable last-log index (WAL tip), not the commit position — then re-check it against raft/raft.tla for Leader Completeness AND liveness (no election livelock) plus the Protocol Verification Checklist (gating item; analysis only, no shared source files).
    • Scope of the raft.tla check: it validates the election/freshness logic (the mlastLogIndex/LastTerm semantics this change touches), which raft.tla models directly. It does NOT validate the durability of that floor — raft.tla assumes a durable log (see 5.3). The durability of the WAL-tip derivation is verified by tests (3.4), not by raft.tla.
    • Observable: the pinned formula is recorded and the model/checklist verification passes for both a truncated-candidate-denied case and a longest-durable-log-still-elects case.
    • Requirements: 2.3
    • Boundary: FreshnessFloor
  • 4.2 Derive the vote and PreVote freshness floor from durable WAL state

    • Implement the pinned formula so a candidate cannot advertise, and a voter cannot grant against, a position beyond durable WAL state; the post-election safe-commit point continues to come from voter replies, never a stale ctrlhello.
    • Observable: a node restarted with a truncated log is denied (vote and PreVote), while the node holding the longest durable log still wins — no livelock.
    • Note: 4.2 and 4.3 both edit the same election code (cluster.c vote/PreVote and ha.c promotion); they are sequential (4.2 before 4.3), not parallel.
    • Requirements: 2.3
    • Depends: 4.1, 3.4
  • 4.3 Add the pre-promotion eligibility gate and fence release

    • Before a member becomes primary, refuse promotion if it carries the recovery-truncated flag (from 3.1) or its durable position is below the cluster committed high-water mark; extend the existing catch-up fence to cover the promoting/recovered member and release it when catch-up passes the last committed boundary. Reuse the durable-LSN truncate-clamp from 2.2 so the gate evaluates a clamped durable position at the promotion-path truncate site.
    • Observable: a recovery-truncated or behind member is denied promotion until catch-up completes, then regains eligibility; a hollow node cannot win even at minimal quorum.
    • Requirements: 2.2, 2.5, 2.6
    • Depends: 4.2, 3.1, 3.2, 2.2
  • 5. Integration and validation

  • 5.1 Wire durable-majority commit end to end

    • Connect the durable-LSN self-count and the commit barrier so the client reply is released only after the entry is durable on a majority; assert liveness (the flusher does not stall heartbeat/election handling).
    • Observable: an integration test confirms reply-ok only after durable-majority, killing the sole durable contributor mid-commit leaves the acknowledged op present on the survivors, and the full controller suite passes under ASAN.
    • Requirements: 1.1, 1.4, 1.5, 1.6, 2.5
    • Depends: 2.2, 4.2
  • 5.2 Prove zero acknowledged-job loss across failover

    • Run the deterministic durability E2E and the rolling SIGKILL+restart soak; both must show every acknowledged job surviving every failover.
    • Observable: the deterministic mode that was RED in 1.3 now passes (GREEN), and the soak reports zero DATA LOSS across rounds.
    • Requirements: 1.1, 1.5, 2.2, 2.3, 2.6
    • Depends: 5.1, 4.3, 1.3
  • 5.3 Re-verify the formal safety checklist and flip the durability gap note

    • Re-run the raft/raft.tla alignment and the Protocol Verification Checklist (commit rule, leader completeness, commitIndex monotonic) against the integrated implementation.
    • IMPORTANT — raft.tla does NOT model durability: it restores log intact from "stable storage" on restart (raft.tla:165-174), i.e. it assumes what bug-661 violates. A passing TLC/alignment run on the unmodified spec therefore does NOT prove the commit-durability invariant. That invariant is verified by the unit/integration/E2E tests (2.x, 5.1, 5.2) plus the manual checklist — not by raft.tla. Do not conclude durability is proven from raft.tla alone. Formal durability proof would require extending raft.tla with a durable-vs-volatile log model (out of scope unless explicitly opted in).
    • Update src/controller/CLAUDE.md: convert the "Commit Durability — KNOWN GAP (bug-661)" note into the now-true guarantee and tick the post-ha-commit-durability checklist item.
    • Observable: the checklist passes with the durable self-count and the durable-backed freshness floor in place; the durability invariant is backed by passing tests (not by raft.tla); the CLAUDE.md gap note is removed; any deviation is recorded.
    • Requirements: 1.2, 2.3
    • Depends: 5.1

Implementation Notes

  • 1.1: Config plumbing routes through myconf_t.wal_sync_ms + persist_config_set_wal_sync_ms() (0-sentinel = keep DEFAULT_WAL_SYNC_MS), NOT a new config.h constant (design File Structure Plan keeps DEFAULT_WAL_SYNC_MS). The override is applied at BOTH persist_init sites — myapp_run step-6 (production path) and cluster_init_phase2 (guarded by wal_path[0]=='\0', skipped in production). They are mutually exclusive; apply any new persist_config override at both sites or it is silently dropped. Pre-existing latent bug (do NOT fix here): --sync-timeout-ms is applied only in phase2, so it is ignored on the production startup path — relevant if a later task relies on sync_timeout_ms.
  • 1.2: Test seam = persist_test_durable_lsn_on_disk(persist_t*) (decl persist.h, impl persist.c, all #ifdef XCE_TESTING) — re-opens WAL segment files from disk and CRC-validates the chain (read-only, state-free) to return the highest fully-persisted LSN; new test testsuite/test_commit_durability.c (registered in meson.build + controller_test.c). Production xce_controller links without it (proven via nm). DRY NOTE: ~115 lines of segment-scan/CRC-chain logic are duplicated from the static replay_wal; tasks 3.1 (recovery verdict) and 3.4 (durable floor from WAL tip) touch the SAME scan/CRC logic — consider extracting a shared read-only wal_scan_durable_lsn-style helper there and routing this seam through it (avoid a third copy). XCE_TESTING is defined only for controller_unit_test (src/controller/meson.build:167).