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) <noreply@anthropic.com>
This commit is contained in:
parent
d534f83e46
commit
3b4656228e
4 changed files with 276 additions and 1 deletions
|
|
@ -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_
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
272
src/controller/testsuite/test_ha_vote_floor_heartbeat.c
Normal file
272
src/controller/testsuite/test_ha_vote_floor_heartbeat.c
Normal file
|
|
@ -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 <stdatomic.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include <unity/unity.h>
|
||||
|
||||
#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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue