feat(ha-commit-durability): durable-backed vote/PreVote freshness floor + bare-durable candidate advertise (task 4.2)

This commit is contained in:
Rongsong Shen 2026-06-18 09:02:11 +00:00
parent 4b7457144c
commit 3049b620f3
7 changed files with 282 additions and 26 deletions

View file

@ -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");

View file

@ -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);