feat(ha-term-safety): route AppendEntries intake through library + leaderCommit wire (task 3.2)

This commit is contained in:
Rongsong Shen 2026-06-22 08:42:35 +00:00
parent 6a519a928b
commit 754e586f8f
10 changed files with 508 additions and 168 deletions

View file

@ -592,46 +592,44 @@ static void rj_make_standby_with_seams(void)
g_restart_hook_rc = 0; /* model a successful restart */
}
/* 2.1/2.2: local tip ABOVE the primary boundary AND local committed_lsn ABOVE the
* boundary → reconcile truncates at the CLAMP max(boundary, local committed) (the
* higher local value, never the lower reported boundary) and self-restarts once.
* RED: ha_rejoin_reconcile is a no-op stub → no truncation, no restart. */
static void test_rejoin_clamp_truncates_at_local_committed_and_restarts_once(void)
/* ha-term-safety task 3.2: the committed-boundary (watermark) truncate + self-
* restart is RETIRED. Divergent-suffix reconciliation is now owned by the raft
* library's (term,lsn) log-matching (raft_on_append_entries_request →
* on_truncate_from), driven by the leader's AppendEntries — NOT by an LSN
* watermark on a CtrlHelloReply. ha_rejoin_reconcile therefore no longer
* truncates the local WAL or self-restarts; it is a no-op that returns 0 while
* the learned committed boundary is recorded by the caller and the precise
* truncation happens later via the library AE path. This test pins the RETIRED
* contract: even with a local tip ABOVE the reported boundary, NO truncation and
* NO restart occur here. */
static void test_rejoin_no_watermark_truncate_no_restart(void)
{
dc_setup("rjclamp", /*n_peers=*/3, /*n_standbys=*/0,
/*appended_lsn=*/9, /*n_vcpus=*/8);
rj_make_standby_with_seams();
/* Local tip = 9 (entries 1..9 on disk). Local committed_lsn = 6 — ABOVE the
* primary-reported boundary of 4. The clamp must protect [1..6] and truncate
* only above 6 → cutoff = max(4, 6) = 6 → current_lsn collapses to 7. */
/* Local tip = 9 (entries 1..9 on disk), local committed_lsn = 6, primary
* boundary = 4. Pre-3.2 this would have truncated above 6 + restarted; post-
* 3.2 it must leave the WAL and process untouched (the library reconciles by
* term when the leader's AE arrives). */
rj_set_local_state(/*tip_lsn=*/9, /*committed=*/6);
const uint64_t primary_boundary = 4;
const uint64_t local_committed = 6;
uint64_t tip_before = g_dc.persist.current_lsn;
/* Act. */
int rc = ha_rejoin_reconcile(&g_dc, primary_boundary);
/* Drive any deferred convergence task (the intake may defer to delaymgr,
* same rule as demotion); harmless if 3.2 truncates inline. */
int rc = ha_rejoin_reconcile(&g_dc, /*primary_committed_lsn=*/4);
(void)dc_run_pending_task(&g_dc.delaymgr);
/* Assert (Req 2.2 clamp): truncation cutoff is the LOCAL committed (6), not the
* lower reported boundary (4) — current_lsn collapses to local_committed + 1. */
TEST_ASSERT_EQUAL_UINT64_MESSAGE(local_committed + 1, g_dc.persist.current_lsn,
"rejoin reconcile must clamp the cutoff to max(boundary, local committed) "
"(Req 2.2): with boundary=4 and local committed=6 it must truncate above 6, "
"NOT above the lower reported boundary 4");
/* Assert (Req 2.1): self-restart fired exactly once, fatal seam never. */
TEST_ASSERT_EQUAL_INT_MESSAGE(1, atomic_load(&g_restart_calls),
"rejoin reconcile with tip > boundary must self-restart exactly once "
"(Req 2.1)");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc,
"ha_rejoin_reconcile is retired to a no-op (task 3.2) and returns 0");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(tip_before, g_dc.persist.current_lsn,
"the retired watermark truncate must NOT shrink the local WAL — the "
"library's (term,lsn) AE reconcile owns truncation now (task 3.2)");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, atomic_load(&g_restart_calls),
"the retired rejoin reconcile must NOT self-restart (task 3.2)");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, atomic_load(&g_fatal_calls),
"a successful rejoin restart must NOT invoke the fatal seam");
(void)rc;
"the retired rejoin reconcile must NOT invoke the fatal seam");
dc_teardown();
printf("test_rejoin_clamp_truncates_at_local_committed_and_restarts_once: checked\n");
printf("test_rejoin_no_watermark_truncate_no_restart: checked\n");
}
/* 2.3: local tip AT/BELOW the boundary → no truncation, no restart, returns 0.
@ -664,38 +662,32 @@ static void test_rejoin_tip_at_or_below_boundary_no_action(void)
printf("test_rejoin_tip_at_or_below_boundary_no_action: checked\n");
}
/* 2.1 once-guard: a SECOND reconcile call in the same process lifetime must NOT
* self-restart again, even if the tip still exceeds the boundary (defensive guard;
* "once" is also structural post-restart).
* RED: the no-op stub never restarts even once, so the "exactly one restart total"
* assertion fails. */
static void test_rejoin_second_call_no_second_restart(void)
/* ha-term-safety task 3.2: with the watermark truncate retired, REPEATED rejoin
* reconcile calls remain side-effect-free — never truncating and never
* restarting, regardless of how far the local tip runs ahead. (Pre-3.2 this
* pinned a defensive once-guard around a single restart; post-3.2 there is no
* restart at all, so any number of calls is a no-op.) */
static void test_rejoin_repeated_calls_remain_no_op(void)
{
dc_setup("rjonce", /*n_peers=*/3, /*n_standbys=*/0,
/*appended_lsn=*/9, /*n_vcpus=*/8);
rj_make_standby_with_seams();
rj_set_local_state(/*tip_lsn=*/9, /*committed=*/4);
uint64_t tip_before = g_dc.persist.current_lsn;
/* Act: first reconcile (tip 9 > boundary 4 → truncate + restart once). */
(void)ha_rejoin_reconcile(&g_dc, /*primary_committed_lsn=*/4);
(void)dc_run_pending_task(&g_dc.delaymgr);
int restarts_after_first = atomic_load(&g_restart_calls);
/* Act: second reconcile in the SAME lifetime. The once-guard must suppress a
* second restart regardless of the reported boundary. */
(void)ha_rejoin_reconcile(&g_dc, /*primary_committed_lsn=*/4);
(void)dc_run_pending_task(&g_dc.delaymgr);
/* Assert (Req 2.1): exactly ONE restart across both calls. */
TEST_ASSERT_EQUAL_INT_MESSAGE(1, restarts_after_first,
"the FIRST rejoin reconcile with tip > boundary must self-restart once "
"(Req 2.1)");
TEST_ASSERT_EQUAL_INT_MESSAGE(1, atomic_load(&g_restart_calls),
"a SECOND rejoin reconcile in the same process lifetime must NOT restart "
"again — defensive once-guard (Req 2.1)");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, atomic_load(&g_restart_calls),
"the retired rejoin reconcile must NEVER self-restart (task 3.2)");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(tip_before, g_dc.persist.current_lsn,
"repeated retired rejoin reconcile must leave the local WAL untouched "
"(task 3.2)");
dc_teardown();
printf("test_rejoin_second_call_no_second_restart: checked\n");
printf("test_rejoin_repeated_calls_remain_no_op: checked\n");
}
/* Intake integration: delivering a CtrlHelloReply through the REAL cluster.c
@ -727,12 +719,17 @@ static void test_ctrlhello_reply_intake_stores_and_reconciles(void)
atomic_load(&g_dc.ha.last_known_committed_config_tag),
"CtrlHelloReply intake must store the config tag (existing)");
/* Assert (intake integration): the intake also TRIGGERED reconciliation —
* tip 9 > boundary 4 → exactly one self-restart. */
TEST_ASSERT_EQUAL_INT_MESSAGE(1, atomic_load(&g_restart_calls),
"CtrlHelloReply intake must trigger rejoin reconciliation when the local "
"tip exceeds the reported boundary — exactly one self-restart (task 3.2 "
"wires ha_rejoin_reconcile into the intake)");
/* Assert (task 3.2): the intake STILL stores the boundary (above) but the
* watermark truncate/restart is RETIRED — the intake must NOT self-restart
* even though the local tip (9) exceeds the reported boundary (4). The
* library's (term,lsn) AE reconcile owns the divergent-suffix truncation now;
* the member stays alive and converges when the leader's AppendEntries arrive. */
TEST_ASSERT_EQUAL_INT_MESSAGE(0, atomic_load(&g_restart_calls),
"CtrlHelloReply intake must NOT self-restart on a tip-ahead boundary — the "
"retired watermark reconcile no longer truncates/restarts (task 3.2)");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(10, g_dc.persist.current_lsn,
"the intake must leave the local WAL untouched (tip 9 → current_lsn 10); "
"reconciliation is deferred to the library AE path (task 3.2)");
dc_teardown();
printf("test_ctrlhello_reply_intake_stores_and_reconciles: checked\n");
@ -757,9 +754,9 @@ void test_demotion_convergence(void)
printf("\n>> rejoin reconciliation tests (RED, task 3.1)\n\n");
RUN_TEST(test_rejoin_clamp_truncates_at_local_committed_and_restarts_once);
RUN_TEST(test_rejoin_no_watermark_truncate_no_restart);
RUN_TEST(test_rejoin_tip_at_or_below_boundary_no_action);
RUN_TEST(test_rejoin_second_call_no_second_restart);
RUN_TEST(test_rejoin_repeated_calls_remain_no_op);
RUN_TEST(test_ctrlhello_reply_intake_stores_and_reconciles);
#else
printf("test_demotion_convergence: SKIP (non-test build)\n");