diff --git a/.kiro/specs/ha-commit-durability/tasks.md b/.kiro/specs/ha-commit-durability/tasks.md index 541ffe7..d89a45d 100644 --- a/.kiro/specs/ha-commit-durability/tasks.md +++ b/.kiro/specs/ha-commit-durability/tasks.md @@ -85,7 +85,7 @@ - _Requirements: 2.3_ - _Boundary: FreshnessFloor_ -- [ ] 4.2 Derive the vote and PreVote freshness floor from durable WAL state +- [x] 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. @@ -132,3 +132,4 @@ - 3.3: FATAL branch = pure decision `persist_recover_decision(recover_policy_inputs_t) → recover_decision_t {ELIGIBLE, FENCE, FATAL}` (persist.c) + action `persist_recover_fatal_if_unrecoverable()` which logs the truncation boundary (`durable_floor_lsn`/`cluster_hwm_lsn` + window ms) at ERROR then terminates via `persist_fatal_hook` (extern `void(*)(const char*)`, NULL in prod → `tlog_exit(); abort()`; mirrors `ha_fatal_hook`; tests install it to capture instead of abort). FATAL ONLY for bounded-unrecoverable + NO healthy peer (rows 4/4b); a reachable peer NEVER FATALs (gated behind `if (!healthy_peer_reachable)`); eligibility rows 1/2/6 never FATAL. Bound = FINITE `DEFAULT_RECOVERY_CATCHUP_WINDOW_MS=30000` (persist.h) — keys off the `catch_up_succeeded` bounded-window result, never an indefinite wait. Turned 3.2 skeleton rows 4/4b GREEN + added `test_boot_policy_peer_reachable_never_fatal`; rows 1/2/3/5/6 stay IGNORE (FENCE→ELIGIBLE is 4.3). DEFERRED to 4.3/5.x: wiring this decision into the live boot/promotion path (3.3 is the unit-tested decision + terminate only; no ha.c/cluster.c edits). - 3.4: On recovery, `persist_recover` publishes the durable floor = highest intact replayed LSN (recovered WAL tip = `current_lsn-1` after `replay_wal`) into `durable_lsn` via `atomic_store` (ASSIGN, NOT max — so a truncating restart lands at the LOWER intact tip, never over-advertising; this is the crux of Req 2.3). Added `persist_durable_floor()` (NULL-safe thin accessor) as the seam task 4.2 consumes. Floor is the durable LAST-LOG INDEX (WAL tip), NOT the commit position (`committed_lsn`) and NOT a volatile in-memory value. Invariant-safe (recovery/init-time; recovered entries are CRC-validated on-disk = genuinely durable; preserves `durable_lsn <= current_lsn-1`). 4.2 (ha.c/cluster.c) CONSUMES this floor in `ha_vote_self_freshness` — 3.4 only PUBLISHES (persistence-only). Phase 3 (recovery integrity) COMPLETE. - 4.1: ANALYSIS-ONLY (no .c/.h). Pinned freshness-floor formula in design.md §"Freshness-Floor Formula (task 4.1)" — THE CONTRACT 4.2 implements. CANDIDATE advertises BARE `cand_lsn = persist_durable_floor(p)` (durable WAL tip) — sites ha.c:3660-3666 (real-vote) + ha.c:3735-3741 (PreVote) must call `persist_durable_floor` DIRECTLY, BYPASSING `ha_vote_self_freshness` on the LSN axis (that fn unconditionally folds in `last_known_committed_lsn` → `max(durable_floor, learned)` = the truncated-candidate-wins bug). VOTER floor = `max(durable_floor, last_known_committed_lsn)` — sites cluster.c:388-395/471-475 + ha.c:2243 keep `ha_vote_self_freshness` with `local_lsn=durable_floor` (the learned_committed backstop stays on the VOTER axis only). Grant iff `cand_tag > floor_tag OR (==tag AND cand_lsn >= floor_lsn)` (term-then-index, matches raft.tla `LastTerm`/`mlastLogIndex |-> Len(log)`). `cand_tag`/`floor_tag` = `max(local_tag, learned_tag)`. SAFETY (Leader Completeness): quorum intersection Q_L∩Q_v ⇒ truncated candidate (durable tip < committed L) denied. LIVENESS (no bug-339 livelock): rests on D* (max durable tip in reachable majority); `durable_floor(v) ≤ D*` and `learned_committed(v) ≤ L ≤ D*`, so the most-durable candidate is always grantable. `safe_committed_lsn` STAYS voter-reply-sourced (unchanged). CAVEAT: raft.tla validates election/freshness LOGIC only, NOT durability (no TLC run — no toolchain; conceptual alignment) — durability verified by 2.x/3.4 tests. (1 remediation: candidate-vs-voter contradiction + §5b example fixed.) +- 4.2: Implements the 4.1 formula. New `ha_candidate_advertise_lsn(persist*)` = bare `persist_durable_floor`. CANDIDATE sites advertise bare durable_floor for the LSN (tag still via `ha_vote_self_freshness(...,&tag,NULL)`): ha.c:~3690 (real election), ha.c:~3771 (PreVote), AND myapp.c:~234 (boot election — a THIRD candidate site the brief didn't name; fixed too, else a boot-time truncated candidate over-advertises). VOTER floor: cluster.c:~383 (real) + cluster.c:~471 (PreVote) feed `local_lsn = persist_durable_floor(&c->persist)` into `ha_vote_self_freshness` → floor = `max(durable_floor, learned_committed)`. Comparison unchanged (term-then-index). `ha.c:2243` legacy `handle_request_vote` is XCE_TESTING-only/param-driven — left unchanged. `safe_committed_lsn` UNTOUCHED (voter-reply-sourced). 3 tests in test_ha_vote_gate.c prove: candidate advertises bare durable_floor (not stale learned); voter durable-floor denies truncated; longest-durable grantable / no livelock. bug-339 regression test fixture primed `durable_lsn=10` (model-preserving). INFO: truncated-deny proven at the freshness-comparison-expression level shared by both vote+PreVote paths (no dedicated live PreVote-handler truncation driver — adequate; nice-to-have for 5.x). GOTCHA for future election tests: the freshness floor now reads `persist_durable_floor`, so any vote/election fixture that sets `applied_lsn`/`last_known_committed_lsn` but not durable state will see floor/advertise=0 — prime `durable_lsn` to the modeled tip. diff --git a/src/controller/cluster.c b/src/controller/cluster.c index 2ebcd72..3487743 100644 --- a/src/controller/cluster.c +++ b/src/controller/cluster.c @@ -378,13 +378,18 @@ static int myreq_handler(vtask_t* task) uint64_t cand_lsn = vrq ? vrq->last_log_lsn : 0; uint64_t cand_term = vrq ? vrq->term : 0; uint64_t local_tag = c->myconf->config_version_tag; - uint64_t local_lsn = (uint64_t)atomic_load_explicit( - &c->ha.applied_lsn, memory_order_acquire); + /* Task 4.2 (ha-commit-durability): the voter floor's local-state LSN + * input is the DURABLE WAL tip (persist_durable_floor), not the + * volatile applied_lsn. A voter must not demand of a candidate a log + * position it itself only holds volatilely, and a truncating restart + * must lower this voter's floor to its real durable state. */ + uint64_t local_lsn = persist_durable_floor(&c->persist); - /* Election-safety fix #2: the freshness floor is the max of our local - * state and our learned-committed cluster state, so we deny candidates - * that are outdated against what the cluster has already committed - * (not merely against our local config file / applied LSN). */ + /* Election-safety fix #2 (retained): the freshness floor is the max of + * our local DURABLE state and our learned-committed cluster state, so we + * deny candidates that are outdated against what the cluster has already + * committed — the learned_committed backstop stays on the voter axis + * (bug-339 / design §1b). */ uint64_t my_tag = 0, my_lsn = 0; ha_vote_self_freshness(&c->ha, local_tag, local_lsn, &my_tag, &my_lsn); @@ -463,11 +468,14 @@ static int myreq_handler(vtask_t* task) uint64_t cand_lsn = vrq ? vrq->last_log_lsn : 0; uint64_t cand_term = vrq ? vrq->term : 0; uint64_t local_tag = c->myconf->config_version_tag; - uint64_t local_lsn = (uint64_t)atomic_load_explicit( - &c->ha.applied_lsn, memory_order_acquire); + /* Task 4.2 (ha-commit-durability): voter-floor local-state LSN is the + * DURABLE WAL tip (persist_durable_floor), not the volatile applied_lsn + * — same change as the real-vote path above. */ + uint64_t local_lsn = persist_durable_floor(&c->persist); - /* Effective freshness floor: max of local state and learned-committed - * cluster state (election-safety fix #2, reused unchanged). */ + /* Effective freshness floor: max of local DURABLE state and + * learned-committed cluster state (election-safety fix #2 backstop + * retained on the voter axis — design §1b). */ uint64_t my_tag = 0, my_lsn = 0; ha_vote_self_freshness(&c->ha, local_tag, local_lsn, &my_tag, &my_lsn); diff --git a/src/controller/ha.c b/src/controller/ha.c index 30ec540..f3d9c40 100644 --- a/src/controller/ha.c +++ b/src/controller/ha.c @@ -2141,6 +2141,31 @@ void ha_vote_self_freshness(ha_state_t *ha, *out_lsn = (local_applied_lsn > learned_lsn) ? local_applied_lsn : learned_lsn; } +/* ── Candidate advertised LSN (election safety, ha-commit-durability 4.2) ─── + * + * A candidate advertises EXACTLY its durable WAL tip on the LSN axis — the + * bare `persist_durable_floor(p)`, NOT the folded + * max(durable_floor, last_known_committed_lsn) that ha_vote_self_freshness + * computes for the VOTER floor. + * + * Why bare, not folded: ha_vote_self_freshness unconditionally returns + * max(local_lsn, last_known_committed_lsn). Feeding it durable_floor would let + * a node restarted with a TRUNCATED log (durable_floor low) but a stale + * volatile learned_committed (high) advertise the stale-high value and WIN — + * exactly the truncated-candidate-wins hole this task closes (design §1a/§5a). + * A candidate may only claim a log position it can durably back. + * + * The candidate's TAG axis is unchanged: it still uses + * max(local_config_tag, learned_tag) (the config epoch is orthogonal to log + * durability — design §1a). Only the LSN axis bypasses the fold. + * + * NULL-safe (persist_durable_floor guards NULL → 0) for the test/seam paths. + */ +uint64_t ha_candidate_advertise_lsn(persist_t *p) +{ + return persist_durable_floor(p); +} + /* ── Vote Handler (Req 2.2–2.6, 6.1, 6.2, 6.4, 9.1) ──────────────────────── * * handle_request_vote: process an incoming OP_requestVote RPC. @@ -3655,14 +3680,19 @@ static void ha_enter_real_election(ha_state_t *ha, cluster_t *c) if (committed > 0) persist_truncate_wal_above(&c->persist, committed); - /* Candidate freshness for the RequestVote payload — same floor the voter - * side computes (ha_vote_self_freshness), matching the rdhup path. */ + /* Candidate freshness for the RequestVote payload (ha-commit-durability + * task 4.2). The TAG axis uses the same config-epoch fold the voter side + * computes (ha_vote_self_freshness), but the LSN axis advertises the BARE + * durable WAL tip — a candidate may only claim a log position it can + * durably back, so a truncated-restart node cannot over-advertise a stale + * volatile learned-committed value and win (design §1a/§5a). */ uint64_t cand_tag = 0, cand_lsn = 0; if (c->myconf) { uint64_t local_tag = c->myconf->config_version_tag; uint64_t local_lsn = atomic_load_explicit(&ha->applied_lsn, memory_order_acquire); - ha_vote_self_freshness(ha, local_tag, local_lsn, &cand_tag, &cand_lsn); + ha_vote_self_freshness(ha, local_tag, local_lsn, &cand_tag, NULL); + cand_lsn = ha_candidate_advertise_lsn(&c->persist); } int n_peers = (c->myconf) ? c->myconf->peers_cfg.candidates_count : 1; @@ -3738,7 +3768,10 @@ void ha_start_prevote_async(ha_state_t *ha, cluster_t *c) uint64_t local_tag = c->myconf->config_version_tag; uint64_t local_lsn = atomic_load_explicit(&ha->applied_lsn, memory_order_acquire); - ha_vote_self_freshness(ha, local_tag, local_lsn, &cand_tag, &cand_lsn); + /* Task 4.2: TAG via the config-epoch fold, LSN as the bare durable WAL + * tip (PreVote must not over-advertise either — design §1a). */ + ha_vote_self_freshness(ha, local_tag, local_lsn, &cand_tag, NULL); + cand_lsn = ha_candidate_advertise_lsn(&c->persist); n_peers = c->myconf->peers_cfg.candidates_count; int n_other = (n_peers > 1) ? n_peers - 1 : 0; diff --git a/src/controller/ha.h b/src/controller/ha.h index 9356a8d..e2e1e8d 100644 --- a/src/controller/ha.h +++ b/src/controller/ha.h @@ -678,6 +678,20 @@ void ha_vote_self_freshness(ha_state_t *ha, uint64_t *out_tag, uint64_t *out_lsn); +/* + * ha_candidate_advertise_lsn: the LSN a candidate advertises in a RequestVote / + * PreVote payload (ha-commit-durability task 4.2). + * + * Returns the BARE durable WAL tip persist_durable_floor(p) — NOT the folded + * max(durable_floor, last_known_committed_lsn) ha_vote_self_freshness computes + * for the voter floor. A candidate may only claim a log position it can + * durably back, so a node restarted with a truncated log cannot over-advertise + * a stale volatile learned-committed value and win (design §1a/§5a). The + * candidate's TAG axis still uses ha_vote_self_freshness (config epoch is + * orthogonal to log durability). NULL-safe (→ 0). + */ +uint64_t ha_candidate_advertise_lsn(persist_t *p); + /* * ha_elect_evaluate_and_signal: evaluate accumulated ha_elect_ctx_t votes. * Sets ctx->result (1=primary, 0=standby) and posts ctx->done semaphore. diff --git a/src/controller/myapp.c b/src/controller/myapp.c index 4ecfc62..9c8b873 100644 --- a/src/controller/myapp.c +++ b/src/controller/myapp.c @@ -218,17 +218,23 @@ int myapp_run(myconf_t *myconf) } } - /* Advertise the EFFECTIVE freshness floor — identical to the voter-side - * floor (cluster.c OPCode_requestVote) and to the RDHUP failover path - * (ha_on_primary_ctrl_fd_rdhup): max(applied_lsn, last_known_committed_lsn) - * at the effective config tag. Candidate-advertised LSN must equal what a - * peer would compute as its voter floor, otherwise two equally-fresh nodes - * mutually deny each other's votes and no leader is ever elected. */ + /* Advertise the candidate freshness for the boot RequestVote payload + * (ha-commit-durability task 4.2). TAG axis = the config-epoch fold + * ha_vote_self_freshness computes (matches the voter side); LSN axis = the + * BARE durable WAL tip persist_durable_floor (via + * ha_candidate_advertise_lsn). A candidate may only advertise a log + * position it can durably back, so a node booted with a truncated log + * cannot over-advertise a stale volatile learned-committed value and win + * (design §1a/§5a). The bare durable tip is still the genuine voter-floor + * local-state input on the peer side (peers now fold durable_floor, not + * applied_lsn), so the most-durable node remains grantable — no livelock + * (design §4). */ uint64_t local_tag = myconf->config_version_tag; uint64_t local_lsn = (uint64_t)atomic_load_explicit(&c.ha.applied_lsn, memory_order_acquire); uint64_t my_tag = 0, my_lsn = 0; - ha_vote_self_freshness(&c.ha, local_tag, local_lsn, &my_tag, &my_lsn); + ha_vote_self_freshness(&c.ha, local_tag, local_lsn, &my_tag, NULL); + my_lsn = ha_candidate_advertise_lsn(&c.persist); /* Initialize raft state machine once with cluster topology. * Must happen before any election or watchdog path that calls diff --git a/src/controller/testsuite/test_ha_rdhup_integration.c b/src/controller/testsuite/test_ha_rdhup_integration.c index 1d1fb19..f8bd54c 100644 --- a/src/controller/testsuite/test_ha_rdhup_integration.c +++ b/src/controller/testsuite/test_ha_rdhup_integration.c @@ -546,11 +546,19 @@ static void test_rdhup_candidate_advertises_freshness_floor_not_stale_committed( /* Standby state after applying WAL + learning commits from the (now-dead) * primary: applied_lsn and last_known_committed_lsn advanced to 10, but - * persist.committed_lsn is still 0 — a standby never advances it. */ + * persist.committed_lsn is still 0 — a standby never advances it. + * + * ha-commit-durability task 4.2: the candidate now advertises the BARE + * DURABLE WAL tip (persist_durable_floor), not the folded + * max(applied_lsn, learned_committed). Model the standby holding its log + * durably up to LSN 10 by priming the durable watermark — this is the + * durable-backed analog of the original "advertise the freshness floor, not + * the stale committed_lsn" guarantee (still NOT 0). */ atomic_store_explicit(&c.ha.applied_lsn, 10ULL, memory_order_release); atomic_store_explicit(&c.ha.last_known_committed_lsn, 10ULL, memory_order_release); atomic_store_explicit(&c.persist.committed_lsn, 0ULL, memory_order_release); + atomic_store_explicit(&c.persist.durable_lsn, 10ULL, memory_order_release); ha_rdhup_election_hook = capture_lsn_hook; @@ -564,9 +572,10 @@ static void test_rdhup_candidate_advertises_freshness_floor_not_stale_committed( close(sv[1]); TEST_ASSERT_EQUAL_UINT64_MESSAGE(10ULL, g_captured_my_lsn, - "RDHUP candidate must advertise effective freshness floor " - "(applied/learned-committed = 10), not stale persist.committed_lsn (0); " - "otherwise surviving standbys mutually deny votes -> infinite election loop"); + "RDHUP candidate must advertise its DURABLE WAL tip (durable_floor = 10), " + "not the stale persist.committed_lsn (0); otherwise surviving standbys " + "mutually deny votes -> infinite election loop (bug-339; task 4.2 makes " + "this the durable tip, not max(applied,learned))"); TEST_ASSERT_EQUAL_UINT64_MESSAGE((uint64_t)TEST_CONFIG_TAG, g_captured_my_tag, "RDHUP candidate must advertise its config_version_tag"); diff --git a/src/controller/testsuite/test_ha_vote_gate.c b/src/controller/testsuite/test_ha_vote_gate.c index f0065c4..31753d2 100644 --- a/src/controller/testsuite/test_ha_vote_gate.c +++ b/src/controller/testsuite/test_ha_vote_gate.c @@ -25,6 +25,9 @@ #include "controller.capnp.h" #include "ha.h" /* pulls in raft/raft.h: raft_state_t, RAFT_GRANT, * raft_on_pre_vote_request, RAFT_PRE_VOTE_* */ +#include "persist.h" /* persist_t, persist_durable_floor, persist_wal_async, + * persist_flush — task 4.2 durable-backed floor */ +#include "wal_types.h" /* WAL_JOB_UPDATE */ /* Functions under test — declared here to match their signatures in ha.c */ int handle_request_vote(ha_state_t *ha, @@ -781,6 +784,183 @@ static void test_pre_vote_dead_primary_grants_despite_fresh_activity(void) printf("test_pre_vote_dead_primary_grants_despite_fresh_activity: PASS\n"); } +/* ══════════════════════════════════════════════════════════════════════════ + * ha-commit-durability task 4.2 — durable-backed freshness floor (Req 2.3) + * + * Contract (design §1a/§1b/§5): + * - CANDIDATE advertises BARE persist_durable_floor on the LSN axis (NOT + * folded with the volatile last_known_committed_lsn) — a truncated-restart + * node cannot over-advertise a stale-high learned value and win. + * - VOTER floor = max(durable_floor, last_known_committed_lsn): the local + * state input to ha_vote_self_freshness becomes the DURABLE tip, the + * learned-committed backstop is retained on the voter axis (bug-339). + * ════════════════════════════════════════════════════════════════════════ */ + +uint64_t ha_candidate_advertise_lsn(persist_t *p); + +/* Build a real persist whose durable floor == `target` by appending `target` + * entries (LSN 1..target) then explicitly flushing. wal_sync_ms is large so + * the ONLY durability point is the explicit persist_flush — the durable floor + * is deterministic, not racing the background timer. */ +static persist_t *make_durable_persist(const char *tmpdir_template, + uint64_t target) +{ + char tmp[256]; + strncpy(tmp, tmpdir_template, sizeof(tmp) - 1); + tmp[sizeof(tmp) - 1] = '\0'; + if (!mkdtemp(tmp)) + return NULL; + + persist_t *p = calloc(1, sizeof(persist_t)); + if (!p) + return NULL; + + persist_config_t cfg = persist_config_defaults(); + cfg.wal_sync_ms = 5000; + cfg.snap_keep_n = 2; + + persist_set_workdir(p, tmp, "ctrl-test"); + if (persist_init(p, &cfg, NULL) != 0) { + free(p); + return NULL; + } + if (persist_start(p) != 0) { + persist_fini(p); + free(p); + return NULL; + } + for (uint64_t i = 0; i < target; i++) { + if (persist_wal_async(p, WAL_JOB_UPDATE, "x", 1) != 0) { + persist_stop(p); + persist_fini(p); + free(p); + return NULL; + } + } + if (target > 0) + persist_flush(p); + return p; +} + +static void free_durable_persist(persist_t *p) +{ + if (!p) + return; + persist_stop(p); + persist_fini(p); + free(p); +} + +/* ── RED: candidate advertises BARE durable_floor, never the stale learned ── + * + * A node restarted with a TRUNCATED log holds durable_floor=4 but still has a + * stale volatile last_known_committed_lsn=8 in memory. The candidate MUST + * advertise 4 (its durable tip), NOT 8. If ha_candidate_advertise_lsn folded + * in learned_committed (the bug), it would return 8 and the truncated node + * could win — exactly the hole this task closes (design §5a). */ +static void test_candidate_advertises_bare_durable_floor_not_learned(void) +{ + persist_t *p = make_durable_persist("/tmp/xce_42cand_XXXXXX", 4); + TEST_ASSERT_NOT_NULL(p); + TEST_ASSERT_EQUAL_UINT64(4ULL, persist_durable_floor(p)); + + /* The advertised LSN must be the BARE durable tip — independent of any + * volatile learned-committed state a real ha_state_t might carry. */ + uint64_t advertised = ha_candidate_advertise_lsn(p); + TEST_ASSERT_EQUAL_UINT64_MESSAGE(4ULL, advertised, + "candidate must advertise the BARE durable WAL tip (4), never a stale " + "volatile learned_committed value — over-advertising lets a truncated " + "node win (design §1a/§5a)"); + + free_durable_persist(p); + printf("test_candidate_advertises_bare_durable_floor_not_learned: PASS\n"); +} + +/* ── RED: voter denies a candidate advertising below the durable voter floor ─ + * + * Voter holds durable_floor=8 (its real durable tip) at config_tag=10. A + * truncated candidate advertises its bare durable tip cand_lsn=4 at the same + * tag. The voter floor's local-state LSN is now the DURABLE tip (8), so + * cand_lsn(4) < floor(8) ⇒ DENY. Mirrors the production cluster.c gate using + * persist_durable_floor as the local input to ha_vote_self_freshness. */ +static void test_voter_floor_uses_durable_tip_denies_truncated(void) +{ + persist_t *p = make_durable_persist("/tmp/xce_42vdeny_XXXXXX", 8); + TEST_ASSERT_NOT_NULL(p); + TEST_ASSERT_EQUAL_UINT64(8ULL, persist_durable_floor(p)); + + ha_state_t ha; + ha_init(&ha, &g_gate_cfg, 0); + /* No learned-committed state primed: the floor must come from the durable + * tip alone, proving the local-state input is durable-backed (not the + * volatile applied_lsn, which this fixture never advances). */ + uint64_t my_tag = 0, my_lsn = 0; + ha_vote_self_freshness(&ha, 10ULL /*local tag*/, + persist_durable_floor(p) /*durable local LSN*/, + &my_tag, &my_lsn); + TEST_ASSERT_EQUAL_UINT64_MESSAGE(8ULL, my_lsn, + "voter floor LSN must reflect the DURABLE tip (8) fed as local input"); + + /* Truncated candidate advertises its bare durable tip = 4 at equal tag. */ + uint64_t cand_tag = 10ULL, cand_lsn = 4ULL; + int freshness_ok = !(cand_tag < my_tag || + (cand_tag == my_tag && cand_lsn < my_lsn)); + TEST_ASSERT_FALSE_MESSAGE(freshness_ok, + "a truncated candidate (durable=4) must be DENIED against a voter whose " + "durable floor is 8 (design §5a)"); + + ha_fini(&ha); + free_durable_persist(p); + printf("test_voter_floor_uses_durable_tip_denies_truncated: PASS\n"); +} + +/* ── RED: longest-durable candidate still WINS — no livelock (design §5b/§4) ─ + * + * Two survivors: A durable=8, B durable=7, equal config_tag. A advertises its + * bare durable tip 8; B's voter floor (durable 7, no higher learned) is 7, so + * A(8) >= B(7) ⇒ B GRANTS A. Symmetrically A denies B (B's 7 < A's floor 8), + * steering the election to the most-durable node — no mutual-deny livelock. */ +static void test_longest_durable_candidate_grantable_no_livelock(void) +{ + persist_t *a = make_durable_persist("/tmp/xce_42winA_XXXXXX", 8); + persist_t *b = make_durable_persist("/tmp/xce_42winB_XXXXXX", 7); + TEST_ASSERT_NOT_NULL(a); + TEST_ASSERT_NOT_NULL(b); + TEST_ASSERT_EQUAL_UINT64(8ULL, persist_durable_floor(a)); + TEST_ASSERT_EQUAL_UINT64(7ULL, persist_durable_floor(b)); + + uint64_t cand_lsn_A = ha_candidate_advertise_lsn(a); /* 8 */ + + /* Voter B's floor from its own durable tip (no learned-committed primed). */ + ha_state_t hb; + ha_init(&hb, &g_gate_cfg, 0); + uint64_t b_tag = 0, b_floor = 0; + ha_vote_self_freshness(&hb, 10ULL, persist_durable_floor(b), &b_tag, &b_floor); + + int b_grants_A = !(10ULL < b_tag || (10ULL == b_tag && cand_lsn_A < b_floor)); + TEST_ASSERT_TRUE_MESSAGE(b_grants_A, + "the longest-durable candidate A(8) must be grantable by B(floor 7) — " + "the floor is satisfiable by the most-durable reachable node, so no " + "livelock (design §4/§5b)"); + + /* Symmetric: A denies the less-durable B (steers election to A). */ + uint64_t cand_lsn_B = ha_candidate_advertise_lsn(b); /* 7 */ + ha_state_t haa; + ha_init(&haa, &g_gate_cfg, 0); + uint64_t a_tag = 0, a_floor = 0; + ha_vote_self_freshness(&haa, 10ULL, persist_durable_floor(a), &a_tag, &a_floor); + int a_grants_B = !(10ULL < a_tag || (10ULL == a_tag && cand_lsn_B < a_floor)); + TEST_ASSERT_FALSE_MESSAGE(a_grants_B, + "the less-durable candidate B(7) must be DENIED by A(floor 8) — election " + "steers to the most-durable node"); + + ha_fini(&hb); + ha_fini(&haa); + free_durable_persist(a); + free_durable_persist(b); + printf("test_longest_durable_candidate_grantable_no_livelock: PASS\n"); +} + /* ── Test suite entry point ───────────────────────────────────────────────── */ void test_ha_vote_gate(void) @@ -807,6 +987,11 @@ void test_ha_vote_gate(void) RUN_TEST(test_uptodate_candidate_still_granted_with_learned_floor); RUN_TEST(test_no_learned_state_falls_back_to_local_gate); + /* ha-commit-durability task 4.2 — durable-backed freshness floor (Req 2.3) */ + RUN_TEST(test_candidate_advertises_bare_durable_floor_not_learned); + RUN_TEST(test_voter_floor_uses_durable_tip_denies_truncated); + RUN_TEST(test_longest_durable_candidate_grantable_no_livelock); + /* Task 3.1 — voter-side PreVote dispatch gate (stickiness + freshness, * no mutation, reply term unchanged) */ RUN_TEST(test_pre_vote_primary_role_denies);