35 KiB
Design Document — ha-commit-durability
Overview
Purpose: Make acknowledgment imply durability in the XCE HA controller. Today the controller advances its commit position on a majority of replication acknowledgments, but the primary counts its own contribution from a page-cache write (commit.c:majority_candidate_locked uses highest_appended_lsn), while background fdatasync runs on a timer (fsync_worker). A "committed" operation can therefore be durable on as few as one member — bug-661, observed as permanent job loss across failover.
Users: Cluster operators and every client whose acknowledged job/queue/worker operations must survive controller failover.
Impact: Strengthens the meaning of "committed" from "majority-ACKed (volatile)" to "persisted to stable storage on a fault-tolerant majority." The client reply path is already gated on the commit barrier, so once committed_lsn implies a durable majority, the invariant holds with no change to the reply path. Durability becomes group-commit: fsync_worker is repurposed from a fire-and-forget timer into a demand-driven flusher that, on append, batches fdatasync across all pending entries, advances durable_lsn, and wakes the commit evaluation. The commit majority counts durable_lsn (not appended). This amortizes one fdatasync across many entries — important because the highest-volume WAL traffic is WAL_MODE_RETAIN facts (job start/finish/fail/rusage), which are not client-latency-bound but must be durable before committed_lsn passes them. A second concern — a member with a corrupt or truncated log becoming authoritative — is closed by a recovery-integrity verdict and a promotion fence.
Goals
- Durable commit: when the controller replies ok, the operation's log entry is
fdatasync'd on the acknowledging member plus ⌊N/2⌋ others (Req 1). - Recovery integrity & promotion fence: a member that cannot reconstruct its committed state, or that lags the cluster's committed high-water mark, is barred from leadership until caught up — fail-loud when it cannot recover safely (Req 2).
- Deterministic verification: durability timing is controllable in a test mode so the guarantee is provable without racing the clock (Req 3).
- Preserve cluster liveness: durability work never stalls heartbeat/election processing.
Non-Goals
- Single-record job-array persistence (
job-array-single-wal-record, roadmap) — complements this spec by bounding per-array fsync count; independent of the group-commit flusher introduced here. - Per-entry synchronous fsync as the steady-state mechanism — superseded by group-commit (decision: the high-volume
WAL_MODE_RETAINpath needs batching; see Components/DurableCommit). - Replication/ACK wire format (
ha-wal-replication), per-entry majority arithmetic (ha-commit-engine), demotion truncate / rejoin reconciliation of an intact log (ha-convergence-recovery), promotion no-op / own-term gate (ha-single-leader-role), raw vote-grant arithmetic (raft-library).
Boundary Commitments
This Spec Owns
- The durability precondition on the commit majority: a member contributes to the commit majority for an LSN only once that LSN is
fdatasync'd locally. Introduces and is authoritative forpersist_t.durable_lsn. - Group-commit durability:
fsync_workerrepurposed from a timer into a demand-driven flusher that batchesfdatasync, advancesdurable_lsn, and wakes commit evaluation;persist_wal_asyncsignals the flusher instead of deferring to a timer. Benefits all members on the high-volumeWAL_MODE_RETAINpath. - Recovery integrity verdict:
replay_wal/persist_recoverdistinguish a clean log end from a corruption-truncated end, and re-derive the member's durable floor from the recovered WAL. - Promotion eligibility fence: a recovery-truncated member, or one whose durable position is below the cluster committed high-water mark, is ineligible to become primary until caught up; re-eligible on catch-up.
- Durable-backed freshness advertisement: a candidate's advertised log position and a voter's freshness floor derive from durable WAL state, not from volatile in-memory positions that survive a truncating restart.
- Durability verification seam: a configurable durability cadence / background-flush control plus an
XCE_TESTINGhook.
Out of Boundary
- Counting the majority or advancing
committed_lsnarithmetic (ha-commit-engine— consumed; this spec changes only the durability precondition of its inputs). - The reply ordering / classification sweep (
ha-commit-barrier-rollout— reused unchanged). - Demotion truncate + self-restart and rejoin reconciliation of an intact log (
ha-convergence-recovery). This spec fences promotion; convergence-recovery handles demotion and rejoin. - Vote-grant arithmetic and term handling (
raft-library); this spec adds only the durability/high-water precondition to the existing freshness gate. - WAL group-commit batching; job-array single-record persistence.
Allowed Dependencies
ha-commit-engine:commit_engine_t,commit_submit,majority_candidate_locked(modify self-contribution input only).ha-wal-replication: standby ACK path (persist_wal_write_replicated) and catch-up streaming (consumed;in_catch_upreused).ha-commit-barrier-rollout:persist_commit_barrierreply gate (consumed unchanged).persist_tinternals (current_lsn,committed_lsn,last_crc, segment I/O),ha_state_t(applied_lsn,last_known_committed_lsn,in_catch_up,term).- Constraint: must not write/persist
committed_lsnto disk (Raft keeps the commit index volatile); durability is about the log, not the commit index.
Revalidation Triggers
- Change to the
durable_lsnsemantics or tomajority_candidate_locked's self-contribution → re-checkha-commit-engineandha-convergence-recovery(which assume durable committed state). - Change to the promotion-eligibility gate or freshness-floor source → re-check
ha-single-leader-role,ha-prevote,raft-library, and theCLAUDE.mdTLA+ checklist. - Removal of
fsync_worker→ re-check any caller that assumed deferred fsync (auditwal_classify). - New
wal_syncoverride surface → re-checkmain.cflag conventions and deployment scripts.
Architecture
Existing Architecture Analysis
- Commit gate already off the iomgr thread: the AFTER_EVENT hook "writes WAL and waits for majority replication" before the reply (
job.c:568-569);persist_commit_barrier → commit_submit(COMMIT_MODE_WAIT)blocks on a condvar. Addingfdatasyncto this path is liveness-safe. - Standby already persists-before-ACK:
persist_wal_write_replicatedfdatasyncs before returning (persist.c:515). The primary is the asymmetric gap. - Recovery is silent-truncating:
replay_walbreaks on CRC chain break / mismatch / short read (persist.c:1021/1050/1042) and returns the replayed count as success;persist_recovernever setscommitted_lsn(stays 0). - Promotion has no integrity gate:
raft_cb_become_leadercomputessafe_lsnfrom votes thenpersist_truncate_wal_above(safe_lsn)(ha.c:572/577) with no check that the local log is intact or ≥safe_lsn. - Freshness floor is volatile:
ha_vote_self_freshness = max(applied_lsn, last_known_committed_lsn)(ha.c:2127), in-memory atomics not re-derived from durable WAL. - Test-seam convention:
#ifdef XCE_TESTINGhook shims (ha.c:2270-2335);wal_sync_msis a hard constant (config.h:86) with no override.
Architecture Pattern & Boundary Map
graph TB
subgraph RequestPath
Hook[AFTER_EVENT hook off iomgr]
Barrier[persist_commit_barrier]
end
subgraph Persistence
Append[persist_wal_async sync fsync]
DurLsn[durable_lsn]
Recover[persist_recover integrity verdict]
ReplApply[persist_wal_write_replicated standby fsync]
end
subgraph Commit
Majority[majority_candidate_locked durable self]
Committed[committed_lsn]
end
subgraph Election
Fresh[freshness floor from durable WAL]
Promote[raft_cb_become_leader promotion fence]
end
Hook --> Append
Append --> DurLsn
Append --> ReplApply
DurLsn --> Majority
ReplApply --> Majority
Majority --> Committed
Committed --> Barrier
Barrier --> Reply[reply ok to client]
Recover --> Fresh
Recover --> Promote
Fresh --> Promote
DurLsn --> Promote
Architecture Integration
- Selected pattern: extend-in-place for the durability precondition (Req 1, 3) plus a small testable fence helper for promotion eligibility (Req 2). No new module —
durable_lsnis one atomic onpersist_t. - Boundaries: persistence owns
durable_lsnand the recovery verdict; the commit engine consumesdurable_lsn; election consumes the recovery verdict + durable-backed floor. No shared ownership of the commit index. - Patterns preserved: commit-barrier reply gate,
in_catch_upfence concept,XCE_TESTINGhooks, the standby persist-before-ACK already in place. - Steering compliance: never block the iomgr thread (fsync lands on the off-iomgr commit path); re-verify against
raft/raft.tla(commit rule, leader completeness) perCLAUDE.md.
Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|---|---|---|---|
| Backend / Services | C (gnu17), controller daemon | Durable commit, recovery integrity, promotion fence | Extends persist.c, commit.c, ha.c, cluster.c |
| Data / Storage | Local WAL segments + fdatasync |
Synchronous durability; integrity-verified recovery | posix_fallocate'd segments; fdatasync (data + pre-allocated metadata) |
| Infrastructure / Runtime | --wal-sync-ms (new) + XCE_TESTING seam |
Deterministic durability verification | Follows --sync-timeout-ms (OPT_SYNC_TIMEOUT) precedent |
File Structure Plan
Modified Files
src/controller/persist.c—persist_wal_async: append then signal the flusher (no inline per-entry fsync); convertfsync_workerfrom a timer wait into a demand-driven group-commit flusher (wake on append-signal,fdatasync, advancedurable_lsnto the highest synced LSN, broadcast the commit-eval condvar); keep its thread/mutex/cond lifecycle inpersist_start/persist_stop/persist_fini.replay_wal/persist_recover: return an integrity verdict; re-derive durable floor from the recovered WAL tip.persist_wal_sync_append(dormant, no production caller): delete or re-route through the durable-majority evaluation — it must not advance the commit position on majority ACK without the durable precondition (task 2.4).src/include/persist.h— add_Atomic uint64_t durable_lsnand a recovery-verdict type topersist_t/the recover signature; keepfsync_*fields (now driving the group-commit flusher); add an override field topersist_config_t(or its setter).src/controller/commit.c—majority_candidate_locked: usedurable_lsn(nothighest_appended_lsn) for the self contributioncap.src/controller/ha.c—raft_cb_become_leader: insert the pre-promotion eligibility gate;ha_vote_self_freshness: derive floor from durable WAL state; fence-release on catch-up completion. NewXCE_TESTINGdurability-verification hook in the seam region (ha.c:2270-2335).src/controller/cluster.c— vote / PreVote freshness comparison (cluster.c:380-396,467-474) consume the durable-backed floor; promotion path honors the fence.src/include/config.h— keepDEFAULT_WAL_SYNC_MS; reconcile the stale50inpersistence-and-ha-design.md.src/controller/main.c— parse--wal-sync-ms(and optionally--wal-sync-disable) intopersist_config_t.src/controller/myapp.c/cluster.c(persist_config_defaultscallers) — apply the override beforepersist_init.
New Files
src/controller/testsuite/test_commit_durability.c— unit/white-box tests fordurable_lsnadvancement, self-counts-durable, recovery verdict, promotion fence (Req 1, 2, 3).src/tools/test-commit-durability.sh— exists (soak E2E); extend to drive the new--wal-sync-*seam for a deterministic mode.
Each modified function keeps one responsibility: persistence owns durability + recovery verdict; commit owns majority math (consuming
durable_lsn); election owns eligibility (consuming the verdict + floor).
System Flows
Durable commit (reply ok ⇒ persisted on a majority)
sequenceDiagram
participant Client
participant Primary
participant Standby
participant Flusher
Client->>Primary: submit op
Primary->>Primary: persist_wal_async pwrite then signal flusher
Primary->>Standby: replicate entry
Flusher->>Flusher: batched fdatasync then durable_lsn advance then wake commit
Standby->>Standby: persist_wal_write_replicated fdatasync
Standby-->>Primary: WalAck acked_lsn
Primary->>Primary: majority_candidate_locked self equals durable_lsn plus standby acks
Primary->>Primary: committed_lsn advance then commit barrier releases
Primary-->>Client: reply ok
Key decisions: the reply is released only after committed_lsn covers the entry, and committed_lsn now requires a durable self plus a durable standby majority. The fdatasync runs on the dedicated flusher thread (the repurposed fsync_worker), batched across all pending appends; the AFTER_EVENT caller blocks on the commit barrier (off iomgr) until durable_lsn + standby acks cover its entry, so heartbeats are unaffected (Req 1.6) and one fdatasync amortizes across the high-volume RETAIN-fact stream.
Recovery integrity & promotion fence
graph TD
Start[startup persist_recover] --> Replay[replay_wal]
Replay --> Verdict{integrity verdict}
Verdict -->|clean eof| Floor[set durable floor from WAL tip]
Verdict -->|corruption truncated| Truncated[mark recovery_truncated]
Floor --> Eligible[eligible to lead]
Truncated --> CanCatchUp{healthy peer reachable}
CanCatchUp -->|yes| Fence[fence from promotion until caught up]
CanCatchUp -->|no and needed for quorum| Fatal[FATAL fail loud]
Fence --> Caught{caught up past last committed boundary}
Caught -->|yes| Eligible
Key decisions: corruption is never silently accepted (Req 2.1). A truncated member is fenced from promotion and catches up from a healthy primary (Req 2.2, 2.6); if it cannot reach a consistent committed state and the cluster needs it, the controller fails loudly (Req 2.4), honoring the project's fail-loud preference. A candidate never advertises or leads beyond its durable WAL tip (Req 2.3), so a hollow node cannot win even under degraded quorum (Req 2.5).
Requirements Traceability
| Requirement | Summary | Components | Interfaces | Flows |
|---|---|---|---|---|
| 1.1 | reply ok ⇒ durable on majority | DurableCommit | commit barrier, durable_lsn |
Durable commit |
| 1.2 | self counts only when durable | DurableCommit | majority_candidate_locked |
Durable commit |
| 1.3 | standby persists before ACK | DurableCommit (pin) | persist_wal_write_replicated |
Durable commit |
| 1.4 | no premature ok | DurableCommit | persist_commit_barrier |
Durable commit |
| 1.5 | survives minority crash+restart | DurableCommit | durable-majority property | Durable commit / soak |
| 1.6 | durability work preserves liveness | DurableCommit | off-iomgr placement | Durable commit |
| 2.1 | no silent truncate-accept | RecoveryIntegrity | replay_wal verdict |
Recovery fence |
| 2.2 | fence until caught up | PromotionFence | promotion gate, in_catch_up |
Recovery fence |
| 2.3 | never lead below high-water | PromotionFence + FreshnessFloor | raft_cb_become_leader, ha_vote_self_freshness |
Recovery fence |
| 2.4 | fail-loud when unrecoverable | RecoveryIntegrity | FATAL policy | Recovery fence |
| 2.5 | degraded quorum preserves invariant | DurableCommit + PromotionFence | durable-majority + fence | Both |
| 2.6 | re-eligibility on catch-up | PromotionFence | fence-release hook | Recovery fence |
| 3.1 | configurable durability cadence | DurabilityConfig | --wal-sync-ms |
— |
| 3.2 | recoverable after ungraceful kill in verify mode | DurabilityTestSeam | XCE_TESTING hook |
soak deterministic mode |
| 3.3 | identical external semantics in/out of mode | DurabilityTestSeam | seam affects timing only | — |
Components and Interfaces
| Component | Layer | Intent | Req Coverage | Key Dependencies (P0/P1) | Contracts |
|---|---|---|---|---|---|
| DurableCommit | Persistence + Commit | Synchronous persist + self-counts-durable | 1.1–1.6, 2.5 | persist_t (P0), commit_engine (P0), repl ACK (P1) | State |
| RecoveryIntegrity | Persistence | Verdict on recovery; durable floor; FATAL policy | 2.1, 2.4 | replay_wal (P0) | State |
| PromotionFence | Election | Eligibility gate + fence-release | 2.2, 2.3, 2.6, 2.5 | RecoveryIntegrity (P0), in_catch_up (P1) | State |
| FreshnessFloor | Election | Durable-backed vote floor & advertisement | 2.3 | durable_lsn (P0), ha_state (P1) | State |
| DurabilityConfig + TestSeam | Runtime / Test | Override cadence; verification hook | 3.1–3.3 | persist_config (P0), XCE_TESTING (P1) | State |
Persistence + Commit
DurableCommit
| Field | Detail |
|---|---|
| Intent | Make the primary persist before it contributes to the commit majority |
| Requirements | 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 2.5 |
persist_wal_asyncappends and signals the flusher; it does not fsync inline per entry.- The group-commit flusher (repurposed
fsync_worker) wakes on the append-signal, performs onefdatasync, advancesdurable_lsnto the highest synced LSN, and broadcasts the commit-eval condvar — amortizing one sync across all entries pending at wake time (the high-volume RETAIN stream benefits most). majority_candidate_lockedcaps the self contribution atdurable_lsn, nothighest_appended_lsn.- Standby persist-before-ACK is preserved and regression-pinned (
persist_wal_write_replicated:515); the standby path may later adopt the same flusher (out of scope here). - Checkpoint markers retain their existing inline
fdatasync(persist.c:573). durable_lsnis monotonic, never exceeds the live segment tip; onpersist_truncate_wal_above, clampdurable_lsnto the new tip.
Dependencies
- Inbound: AFTER_EVENT hooks (
job.c,queue.c,worker.c,join.c),ha.cWAL_NOOP — purpose: append path (P0). - Outbound:
commit_engine_t— purpose: self contribution input (P0).
Contracts: State [x]
State Management
- State model:
_Atomic uint64_t durable_lsnonpersist_t; invariantdurable_lsn <= current_lsn - 1, monotonic non-decreasing except clamp-on-truncate. - Persistence & consistency:
durable_lsnadvances only after a successfulfdatasyncin the flusher; the commit engine reads it for self-coverage socommitted_lsn⇒ durable-majority. - Concurrency strategy: the flusher takes
p->lockto read the appended tip andfdatasync, advancesdurable_lsn, releases, then signals commit-eval;majority_candidate_lockedreadsdurable_lsnvia atomic load. Define the append-signal → flush → commit-wake ordering precisely so a WAIT caller cannot miss a wake (lost-wakeup guard).
Implementation Notes
- Integration: the WAIT-mode caller blocks on the commit barrier off the iomgr thread; only the flusher thread blocks on
fdatasync. Heartbeats unaffected. - Validation: assert
committed_lsn <= durable_lsnon the local member after every commit advance (debug build); verify the flusher batches (one fsync advancesdurable_lsnpast multiple LSNs under load). - Risks: lost-wakeup / batching-window correctness between
persist_wal_asyncsignal and the flusher (an entry must never be considered durable before itsfdatasync); design the signal as level-triggered on the appended-vs-synced gap, not edge-triggered. Bounded flush latency vs throughput is a tunable (--wal-sync-msbecomes the max batching delay, not a durability gap).
Persistence — RecoveryIntegrity
| Field | Detail |
|---|---|
| Intent | Distinguish clean log end from corruption; re-derive durable floor; fail loud when unrecoverable |
| Requirements | 2.1, 2.4 |
Responsibilities & Constraints
replay_wal/persist_recoverreturn a verdict:RECOVER_CLEAN(reached a clean entry boundary) vsRECOVER_TRUNCATED(stopped at CRC break / mismatch / short read).- On
RECOVER_TRUNCATED, the member is marked recovery-truncated and is NOT treated as a clean recovery; it must not advertise or lead beyond the last intact LSN. - The recovered durable floor (highest intact replayed LSN) is published so the freshness advertisement is durable-backed.
- FATAL policy: if a recovery-truncated member cannot reach a healthy peer to catch up and the cluster requires it, the controller terminates loudly rather than serve divergent state.
Contracts: State [x]
State Management
- State model: verdict enum +
recovery_truncatedflag (onha_state_torpersist_t); recovered durable floor = highest intact LSN. - Persistence & consistency: does not persist
committed_lsn; only re-derives the durable log tip from on-disk segments. - Concurrency strategy: runs single-threaded during
cluster_init_phase2before the node participates.
Implementation Notes
- Integration: composes with
ha-convergence-recovery— that spec truncates an intact log on demotion/rejoin; this spec refuses to accept a corrupt log silently. - Validation: unit test injects a CRC break at LSN 1 and asserts verdict
RECOVER_TRUNCATED+ non-promotability. - Risks: must not FATAL on a legitimately empty first-boot WAL (no entries ≠ corruption).
Boot Policy Decision Table (task 3.2)
Status: DECISION recorded (task 3.2). No production behavior change is made by 3.2 — this table is the contract that task 3.3 (the FATAL branch) and task 4.3 (the promotion fence + release) implement. The skeleton tests in
test_commit_durability.c(one per row,TEST_IGNORE-skipped pending 3.3/4.3) encode each row's expected outcome.
Decision (Req 2.2, 2.4): A member that boots with a structurally clean log but below the cluster's committed high-water mark, or with a recovery-truncated log, is fenced from leadership at boot (never promoted while behind/truncated). It then attempts catch-up from a healthy peer. It becomes eligible only once catch-up brings its durable position to a committed-consistent state at/above the cluster boundary. It terminates loudly (FATAL) only on a bounded failure to reach that state — i.e. it cannot recover locally and cannot catch up within the bounded window (no healthy peer reachable / cannot reach a committed-consistent state).
Invariant (authoritative): the fence is never a silent indefinite wait. A truncated/behind member always resolves to exactly one of two terminal outcomes: it catches up → ELIGIBLE, or it FATALs (fail-loud) on a bounded failure. There is no third "fenced forever, still running, silently divergent" state. Snapshot-sentinel recovery is treated as CLEAN-but-behind (catch-up), never FATAL on a legitimate sentinel.
Inputs (evaluated at boot / promotion-eligibility):
- verdict — recovery integrity verdict from task 3.1:
CLEANvsTRUNCATED. - durable-vs-HWM — local durable position relative to the cluster committed high-water mark:
at/abovevsbelow. - peer — a healthy peer reachable for catch-up:
yesvsno. - catch-up — catch-up reaches a committed-consistent state within the bounded window:
succeedsvsfails(n/a when no catch-up is attempted).
Outcomes:
- ELIGIBLE — may promote (no fence).
- FENCE+CATCH-UP → ELIGIBLE — ineligible to promote; catch up from a healthy peer; regain eligibility on success (Req 2.6).
- FATAL — fail loud; terminate with the truncation/boundary logged rather than serve divergent state (Req 2.4).
| # | verdict | durable-vs-HWM | healthy peer | catch-up result | Outcome | Rationale / Req |
|---|---|---|---|---|---|---|
| 1 | CLEAN | at/above | n/a | n/a | ELIGIBLE | Log intact and not behind — normal promotable member. (Req 2.3) |
| 2 | CLEAN | below | yes | succeeds | FENCE+CATCH-UP → ELIGIBLE | Intact but behind the HWM; must not lead below the boundary; catch up then promote. (Req 2.2, 2.3, 2.6) |
| 3 | TRUNCATED | (below — a truncated log is by definition not provably at/above) | yes | succeeds | FENCE+CATCH-UP → ELIGIBLE | Structurally truncated but a healthy peer exists; fence, catch up, regain eligibility. Never FATAL when a peer is reachable. (Req 2.2, 2.6) |
| 4 | TRUNCATED | below | no | fails (bounded window elapses / no committed-consistent state reachable) | FATAL | Cannot recover locally AND cannot catch up — bounded failure ⇒ fail loud, never silent indefinite fence. (Req 2.4) |
| 4b | CLEAN | below | no | fails (bounded window elapses, no peer to advance from) | FATAL | Behind the HWM and no healthy peer to catch up from within the bounded window — same bounded-failure terminal as row 4; never a silent indefinite fence. (Req 2.4, 2.2) |
| 5 | CLEAN (snapshot-sentinel: committed at sentinel LSN, no sub-sentinel entries) | below (no entries below the sentinel) | yes | succeeds | FENCE+CATCH-UP → ELIGIBLE | A legitimate sentinel snapshot is CLEAN-but-behind, NOT corruption; treat as must-catch-up, never FATAL on a legitimate sentinel. (Req 2.1, 2.2) |
| 6 | CLEAN | at/above (empty first-boot WAL, fresh cold start: durable=0, HWM=0) | n/a | n/a | ELIGIBLE | An empty first-boot WAL is not behind anything — cold-start members are promotable. (Req 2.1, 2.3) |
Notes on the dimensions:
- A
TRUNCATEDverdict is never simultaneously "provably at/above the HWM": truncation means the log stops below its last intact boundary, so a truncated member is treated as behind for fence purposes regardless of the volatile in-memory position (this is why 4.2/3.4 derive the freshness floor from the durable WAL tip, not a volatile value). - "bounded window" is the catch-up deadline pinned by task 3.3; the policy requires it to be finite so the fence cannot become a silent indefinite wait.
- The
healthy peer = no+at/abovecombination does not need catch-up and is covered by row 1 (ELIGIBLE) — a member already at/above the HWM is promotable without a peer.
Election — PromotionFence + FreshnessFloor
| Field | Detail |
|---|---|
| Intent | Bar a truncated/behind member from leadership until caught up; advertise/vote on durable WAL state |
| Requirements | 2.2, 2.3, 2.5, 2.6 |
Responsibilities & Constraints
- Pre-promotion gate in
raft_cb_become_leader: if recovery-truncated, or local durable position < cluster committed high-water mark, refuse promotion (set/extendin_catch_up-style fence) and instead catch up from a healthy primary. ha_vote_self_freshness: floor derives from the durable WAL tip (re-derived on recovery), so a truncated member cannot over-advertise a volatilelast_known_committed_lsn. HonorsCLAUDE.md: the post-election safe-commit point still comes from voter replies, never from a stale ctrlhello.- Fence-release: when catch-up brings the member past its last-known committed boundary, restore eligibility (hook the existing catch-up completion / re-inclusion path).
- Degraded quorum: because a candidate cannot lead beyond its durable tip, a hollow node cannot win even at minimal quorum — preserving Req 1's invariant across leadership changes.
Contracts: State [x]
Implementation Notes
- Integration: reuses
in_catch_up(ha.c:954, cluster.c:529) and the PreVote/vote freshness comparison (cluster.c:380-396, 467-474); extends the fence from "streaming standby" to "recovery-truncated / behind-high-water promoter." - Validation: simulate a node restarting truncated; assert it is denied promotion until catch-up, then re-enabled (Req 2.2/2.6).
- Risks: interaction with
ha-single-leader-roleown-term gate andha-prevote— re-verify the combined eligibility predicate againstraft.tla.
Runtime / Test — DurabilityConfig + TestSeam
| Field | Detail |
|---|---|
| Intent | Deterministic, configurable durability timing for verification |
| Requirements | 3.1, 3.2, 3.3 |
Responsibilities & Constraints
--wal-sync-msplumbedmain.c → persist_config_tbeforepersist_init; default unchanged (DEFAULT_WAL_SYNC_MS). Under group-commit this bounds the maximum batching delay (a flush fires on the append-signal or when this elapses), never a durability gap — an acked op is always durable regardless of the value.- The seam's verification role: an
XCE_TESTINGhook (and/or a large--wal-sync-msto widen the batching window) lets a test assert an acknowledged op is recoverable after an immediate ungraceful termination — proving durability comes from the commit gate, not the flush cadence. - External acknowledgment semantics are identical with or without the mode (timing controllability only).
Contracts: State [x]
Implementation Notes
- Integration: follows
--sync-timeout-ms(OPT_SYNC_TIMEOUT) and theha.c:2270-2335hook convention. - Validation: deterministic E2E mode in
test-commit-durability.shdrives the seam so the soak becomes a deterministic RED on the unfixed build and GREEN after. - Risks: ensure the seam is compiled out of production (
#ifdef XCE_TESTING).
Error Handling
Error Strategy
- Durability failure (
fdatasyncerror in the flusher):durable_lsndoes not advance, so the entry is NOT counted durable, the commit does not advance, and the client does not receive ok (Req 1.4). The flusher surfaces the I/O error loudly (fail-loud); a persistent local sync failure is treated as a member fault, not silently retried into a false-durable state. - Recovery corruption (
RECOVER_TRUNCATED): never silently accepted (Req 2.1). Fence from promotion + catch up; FATAL when catch-up is impossible and the node is required (Req 2.4) — fail-loud. - Promotion while behind: refused at the gate (Req 2.3); logged with the local durable tip vs the cluster high-water mark.
Monitoring
- Log every
durable_lsnregression-clamp on truncate, every recovery verdict (with the truncation boundary), every promotion refusal (durable tip vs high-water), and every fence set/release — operator-visible, once per transition (consistent with the quorum-transition logging already in the HA stack).
Testing Strategy
Unit Tests
durable_lsnadvances only afterfdatasync;majority_candidate_lockedself-cap equalsdurable_lsnnothighest_appended_lsn(1.2).replay_walreturnsRECOVER_TRUNCATEDon an injected CRC break at LSN 1 andRECOVER_CLEANon an intact log (2.1).- Promotion gate denies a recovery-truncated / behind-high-water member and re-enables it after catch-up (2.2, 2.6).
ha_vote_self_freshnessfloor reflects the durable WAL tip, not a stale in-memorylast_known_committed_lsn(2.3).- Standby
persist_wal_write_replicatedfdatasyncs before ACK — regression pin (1.3).
Integration Tests
- Commit advances
committed_lsnonly when self is durable + a standby ACK is durable; reply released only after (1.1, 1.4). - Kill the sole durable contributor mid-commit; acknowledged op survives on the majority (1.5).
- Durability work does not delay heartbeat handling beyond tolerance (1.6).
E2E / Failover
src/tools/test-commit-durability.shsoak: rolling SIGKILL+restart under load asserts every acknowledged job survives (1.1, 1.5, 2.x). With the--wal-sync-*seam, run a deterministic variant: widen/disable background timing, kill a durable contributor in-window, assert survival — RED on the current build, GREEN after the fix (3.1, 3.2).
Performance
- Measure submit latency delta from synchronous
fdatasyncvs the existing replication round-trip; confirm it is within the round-trip already paid before deciding group-commit is needed (research item).
Performance & Scalability
- Group-commit amortizes one
fdatasyncacross all entries pending at flush time, so the per-entry sync cost falls as load rises — critical for the high-volumeWAL_MODE_RETAINfact stream (job start/finish/fail/rusage) which is not client-latency-bound.--wal-sync-msbounds the maximum batching delay (a latency/throughput tunable), never a durability gap. Thejob-array-single-wal-recordroadmap item independently cuts per-array entry count. Target: zero acknowledged-job loss across failover (correctness) takes precedence over per-submit latency; group-commit keeps the throughput cost sub-linear in write rate.
Open Questions / Gating Items (resolve in /kiro-spec-tasks)
- Freshness-floor formula (gating, election-safety): pin the exact reconciliation of the durable-WAL-derived floor vs the existing
max(applied_lsn, last_known_committed_lsn). Too low ⇒ a truncated node still wins (the bug); too high ⇒ election livelock. Make araft/raft.tlaLeader-Completeness and liveness re-check a blocking sub-task before the freshness change merges (Req 2.3). - FATAL-vs-fence decision point (gating): a booting truncated member cannot know it is "required for quorum" at
cluster_init. Define the concrete policy: fence at boot, attempt catch-up from a healthy primary, and FATAL only on a bounded failure to reach a committed-consistent state — never a silent indefinite fence (Req 2.2, 2.4). RESOLVED (task 3.2): see the Boot Policy Decision Table (task 3.2) above for the recorded input-state → outcome table; tasks 3.3 (FATAL branch) and 4.3 (fence + release) implement it. - Flusher lost-wakeup proof: the append-signal → flush → commit-wake path must be shown free of lost wakeups (level-triggered on the appended-vs-synced gap) before relying on it for the durability invariant (Req 1.1, 1.2).
- Snapshot-recovery edge: current snapshots are 16-byte LSN sentinels (no entity data), so a member that recovers "from snapshot" has a committed position at the sentinel LSN but no log entries below it. The recovery verdict (3.1) must treat snapshot-sentinel + clean replay as CLEAN (not TRUNCATED), and the promotion fence (4.3) must treat such a member as behind-and-must-catch-up (never FATAL on a legitimate sentinel snapshot). Interacts with the roadmap
entity-bearing snapshotsitem; pin the classification before implementing 3.1/4.3 (Req 2.1, 2.2). - Dormant synchronous-append path:
persist_wal_sync_appendadvances the commit position directly on majority ACK and assumes inline fsync — a premise group-commit removes (task 2.4). It has no production caller today but must be deleted or re-routed so it cannot reintroduce ACK-without-durability commit (Req 1.1, 1.2).