From 3b4656228eb23983cf6e6b6d1920d49ad532f375 Mon Sep 17 00:00:00 2001 From: Rongsong Shen Date: Sat, 20 Jun 2026 12:31:04 +0000 Subject: [PATCH] test(ha-heartbeat-committed-propagation): verify vote floor consumes heartbeat-raised committed safely (task 3.2) Test-only: exercises the real ha_vote_self_freshness/handle_request_vote gate. A heartbeat raise via ha_observe_heartbeat_committed lifts the floor to max(durable=5, raised=9); candidate at 7 denied, at 9/12 granted; a lower heartbeat never lowers it. Discriminating constants fail under either regression (floor->durable-only or fold->min). No production/vote-path change (Req 3.2). 123/0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tasks.md | 2 +- src/controller/meson.build | 1 + src/controller/testsuite/controller_test.c | 2 + .../testsuite/test_ha_vote_floor_heartbeat.c | 272 ++++++++++++++++++ 4 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 src/controller/testsuite/test_ha_vote_floor_heartbeat.c diff --git a/.kiro/specs/ha-heartbeat-committed-propagation/tasks.md b/.kiro/specs/ha-heartbeat-committed-propagation/tasks.md index af4d279..2cba45e 100644 --- a/.kiro/specs/ha-heartbeat-committed-propagation/tasks.md +++ b/.kiro/specs/ha-heartbeat-committed-propagation/tasks.md @@ -39,7 +39,7 @@ - _Requirements: 1.1, 3.4_ - _Boundary: primary_heartbeat_fn_ - _Depends: 1.2_ -- [ ] 3.2 Verify the vote-freshness floor consumes the fresher value safely +- [x] 3.2 Verify the vote-freshness floor consumes the fresher value safely - Add an integration test proving the existing freshness gate, with `last_known_committed_lsn` raised by a heartbeat (via `ha_observe_heartbeat_committed`), rejects a vote candidate whose last-log LSN is below the raised floor and accepts one at/above it — confirming the floor formula `max(durable_floor, last_known_committed_lsn)` is read unchanged and is never lowered by this feature. No production change to the vote path. - Observable: the test shows the candidate-below-floor rejected and candidate-at/above-floor accepted after a heartbeat raise, and no edit to the floor formula. - _Requirements: 3.2_ diff --git a/src/controller/meson.build b/src/controller/meson.build index edd4e5b..bf65f8b 100644 --- a/src/controller/meson.build +++ b/src/controller/meson.build @@ -135,6 +135,7 @@ controller_unit_test_src = [ 'testsuite/test_commit_durability.c', 'testsuite/test_ctrlstatus.c', 'testsuite/test_ha_heartbeat_committed.c', + 'testsuite/test_ha_vote_floor_heartbeat.c', 'ctrlstatus.c', 'commit.c', 'job.c', diff --git a/src/controller/testsuite/controller_test.c b/src/controller/testsuite/controller_test.c index 0ebfad3..5f6329a 100644 --- a/src/controller/testsuite/controller_test.c +++ b/src/controller/testsuite/controller_test.c @@ -62,6 +62,7 @@ extern void test_coldstart_lsn0(void); extern void test_commit_durability(void); extern void test_ctrlstatus(void); extern void test_ha_heartbeat_committed(void); +extern void test_ha_vote_floor_heartbeat(void); void setUp(void) { return; } @@ -193,6 +194,7 @@ int main(int argc, char* argv[]) TEST(test_commit_durability); TEST(test_ctrlstatus); TEST(test_ha_heartbeat_committed); + TEST(test_ha_vote_floor_heartbeat); return UNITY_END(); } diff --git a/src/controller/testsuite/test_ha_vote_floor_heartbeat.c b/src/controller/testsuite/test_ha_vote_floor_heartbeat.c new file mode 100644 index 0000000..89d9802 --- /dev/null +++ b/src/controller/testsuite/test_ha_vote_floor_heartbeat.c @@ -0,0 +1,272 @@ +/* test_ha_vote_floor_heartbeat.c — characterization test for + * ha-heartbeat-committed-propagation task 3.2 (Req 3.2). + * + * SAFETY PROPERTY UNDER TEST (no production change in this task): + * The EXISTING vote-freshness floor is `max(durable_floor, last_known_committed_lsn)` + * (computed by ha_vote_self_freshness; design "Security Considerations"). This + * feature only RAISES `last_known_committed_lsn` via a heartbeat + * (ha_observe_heartbeat_committed). We prove that raising it through a + * heartbeat: + * 1. flows UNCHANGED into the floor (floor == max(durable_floor, RAISED)), + * 2. REJECTS a vote candidate whose last-log LSN is below the raised floor, + * 3. ACCEPTS a candidate at/above it, + * 4. never LOWERS the floor below the durable floor (monotone strengthening). + * + * This is a VERIFICATION/CHARACTERIZATION test: it must PASS against the + * already-landed code with NO production edit. If it failed, the safety + * property would NOT hold — a spec finding, not a green-on-new-code cycle. + * + * The gate functions exercised are the SAME ones the existing vote tests use + * (handle_request_vote + ha_vote_self_freshness in ha.c) — no new gate invented. + * + * Covers Requirement 3.2 (advisory committed must not LOWER/WEAKEN the + * durability-backed vote floor; raising it only strengthens Leader Completeness). + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include + +#include + +#include "ha.h" +#include "xce/cluster_def.h" /* full cluster_t (embedded ha_state_t) */ + +/* Functions under test — the EXISTING vote gate (declared to match ha.c). */ +int handle_request_vote(ha_state_t *ha, + uint64_t term, + const char *candidate_id, + uint64_t config_tag, + uint64_t last_lsn, + uint64_t my_config_tag, + uint64_t my_last_lsn, + int in_catch_up_flag); + +void ha_vote_self_freshness(ha_state_t *ha, + uint64_t local_config_tag, + uint64_t local_applied_lsn, + uint64_t *out_tag, + uint64_t *out_lsn); + +/* ── shared config ──────────────────────────────────────────────── */ + +static ha_config_t vff_cfg(void) +{ + ha_config_t cfg = { + .heartbeat_interval_ms = 3000, + .heartbeat_timeout_ms = 10000, + .election_timeout_ms = 2000, + .probe_connect_timeout_ms = 500, + .max_promotion_retries = 5, + }; + return cfg; +} + +/* Open the role gate so handle_request_vote falls through to the freshness + * floor: STANDBY, no live primary connection (fd<0), stale heartbeat. */ +static void vff_open_role_gate(ha_state_t *ha) +{ + atomic_store_explicit(&ha->role, (int32_t)CTRL_STANDBY, memory_order_release); + atomic_store_explicit(&ha->term, 1ULL, memory_order_release); + ha->voted_for_term = 0; + ha->primary_ctrl_fd = -1; + + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + ha->last_activity = now.tv_sec - 60; /* 60 s ago → stale */ +} + +/* The durable floor enters the gate as the `local_applied_lsn` / `my_last_lsn` + * argument (production cluster.c feeds persist_durable_floor there — see + * test_ha_vote_gate.c task-4.2 tests). We model that durable floor as a plain + * constant input so this test stays focused on the heartbeat→floor flow without + * a real persist fixture. */ +#define DURABLE_FLOOR 5ULL +#define INITIAL_LEARNED 3ULL /* < DURABLE_FLOOR: floor starts at durable */ +#define RAISED_LSN 9ULL /* heartbeat raises learned ABOVE durable */ + +/* ── (1) the heartbeat value flows into the floor and RAISES it ──────────── */ + +static void test_heartbeat_raises_vote_floor_to_max(void) +{ + cluster_t c; + memset(&c, 0, sizeof(c)); + ha_config_t cfg = vff_cfg(); + TEST_ASSERT_EQUAL_INT(0, ha_init(&c.ha, &cfg, 0)); + + /* Initial learned-committed is BELOW the durable floor → floor==durable. */ + atomic_store_explicit(&c.ha.last_known_committed_lsn, INITIAL_LEARNED, + memory_order_release); + + uint64_t tag0 = 0, lsn0 = 0; + ha_vote_self_freshness(&c.ha, 0ULL /*local tag*/, DURABLE_FLOOR, &tag0, &lsn0); + TEST_ASSERT_EQUAL_UINT64_MESSAGE(DURABLE_FLOOR, lsn0, + "before the heartbeat the floor must be max(durable=5, learned=3) = 5"); + + /* A heartbeat raises the learned committed ABOVE the durable floor. The tag + * argument is the heartbeat's config_tag (advisory, orthogonal here). */ + ha_observe_heartbeat_committed(&c, RAISED_LSN, 0xAB); + TEST_ASSERT_EQUAL_UINT64_MESSAGE(RAISED_LSN, + atomic_load_explicit(&c.ha.last_known_committed_lsn, memory_order_acquire), + "heartbeat must have raised last_known_committed_lsn to 9"); + + /* The EXISTING floor now consumes the raised value: max(5, 9) = 9. */ + uint64_t tag1 = 0, lsn1 = 0; + ha_vote_self_freshness(&c.ha, 0ULL, DURABLE_FLOOR, &tag1, &lsn1); + TEST_ASSERT_EQUAL_UINT64_MESSAGE(RAISED_LSN, lsn1, + "after the heartbeat the floor must be max(durable=5, learned=9) = 9 — " + "the raised last_known_committed_lsn flowed in UNCHANGED (Req 3.2)"); + + /* Monotone strengthening: the floor was RAISED (9 > 5), never lowered. */ + TEST_ASSERT_TRUE_MESSAGE(lsn1 >= lsn0, + "the heartbeat may only RAISE the floor, never lower it (Req 3.2)"); + TEST_ASSERT_TRUE_MESSAGE(lsn1 >= DURABLE_FLOOR, + "the floor must never drop below the durable floor (Req 3.2)"); + + ha_fini(&c.ha); + printf("test_heartbeat_raises_vote_floor_to_max: PASS\n"); +} + +/* ── (2) a candidate BELOW the raised floor is REJECTED ──────────────────── */ + +static void test_candidate_below_raised_floor_rejected(void) +{ + cluster_t c; + memset(&c, 0, sizeof(c)); + ha_config_t cfg = vff_cfg(); + TEST_ASSERT_EQUAL_INT(0, ha_init(&c.ha, &cfg, 0)); + vff_open_role_gate(&c.ha); + + atomic_store_explicit(&c.ha.last_known_committed_lsn, INITIAL_LEARNED, + memory_order_release); + /* Heartbeat raises the learned floor to 9 (> durable 5). */ + ha_observe_heartbeat_committed(&c, RAISED_LSN, 0xAB); + + /* Candidate's last-log LSN (7) is ABOVE the durable floor (5) and ABOVE the + * stale initial learned (3) but BELOW the raised floor (9). WITHOUT the + * heartbeat raising the floor this candidate would have been fresh enough; + * because the floor now consumes the raised learned value, it is DENIED. + * config_tag axis is held equal (0) so only the LSN axis decides. */ + int granted = handle_request_vote(&c.ha, + 1ULL, /* term */ + "ctrl-laggard", + 0ULL, /* cand config_tag == floor tag */ + 7ULL, /* cand last_lsn: 5 < 7 < 9 */ + 0ULL, /* my local config_tag */ + DURABLE_FLOOR, /* my local (durable) lsn = 5 */ + 0); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, granted, + "a candidate (lsn=7) below the heartbeat-raised floor (9) must be DENIED " + "— the raised last_known_committed_lsn strengthened the gate (Req 3.2)"); + + ha_fini(&c.ha); + printf("test_candidate_below_raised_floor_rejected: PASS\n"); +} + +/* ── (3) a candidate AT or ABOVE the raised floor is ACCEPTED ────────────── */ + +static void test_candidate_at_or_above_raised_floor_accepted(void) +{ + /* Candidate AT the raised floor (lsn == 9) → GRANT. */ + cluster_t c; + memset(&c, 0, sizeof(c)); + ha_config_t cfg = vff_cfg(); + TEST_ASSERT_EQUAL_INT(0, ha_init(&c.ha, &cfg, 0)); + vff_open_role_gate(&c.ha); + + atomic_store_explicit(&c.ha.last_known_committed_lsn, INITIAL_LEARNED, + memory_order_release); + ha_observe_heartbeat_committed(&c, RAISED_LSN, 0xAB); + + int granted_at = handle_request_vote(&c.ha, + 1ULL, + "ctrl-fresh", + 0ULL, + RAISED_LSN, /* cand lsn == floor (9) */ + 0ULL, + DURABLE_FLOOR, + 0); + TEST_ASSERT_EQUAL_INT_MESSAGE(1, granted_at, + "a candidate AT the heartbeat-raised floor (lsn=9) must be GRANTED — " + "the floor is satisfiable, no liveness wedge (Req 3.2)"); + ha_fini(&c.ha); + + /* Candidate ABOVE the raised floor (lsn == 12) → GRANT. */ + cluster_t c2; + memset(&c2, 0, sizeof(c2)); + ha_config_t cfg2 = vff_cfg(); + TEST_ASSERT_EQUAL_INT(0, ha_init(&c2.ha, &cfg2, 0)); + vff_open_role_gate(&c2.ha); + + atomic_store_explicit(&c2.ha.last_known_committed_lsn, INITIAL_LEARNED, + memory_order_release); + ha_observe_heartbeat_committed(&c2, RAISED_LSN, 0xAB); + + int granted_above = handle_request_vote(&c2.ha, + 1ULL, + "ctrl-fresher", + 0ULL, + 12ULL, /* cand lsn > floor (9) */ + 0ULL, + DURABLE_FLOOR, + 0); + TEST_ASSERT_EQUAL_INT_MESSAGE(1, granted_above, + "a candidate ABOVE the heartbeat-raised floor (lsn=12) must be GRANTED"); + + ha_fini(&c2.ha); + printf("test_candidate_at_or_above_raised_floor_accepted: PASS\n"); +} + +/* ── (4) a non-advancing heartbeat NEVER lowers the floor ────────────────── */ + +static void test_lower_heartbeat_never_lowers_floor(void) +{ + /* Guards the "never weaken" half of Req 3.2: once the floor is raised to 9, + * a later heartbeat carrying a LOWER committed (4) must NOT pull the floor + * back down — the candidate that was denied (7 < 9) stays denied. */ + cluster_t c; + memset(&c, 0, sizeof(c)); + ha_config_t cfg = vff_cfg(); + TEST_ASSERT_EQUAL_INT(0, ha_init(&c.ha, &cfg, 0)); + vff_open_role_gate(&c.ha); + + atomic_store_explicit(&c.ha.last_known_committed_lsn, INITIAL_LEARNED, + memory_order_release); + ha_observe_heartbeat_committed(&c, RAISED_LSN, 0xAB); /* floor → 9 */ + + /* A lagging/out-of-order heartbeat carrying committed=4 (< current 9). */ + ha_observe_heartbeat_committed(&c, 4ULL, 0xCD); + + uint64_t tag = 0, lsn = 0; + ha_vote_self_freshness(&c.ha, 0ULL, DURABLE_FLOOR, &tag, &lsn); + TEST_ASSERT_EQUAL_UINT64_MESSAGE(RAISED_LSN, lsn, + "a lower/out-of-order heartbeat must NOT lower the floor below 9 (Req 3.2)"); + + /* The below-floor candidate (7) is still DENIED — the floor held. */ + int granted = handle_request_vote(&c.ha, + 1ULL, + "ctrl-laggard", + 0ULL, + 7ULL, + 0ULL, + DURABLE_FLOOR, + 0); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, granted, + "after a lower heartbeat the raised floor must still DENY the lagging " + "candidate (the floor was never weakened) (Req 3.2)"); + + ha_fini(&c.ha); + printf("test_lower_heartbeat_never_lowers_floor: PASS\n"); +} + +/* ── suite entry point ───────────────────────────────────────────── */ + +void test_ha_vote_floor_heartbeat(void) +{ + RUN_TEST(test_heartbeat_raises_vote_floor_to_max); + RUN_TEST(test_candidate_below_raised_floor_rejected); + RUN_TEST(test_candidate_at_or_above_raised_floor_accepted); + RUN_TEST(test_lower_heartbeat_never_lowers_floor); +}