feat(ha-commit-durability): durable-backed vote/PreVote freshness floor + bare-durable candidate advertise (task 4.2)
This commit is contained in:
parent
4b7457144c
commit
3049b620f3
7 changed files with 282 additions and 26 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue