test(ha-single-leader-role): sweep all COMMITTING fixtures to direct-PRIMARY contract (task 5.1)

This commit is contained in:
Rongsong Shen 2026-06-13 13:59:42 +00:00
parent 71ce41f486
commit 7f974e766b
11 changed files with 138 additions and 169 deletions

View file

@ -74,8 +74,9 @@
* green through task 4.2. Fixture copied from test_commit_engine.c
* gate_setup (ha_init + legacy standby_endpoints counting, repl
* back-pointer NULL; quorum-absent = empty endpoint registry).
* CTRL_PRIMARY_COMMITTING is intentionally NOT tested (deleted wholesale
* by ha-single-leader-role).
* No transitional leader sub-state is tested: the single leader role is
* CTRL_PRIMARY (the former transitional state was deleted wholesale by
* ha-single-leader-role).
*
* Task 5.1 adds the config-barrier cases (Req 4.1-4.4) — configAck
* per-push arithmetic, push_config_to_standbys barrier-result contract,

View file

@ -958,7 +958,6 @@ static void test_wal_catchup_entry_advances_applied_lsn(void)
memset(&cfg, 0, sizeof(cfg));
cfg.heartbeat_timeout_ms = 10000;
cfg.election_timeout_ms = 1500;
cfg.committing_timeout_ms = 10000;
ha_init(&ha, &cfg, 0);
/* applied_lsn starts at 0 */
TEST_ASSERT_EQUAL_UINT64(0,
@ -1000,7 +999,6 @@ static void test_wal_catchup_entry_error_skipped_no_crash(void)
memset(&cfg, 0, sizeof(cfg));
cfg.heartbeat_timeout_ms = 10000;
cfg.election_timeout_ms = 1500;
cfg.committing_timeout_ms = 10000;
ha_init(&ha, &cfg, 0);
atomic_store_explicit(&ha.applied_lsn, 10, memory_order_release);
@ -1034,7 +1032,6 @@ static void test_wal_catchup_entry_null_entry_no_crash(void)
memset(&cfg, 0, sizeof(cfg));
cfg.heartbeat_timeout_ms = 10000;
cfg.election_timeout_ms = 1500;
cfg.committing_timeout_ms = 10000;
ha_init(&ha, &cfg, 0);
/* NULL entry — must not crash, must return error */
@ -1060,7 +1057,6 @@ static void test_wal_catchup_batch_advances_applied_lsn(void)
memset(&cfg, 0, sizeof(cfg));
cfg.heartbeat_timeout_ms = 10000;
cfg.election_timeout_ms = 1500;
cfg.committing_timeout_ms = 10000;
ha_init(&ha, &cfg, 0);
/* applied_lsn starts at 0 */
@ -1113,7 +1109,6 @@ static void test_wal_catchup_partial_batch(void)
memset(&cfg, 0, sizeof(cfg));
cfg.heartbeat_timeout_ms = 10000;
cfg.election_timeout_ms = 1500;
cfg.committing_timeout_ms = 10000;
ha_init(&ha, &cfg, 0);
/* Pre-set applied_lsn to 5 to verify monotonic advance */
@ -1162,7 +1157,6 @@ static void test_wal_catchup_batch_error_entry_skipped(void)
memset(&cfg, 0, sizeof(cfg));
cfg.heartbeat_timeout_ms = 10000;
cfg.election_timeout_ms = 1500;
cfg.committing_timeout_ms = 10000;
ha_init(&ha, &cfg, 0);
/* Good entry at LSN 30, then a NULL pointer (simulates corrupt entry),
@ -1212,7 +1206,6 @@ static void test_wal_catchup_batch_zero_entries_fallback(void)
memset(&cfg, 0, sizeof(cfg));
cfg.heartbeat_timeout_ms = 10000;
cfg.election_timeout_ms = 1500;
cfg.committing_timeout_ms = 10000;
ha_init(&ha, &cfg, 0);
/* n_entries == 0 → single-entry path from task 2.1 */
@ -1255,7 +1248,6 @@ static void test_wal_catchup_batch_256_entries(void)
memset(&cfg, 0, sizeof(cfg));
cfg.heartbeat_timeout_ms = 10000;
cfg.election_timeout_ms = 1500;
cfg.committing_timeout_ms = 10000;
ha_init(&ha, &cfg, 0);
/* Allocate 256 sub-entries at consecutive LSNs starting at 1000 */
@ -1318,7 +1310,6 @@ static void test_wal_catchup_batch_applied_lsn_only_after_full_batch(void)
memset(&cfg, 0, sizeof(cfg));
cfg.heartbeat_timeout_ms = 10000;
cfg.election_timeout_ms = 1500;
cfg.committing_timeout_ms = 10000;
ha_init(&ha, &cfg, 0);
/* Pre-set applied_lsn to a known value so we can distinguish partial
@ -1388,7 +1379,6 @@ static void test_nop_reply_carries_state_report(void)
memset(&cfg, 0, sizeof(cfg));
cfg.heartbeat_timeout_ms = 10000;
cfg.election_timeout_ms = 1500;
cfg.committing_timeout_ms = 10000;
ha_init(&ha, &cfg, 0);
/* Set a known applied_lsn */
@ -1432,7 +1422,6 @@ static void test_nop_reply_zero_state_fresh_standby(void)
memset(&cfg, 0, sizeof(cfg));
cfg.heartbeat_timeout_ms = 10000;
cfg.election_timeout_ms = 1500;
cfg.committing_timeout_ms = 10000;
ha_init(&ha, &cfg, 0);
/* applied_lsn stays at 0; config_version_tag stays at 0 → no config yet */
@ -1747,7 +1736,6 @@ static void snap_recv_ha_init(ha_state_t *ha)
memset(&cfg, 0, sizeof(cfg));
cfg.heartbeat_timeout_ms = 10000;
cfg.election_timeout_ms = 1500;
cfg.committing_timeout_ms = 10000;
ha_init(ha, &cfg, 0);
}
@ -2177,7 +2165,6 @@ static void test_ctrlhello_no_repl_messages_enqueued(void)
memset(&hcfg, 0, sizeof(hcfg));
hcfg.heartbeat_timeout_ms = 10000;
hcfg.election_timeout_ms = 1500;
hcfg.committing_timeout_ms = 10000;
ha_init(&c.ha, &hcfg, 0);
c.ha.primary_ctrl_fd = -1;

View file

@ -79,7 +79,6 @@ static ha_config_t g_cfg = {
.max_promotion_retries = 5,
.election_jitter_max_ms = 0,
.election_priority_step_ms = 500,
.committing_timeout_ms = 10000,
};
/* ── Stubs for production hooks ─────────────────────────────────────────── */
@ -176,11 +175,11 @@ static void test_dispatcher_electing_calls_vote_reply_hook(void)
printf("test_dispatcher_electing_calls_vote_reply_hook: PASS\n");
}
/* ── Test 2 (dispatcher CTRL_PRIMARY_COMMITTING → repl_ack count) DELETED ─────
/* ── Test 2 (dispatcher transitional-leader → repl_ack count) DELETED ─────────
* ha-single-leader-role task 4.1. Subject was the deleted ack-counting
* promotion machinery (replCatchUpDone incrementing repl_ack_count to drive
* COMMITTING → PRIMARY). That path is gone — nothing left to verify. No shim
* (design §"Transitional-State Deletions").
* promotion machinery (replCatchUpDone incrementing repl_ack_count to drive a
* transitional leader state to PRIMARY). That path is gone — nothing left to
* verify. No shim (design §"Transitional-State Deletions").
*/
/* ── Test 3: dispatcher drops packet for CTRL_STANDBY ───────────────────── */
@ -346,7 +345,7 @@ static void test_err_handler_removes_promoted_ep_from_standby(void)
ha_state_t ha;
init_ha_with_standby_eps(&ha);
ha.n_peers = 2;
atomic_store_explicit(&ha.role, (int32_t)CTRL_PRIMARY_COMMITTING,
atomic_store_explicit(&ha.role, (int32_t)CTRL_PRIMARY,
memory_order_release);
/* Populate standby_endpoints with a heap-allocated mock ep pointer */
@ -368,7 +367,7 @@ static void test_err_handler_removes_promoted_ep_from_standby(void)
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc, "err_handler must return 0");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, ha.standby_endpoints->count,
"err_handler: ep must be removed from standby_endpoints when role=COMMITTING (Req 4.2)");
"err_handler: ep must be removed from standby_endpoints when role=CTRL_PRIMARY (Req 4.2)");
free(ep->data);
free(ep);
@ -481,7 +480,8 @@ static void test_follower_passive_no_outbound_connect(void)
/* ── Test 10 (Task 7.3: promoted ep repl_ack promotes to PRIMARY) DELETED ─────
* ha-single-leader-role task 4.1. Subject was the deleted ack-counting
* promotion machinery (replCatchUpDone incrementing repl_ack_count until the
* majority threshold drives COMMITTING → CTRL_PRIMARY via ha_promote_to_primary).
* majority threshold promoted a transitional leader state to CTRL_PRIMARY via
* ha_promote_to_primary).
* The mechanism is gone — promotion is now the heartbeat decision loop +
* per-entry majority ack. Nothing left to verify; no shim (design
* §"Transitional-State Deletions").

View file

@ -2,9 +2,10 @@
* test_ha_rdhup_integration.c — Task 6.4 integration test: RDHUP failover
* end-to-end role transition.
*
* Tests verify the full RDHUP → CTRL_ELECTING → CTRL_PRIMARY_COMMITTING →
* CTRL_PRIMARY state-machine chain using XCE_TESTING hook seams. No live
* sockets or real cluster_t required.
* Tests verify the full RDHUP → CTRL_ELECTING → CTRL_PRIMARY state-machine
* chain (the election winner becomes primary directly — single leader role,
* ha-single-leader-role) using XCE_TESTING hook seams. No live sockets or
* real cluster_t required.
*
* Requirements: 6.3, 6.5, 9.2, 9.3 (Req numbers from tasks.md 5.2 / 6.4)
*
@ -68,7 +69,6 @@ static ha_config_t g_cfg = {
.max_promotion_retries = 5,
.election_jitter_max_ms = 0,
.election_priority_step_ms = 500,
.committing_timeout_ms = 10000,
};
/* ── install_stubs / clear_stubs ─────────────────────────────────────────── */
@ -155,13 +155,14 @@ static void make_cluster(cluster_t *c, myconf_t *myconf, const char *my_name)
}
/* ═══════════════════════════════════════════════════════════════════════════
* Test 1: RDHUP → ELECTING → COMMITTING (no standbys present → via hook chain)
* Test 1: RDHUP → ELECTING → PRIMARY (no standbys present → via hook chain)
*
* Req 6.3: RDHUP triggers CTRL_ELECTING + async election
* Req 9.3: RDHUP election uses ha_start_election_async (not am_i_primary_quorum)
* Req 9.2: phase2_done=1 suppresses cluster_init_phase2 on RDHUP re-election
* Req 1.1: the election winner becomes CTRL_PRIMARY directly (single leader role)
* ═════════════════════════════════════════════════════════════════════════ */
static void test_rdhup_full_path_reaches_committing(void)
static void test_rdhup_full_path_reaches_primary(void)
{
install_stubs();
@ -193,13 +194,14 @@ static void test_rdhup_full_path_reaches_committing(void)
TEST_ASSERT_EQUAL_INT_MESSAGE(0, g_phase2_calls,
"RDHUP re-election must not call cluster_init_phase2 when phase2_done=1 (Req 9.2)");
/* Role must be CTRL_PRIMARY_COMMITTING (quorum won → committing gate) */
/* Role must be CTRL_PRIMARY directly — the election winner is the primary
* (single leader role; no transitional sub-state, Req 1.1). */
int role = (int)atomic_load_explicit(&c.ha.role, memory_order_acquire);
TEST_ASSERT_EQUAL_INT_MESSAGE((int)CTRL_PRIMARY_COMMITTING, role,
"RDHUP full path must reach CTRL_PRIMARY_COMMITTING after winning election (Req 6.3)");
TEST_ASSERT_EQUAL_INT_MESSAGE((int)CTRL_PRIMARY, role,
"RDHUP full path must reach CTRL_PRIMARY after winning election (Req 6.3, 1.1)");
ha_fini(&c.ha);
printf("test_rdhup_full_path_reaches_committing: PASS (role=%d==CTRL_PRIMARY_COMMITTING)\n",
printf("test_rdhup_full_path_reaches_primary: PASS (role=%d==CTRL_PRIMARY)\n",
role);
}
@ -211,7 +213,7 @@ static void test_rdhup_full_path_reaches_committing(void)
* ═════════════════════════════════════════════════════════════════════════ */
/* ═══════════════════════════════════════════════════════════════════════════
* Test 3: RDHUP in single-node cluster → immediate PRIMARY (no COMMITTING)
* Test 3: RDHUP in single-node cluster → immediate PRIMARY (single leader role)
*
* Req 9.1: single-node self-elects without network
* n_peers=0 → quorum=1, threshold=0 → acks(0) >= 0 → promotes immediately
@ -243,7 +245,7 @@ static void test_rdhup_single_node_reaches_primary(void)
close(sv[1]);
/* n_peers=0 → ha_elect_evaluate_and_signal single-node shortcircuit lands
* CTRL_PRIMARY directly (no transitional COMMITTING, no ack machinery —
* CTRL_PRIMARY directly (no transitional leader sub-state, no ack machinery —
* ha-single-leader-role). The old replication-ack fallback was excised
* (function deleted task 4.1); the PRIMARY assertion stands on its own. */
int role = (int)atomic_load_explicit(&c.ha.role, memory_order_acquire);
@ -258,18 +260,18 @@ static void test_rdhup_single_node_reaches_primary(void)
/* ═══════════════════════════════════════════════════════════════════════════
* Test 4 (3-node RDHUP — ack-driven promotion) DELETED — ha-single-leader-role
* task 4.1. Subject was the deleted ack-counting promotion machinery
* (the replication-ack handler drives COMMITTING → PRIMARY on threshold acks);
* the mechanism is gone, nothing left to verify. No shim (design §"Transitional-
* State Deletions").
* (the replication-ack handler promoted a transitional leader state to PRIMARY
* on threshold acks); the mechanism is gone, nothing left to verify. No shim
* (design §"Transitional-State Deletions").
* ═════════════════════════════════════════════════════════════════════════ */
/* ═══════════════════════════════════════════════════════════════════════════
* Test 5: config push and ctrlhello hooks fire during COMMITTING entry
* Test 5: config push and ctrlhello hooks fire on promotion to primary
*
* Req 7.1: push_config_to_standbys called after winning election
* Req 7.2: ha_send_ctrlhello_to_all_standbys called after winning election
* ═════════════════════════════════════════════════════════════════════════ */
static void test_rdhup_committing_fires_replication_hooks(void)
static void test_rdhup_promotion_fires_replication_hooks(void)
{
install_stubs();
@ -293,13 +295,13 @@ static void test_rdhup_committing_fires_replication_hooks(void)
close(sv[1]);
TEST_ASSERT_GREATER_THAN_INT_MESSAGE(0, g_push_config_calls,
"push_config_to_standbys hook must fire when entering COMMITTING (Req 7.1)");
"push_config_to_standbys hook must fire on promotion to primary (Req 7.1)");
TEST_ASSERT_GREATER_THAN_INT_MESSAGE(0, g_ctrlhello_calls,
"ha_send_ctrlhello hook must fire when entering COMMITTING (Req 7.2)");
"ha_send_ctrlhello hook must fire on promotion to primary (Req 7.2)");
clear_stubs();
ha_fini(&c.ha);
printf("test_rdhup_committing_fires_replication_hooks: PASS "
printf("test_rdhup_promotion_fires_replication_hooks: PASS "
"(push=%d, ctrlhello=%d)\n",
g_push_config_calls, g_ctrlhello_calls);
}
@ -348,8 +350,8 @@ static void test_rdhup_primary_dispatch_gate_opens(void)
close(sv[1]);
/* ha-single-leader-role: ha_elect_evaluate_and_signal lands the winner in
* CTRL_PRIMARY directly (no transitional COMMITTING, no ack machinery). The
* old replication-ack drive-step was excised (function deleted task 4.1);
* CTRL_PRIMARY directly (no transitional leader sub-state, no ack machinery).
* The old replication-ack drive-step was excised (function deleted task 4.1);
* the dispatch-gate assertion below is the subject of this case. */
int role = (int)atomic_load_explicit(&c.ha.role, memory_order_acquire);
TEST_ASSERT_EQUAL_INT((int)CTRL_PRIMARY, role);
@ -375,12 +377,12 @@ static void test_rdhup_primary_dispatch_gate_opens(void)
void test_ha_rdhup_integration(void)
{
RUN_TEST(test_rdhup_full_path_reaches_committing);
/* test_rdhup_committing_to_primary_on_ack deleted (ha-single-leader-role 4.1:
RUN_TEST(test_rdhup_full_path_reaches_primary);
/* ack-driven promotion test deleted (ha-single-leader-role 4.1:
* ack-driven promotion machinery removed, no shim) */
RUN_TEST(test_rdhup_single_node_reaches_primary);
/* test_rdhup_3node_one_ack_promotes deleted (ha-single-leader-role 4.1:
* ack-counting promotion machinery removed, no shim) */
RUN_TEST(test_rdhup_committing_fires_replication_hooks);
RUN_TEST(test_rdhup_promotion_fires_replication_hooks);
RUN_TEST(test_rdhup_primary_dispatch_gate_opens);
}

View file

@ -1,17 +1,18 @@
/*
* test_ha_repl_push.c — Unity tests for Task 4.2:
* Config and WAL replication push to standbys in the COMMITTING path.
* Config and WAL replication push to standbys on election win (the node
* becomes CTRL_PRIMARY directly — single leader role, ha-single-leader-role).
*
* Tests verify:
* Req 7.1: When entering CTRL_PRIMARY_COMMITTING, push_config_to_standbys
* Req 7.1: When the node becomes CTRL_PRIMARY, push_config_to_standbys
* is called (via ha_push_config_hook seam).
* Req 7.2: When entering CTRL_PRIMARY_COMMITTING, ha_send_ctrlhello_to_all_standbys
* Req 7.2: When the node becomes CTRL_PRIMARY, ha_send_ctrlhello_to_all_standbys
* is called to trigger catch-up replication (via ha_send_ctrlhello_hook seam).
* Req 7.3: The push is triggered at the point of COMMITTING entry, not before.
* Req 7.3: The push is triggered at the point of promotion, not before.
* When election is lost (CTRL_STANDBY), no push occurs.
*
* Design ref: design.md "ha_send_ctrlhello_to_all_standbys" section
* Boundary: ha.c (election evaluation + COMMITTING path)
* Boundary: ha.c (election evaluation + direct-PRIMARY promotion path)
* Requirements: 7.1, 7.2, 7.3
*/
#define _GNU_SOURCE
@ -31,7 +32,7 @@
/* ── Test-only seam: ha_send_ctrlhello_to_all_standbys call hook ─────────────
*
* In production, ha_elect_evaluate_and_signal calls
* ha_send_ctrlhello_to_all_standbys(c) in the COMMITTING path.
* ha_send_ctrlhello_to_all_standbys(c) on the promotion path.
* For unit tests, the real function is not linked (cluster.c not in test binary).
* The XCE_TESTING seam in ha.c checks ha_send_ctrlhello_hook; if non-NULL, it
* calls the hook instead of the real ha_send_ctrlhello_to_all_standbys.
@ -41,7 +42,7 @@ extern int (*ha_send_ctrlhello_hook)(void *c);
/* ── Test-only seam: push_config_to_standbys call hook ──────────────────────
*
* In production, ha_elect_evaluate_and_signal calls push_config_to_standbys(c,...)
* in the COMMITTING path. For unit tests, the seam in ha.c checks
* on the promotion path. For unit tests, the seam in ha.c checks
* ha_push_config_hook; if non-NULL, it calls the hook instead.
*/
extern int (*ha_push_config_hook)(void *c);
@ -83,7 +84,6 @@ static ha_config_t g_cfg = {
.max_promotion_retries = 5,
.election_jitter_max_ms = 0,
.election_priority_step_ms = 500,
.committing_timeout_ms = 10000,
};
/* ── Helper: build a minimal ha_elect_ctx_t ─────────────────────────────── */
@ -136,12 +136,12 @@ static void clear_hooks(void)
ha_phase2_hook = NULL;
}
/* ── Test 1: Req 7.2 — COMMITTING path calls ha_send_ctrlhello_to_all_standbys ── */
/* ── Test 1: Req 7.2 — promotion calls ha_send_ctrlhello_to_all_standbys ───── */
static void test_committing_calls_ctrlhello_push(void)
static void test_promotion_calls_ctrlhello_push(void)
{
/* When election is won (CTRL_PRIMARY → COMMITTING), ha_send_ctrlhello_to_all_standbys
* must be called exactly once.
/* When election is won (node becomes CTRL_PRIMARY directly),
* ha_send_ctrlhello_to_all_standbys must be called exactly once.
* Single-node wins: n_other=0, votes_granted=1. */
reset_counters();
@ -156,18 +156,18 @@ static void test_committing_calls_ctrlhello_push(void)
ha_elect_evaluate_and_signal(&ha, ctx);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_ctrlhello_call_count,
"ha_send_ctrlhello_to_all_standbys must be called once on COMMITTING entry (Req 7.2)");
"ha_send_ctrlhello_to_all_standbys must be called once on promotion (Req 7.2)");
free_ctx(ctx);
ha_fini(&ha);
clear_hooks();
printf("test_committing_calls_ctrlhello_push: PASS (ctrlhello_calls=%d)\n",
printf("test_promotion_calls_ctrlhello_push: PASS (ctrlhello_calls=%d)\n",
g_ctrlhello_call_count);
}
/* ── Test 2: Req 7.1 — COMMITTING path calls push_config_to_standbys ──────── */
/* ── Test 2: Req 7.1 — promotion calls push_config_to_standbys ────────────── */
static void test_committing_calls_push_config(void)
static void test_promotion_calls_push_config(void)
{
/* When election is won, push_config_to_standbys must be called once. */
reset_counters();
@ -183,12 +183,12 @@ static void test_committing_calls_push_config(void)
ha_elect_evaluate_and_signal(&ha, ctx);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_push_config_call_count,
"push_config_to_standbys must be called once on COMMITTING entry (Req 7.1)");
"push_config_to_standbys must be called once on promotion (Req 7.1)");
free_ctx(ctx);
ha_fini(&ha);
clear_hooks();
printf("test_committing_calls_push_config: PASS (push_config_calls=%d)\n",
printf("test_promotion_calls_push_config: PASS (push_config_calls=%d)\n",
g_push_config_call_count);
}
@ -251,8 +251,8 @@ static void test_3node_win_calls_both_pushes(void)
ctrl_role_t actual_role =
(ctrl_role_t)atomic_load_explicit(&ha.role, memory_order_acquire);
TEST_ASSERT_EQUAL_INT_MESSAGE((int)CTRL_PRIMARY_COMMITTING, (int)actual_role,
"ha->role must be CTRL_PRIMARY_COMMITTING after winning");
TEST_ASSERT_EQUAL_INT_MESSAGE((int)CTRL_PRIMARY, (int)actual_role,
"ha->role must be CTRL_PRIMARY directly after winning (single leader role, Req 1.1)");
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_ctrlhello_call_count,
"ha_send_ctrlhello_to_all_standbys must be called once (3-node win)");
@ -303,8 +303,8 @@ static void test_done_posted_prevents_double_push(void)
void test_ha_repl_push(void)
{
RUN_TEST(test_committing_calls_ctrlhello_push);
RUN_TEST(test_committing_calls_push_config);
RUN_TEST(test_promotion_calls_ctrlhello_push);
RUN_TEST(test_promotion_calls_push_config);
RUN_TEST(test_standby_result_does_not_push);
RUN_TEST(test_3node_win_calls_both_pushes);
RUN_TEST(test_done_posted_prevents_double_push);

View file

@ -151,10 +151,10 @@ static void test_demote_sets_standby_role(void)
/*
* Verify that ha_init zeroes the new fields added for election-iomgr-refactor:
* phase2_done, repl_ack_count, n_peers, client_ssl_ctx
* and that CTRL_PRIMARY_COMMITTING is a distinct enum value in ctrl_role_t.
* Also verifies ha_config_t.committing_timeout_ms default macro exists.
* and that the surviving leader role values are distinct in ctrl_role_t.
*
* Requirements: 5.2, 8.1 (election-iomgr-refactor spec)
* Requirements: 5.2, 8.1 (election-iomgr-refactor spec); 1.2 (ha-single-leader-role:
* single leader role — no transitional leader state observable)
*/
static void test_ha_init_zeroes_new_fields(void)
{
@ -165,7 +165,6 @@ static void test_ha_init_zeroes_new_fields(void)
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
.committing_timeout_ms = HA_DEFAULT_COMMITTING_TIMEOUT_MS,
};
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &cfg, 0));
@ -181,14 +180,11 @@ static void test_ha_init_zeroes_new_fields(void)
/* client_ssl_ctx must be NULL after ha_init */
TEST_ASSERT_NULL(ha.client_ssl_ctx);
/* committing_timeout_ms stored in config */
TEST_ASSERT_EQUAL_UINT32(HA_DEFAULT_COMMITTING_TIMEOUT_MS, ha.config.committing_timeout_ms);
/* CTRL_PRIMARY_COMMITTING must be a distinct enum value */
TEST_ASSERT_NOT_EQUAL((int)CTRL_PRIMARY_COMMITTING, (int)CTRL_PRIMARY);
TEST_ASSERT_NOT_EQUAL((int)CTRL_PRIMARY_COMMITTING, (int)CTRL_STANDBY);
TEST_ASSERT_NOT_EQUAL((int)CTRL_PRIMARY_COMMITTING, (int)CTRL_INIT);
TEST_ASSERT_NOT_EQUAL((int)CTRL_PRIMARY_COMMITTING, (int)CTRL_ELECTING);
/* The four surviving roles are distinct: the single leader role is
* CTRL_PRIMARY (no transitional leader sub-state, Req 1.2). */
TEST_ASSERT_NOT_EQUAL((int)CTRL_PRIMARY, (int)CTRL_STANDBY);
TEST_ASSERT_NOT_EQUAL((int)CTRL_PRIMARY, (int)CTRL_INIT);
TEST_ASSERT_NOT_EQUAL((int)CTRL_PRIMARY, (int)CTRL_ELECTING);
ha_fini(&ha);
printf("test_ha_init_zeroes_new_fields: PASS\n");
@ -214,7 +210,6 @@ static void test_ha_init_zeroes_standby_convergence_fields(void)
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
.committing_timeout_ms = HA_DEFAULT_COMMITTING_TIMEOUT_MS,
};
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &cfg, 0));
@ -259,7 +254,6 @@ static ha_state_t *build_ha_for_convergence_test(void)
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
.committing_timeout_ms = HA_DEFAULT_COMMITTING_TIMEOUT_MS,
};
ha_init(&ha, &cfg, 0);
return &ha;
@ -669,28 +663,9 @@ static void test_evaluate_repl_action_stream_wal_fresh_system(void)
printf("test_evaluate_repl_action_stream_wal_fresh_system: PASS\n");
}
/* ── Task 5.3: committing timeout default value ──────────────────────────────
*
* Requirements: 6.4 (ha-repl-hardening)
*
* The committing_timeout_ms CONFIG knob survives until task 5.2 (it is a
* different token from the deleted committing-timeout watchdog callback). The
* degraded-mode force-promotion cases that drove the watchdog test wrapper
* were machinery-subject and were deleted (ha-single-leader-role task 4.1,
* design §"Transitional-State Deletions": no shims). Only the config-default
* assertion remains.
*/
/*
* Test: HA_DEFAULT_COMMITTING_TIMEOUT_MS is 60000 (not 10000).
* Requirements: 6.4 (ha-repl-hardening) — "Change committing_timeout_ms default from 10000 to 60000"
*/
static void test_committing_timeout_default_is_60s(void)
{
TEST_ASSERT_EQUAL_UINT32_MESSAGE(60000, HA_DEFAULT_COMMITTING_TIMEOUT_MS,
"HA_DEFAULT_COMMITTING_TIMEOUT_MS must be 60000 (Req 6.4, Task 5.3)");
printf("test_committing_timeout_default_is_60s: PASS\n");
}
/* The transitional-leader timeout default test was removed: the config knob
* and its default macro are deleted by ha-single-leader-role (the wall-clock
* force-promotion watchdog it configured is gone — Req 1.5). */
/* ── Task 3.3 (ha-wal-replication): dispatch gate allows OPCode_walCatchupEntry
* in CTRL_STANDBY, CTRL_ELECTING, and CTRL_INIT.
@ -698,8 +673,8 @@ static void test_committing_timeout_default_is_60s(void)
* Requirements: ha-wal-replication Req 3.3 (standby role guard).
* Verifies that ha_dispatch_gate(ha, OPCode_walCatchupEntry) returns 0
* (allow) for the three roles where a standby legitimately receives WAL
* catch-up entries from the primary. CTRL_PRIMARY and
* CTRL_PRIMARY_COMMITTING must NOT allow it (they never receive WAL pushes). */
* catch-up entries from the primary. CTRL_PRIMARY must NOT allow it
* (the leader never receives WAL pushes). */
static void test_dispatch_gate_allows_wal_catchup_in_standby_roles(void)
{
ha_state_t ha;
@ -709,7 +684,6 @@ static void test_dispatch_gate_allows_wal_catchup_in_standby_roles(void)
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
.committing_timeout_ms = HA_DEFAULT_COMMITTING_TIMEOUT_MS,
};
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &cfg, 0));
@ -771,10 +745,8 @@ void test_ha_role(void)
RUN_TEST(test_dispatch_repl_action_send_snapshot);
/* Task 5.2 convergence-checker cases deleted (ha-single-leader-role 4.1:
* the majority-convergence checker removed — no shims, machinery-subject) */
/* Task 5.3: committing timeout config-default knob (committing_timeout_ms
* survives until task 5.2; the degraded-mode committing-timeout watchdog
* cases were deleted — ha-single-leader-role 4.1, machinery-subject) */
RUN_TEST(test_committing_timeout_default_is_60s);
/* transitional-leader timeout default test removed (ha-single-leader-role:
* config knob + wall-clock watchdog deleted, Req 1.5) */
/* Task 3.3 (ha-wal-replication): dispatch gate standby-role guard */
RUN_TEST(test_dispatch_gate_allows_wal_catchup_in_standby_roles);
}

View file

@ -467,7 +467,7 @@ static void test_noop_classify_is_retain(void)
*
* Contract (design §Promotion Rewrite, Reqs 1.1, 1.3, 1.4, 1.5, 2.1, 2.2, 2.3):
* every road to PRIMARY lands the node DIRECTLY in CTRL_PRIMARY — no
* CTRL_PRIMARY_COMMITTING ever observable — registers the persist hooks,
* transitional leader sub-state ever observable — registers the persist hooks,
* arms NO wall-clock watchdog, and (production, c != NULL) appends a WAL_NOOP
* of the current term then sets the commit engine's own-term gate
* (commit_set_writable_lsn) to that no-op's LSN so confirmation of any
@ -477,10 +477,10 @@ static void test_noop_classify_is_retain(void)
* - Cases 1-3 drive the raft become-leader callback via its existing
* XCE_TESTING shim `raft_cb_become_leader_for_test(ha, term)` (ha.c:3121),
* which calls `raft_cb_become_leader` with `ha->raft_c = NULL`. This is
* THE seam test_ha_evaluate_committing.c / test_ha_connection_reuse.c use.
* RED today: that path stores CTRL_PRIMARY_COMMITTING (ha.c:514), so the
* direct-PRIMARY / is_primary / no-redirect / hooks-registered assertions
* fail. (The `c == NULL` shim also means the watchdog-arm + noop/gate
* THE seam test_ha_connection_reuse.c uses. The contract: that path
* lands the node directly in CTRL_PRIMARY, so the direct-PRIMARY /
* is_primary / no-redirect / hooks-registered assertions hold. (The
* `c == NULL` shim also means the noop/gate
* blocks — both under `if (c)` — are skipped, so they are NOT observable
* through THIS seam; see cases 4-7 for the real-cluster seam.)
* - Cases 4-7 drive the SHARED promotion sequence `ha_promote_to_primary(c)`
@ -522,7 +522,6 @@ static ha_config_t lifecycle_ha_cfg(void)
.election_timeout_ms = 2000,
.probe_connect_timeout_ms = 500,
.max_promotion_retries = 5,
.committing_timeout_ms = HA_DEFAULT_COMMITTING_TIMEOUT_MS,
};
return cfg;
}
@ -543,7 +542,7 @@ static int delaymgr_task_count(const delaymgr_t *mgr)
/* ── Case 1: DIRECT PRIMARY — become-leader lands in CTRL_PRIMARY, not a
* transitional state; ha_is_primary() is true immediately on return (Req 1.1,
* 1.4). RED today: raft_cb_become_leader stores CTRL_PRIMARY_COMMITTING. */
* 1.4). raft_cb_become_leader stores CTRL_PRIMARY directly. */
static void test_promotion_lands_directly_in_primary(void)
{
#ifdef XCE_TESTING
@ -560,8 +559,7 @@ static void test_promotion_lands_directly_in_primary(void)
ctrl_role_t role =
(ctrl_role_t)atomic_load_explicit(&ha.role, memory_order_acquire);
TEST_ASSERT_EQUAL_INT_MESSAGE((int)CTRL_PRIMARY, (int)role,
"winning election must land DIRECTLY in CTRL_PRIMARY (Req 1.1); "
"current code stores CTRL_PRIMARY_COMMITTING — RED");
"winning election must land DIRECTLY in CTRL_PRIMARY (Req 1.1)");
TEST_ASSERT_EQUAL_INT_MESSAGE(1, ha_is_primary(&ha),
"ha_is_primary() must be true the instant promotion returns (Req 1.4)");
@ -573,9 +571,10 @@ static void test_promotion_lands_directly_in_primary(void)
}
/* ── Case 2: NO REDIRECT — ha_should_redirect() returns 0 the instant
* promotion returns, before any standby ACK (Req 1.3). RED today: in
* CTRL_PRIMARY_COMMITTING ha_should_redirect can return 1 (a live election
* winner pointing clients at a primary that "does not exist yet"). */
* promotion returns, before any standby ACK (Req 1.3). A live election winner
* is CTRL_PRIMARY, so it never redirects clients to a primary that "does not
* exist yet" — the redirect-loop window cannot exist once the single leader
* role is the only leader role. */
static void test_promotion_no_redirect_from_live_primary(void)
{
#ifdef XCE_TESTING
@ -594,11 +593,10 @@ static void test_promotion_no_redirect_from_live_primary(void)
raft_cb_become_leader_for_test(&ha, /*term=*/1);
/* Assert: a live primary never redirects (Req 1.3). In CTRL_PRIMARY the
* stale hint is ignored; in CTRL_PRIMARY_COMMITTING (today) the hint makes
* ha_should_redirect return 1 → RED. */
* stale hint is ignored — a node that is itself the primary cannot satisfy
* the redirect condition. */
TEST_ASSERT_EQUAL_INT_MESSAGE(0, ha_should_redirect(&ha),
"a freshly promoted primary must NOT redirect clients (Req 1.3); "
"current COMMITTING role + a primary hint yields redirect=1 — RED");
"a freshly promoted primary must NOT redirect clients (Req 1.3)");
ha_fini(&ha);
printf("test_promotion_no_redirect_from_live_primary: PASS\n");
@ -1074,9 +1072,9 @@ static void test_standalone_promotion_gate_opens_immediately(void)
*
* Pin: the legacy real-cluster path produces role/hook/gate/no-watchdog state
* identical to the raft road's role and to ha_promote_to_primary's full state.
* RED before task 3.1: the legacy branch stored CTRL_PRIMARY_COMMITTING, reset
* repl_ack_count, and armed the committing watchdog — diverging from the raft
* road on every axis. */
* Both roads land directly in CTRL_PRIMARY, arm the own-term gate, and arm no
* wall-clock watchdog — symmetric on every axis (the former transitional-state
* divergence, with its ack counter and watchdog, is gone). */
static void test_legacy_and_raft_promotion_are_symmetric(void)
{
#ifdef XCE_TESTING

View file

@ -8,7 +8,7 @@
* shortcircuit delegates to raft_start_election, which fires on_become_leader
* synchronously → raft_cb_become_leader (ha-single-leader-role task 2.2),
* so the role lands DIRECTLY in CTRL_PRIMARY — there is no transitional
* CTRL_PRIMARY_COMMITTING and no committing-timeout watchdog (Req 1.1, 1.5).
* leader sub-state and no wall-clock promotion watchdog (Req 1.1, 1.5).
* - Function returns in < 5ms (observable via clock_gettime).
* - n_peers=1 shortcircuit stores n_peers in ha->n_peers.
*
@ -50,7 +50,6 @@ static ha_config_t g_async_cfg = {
.max_promotion_retries = 5,
.election_jitter_max_ms = 0,
.election_priority_step_ms = 500,
.committing_timeout_ms = 10000,
};
/* ── Helper: elapsed ms since a timespec ───────────────────────────────── */
@ -116,14 +115,14 @@ static void test_single_node_shortcircuit_evaluates_immediately(void)
/* n_peers=1: the shortcircuit fires raft_start_election → on_become_leader
* (raft_cb_become_leader) synchronously. ha-single-leader-role (Req 1.1):
* a winning node lands DIRECTLY in CTRL_PRIMARY — there is no transitional
* CTRL_PRIMARY_COMMITTING. New-leader safety comes from the own-term
* leader sub-state. New-leader safety comes from the own-term
* WAL_NOOP + writable_lsn gate, not from a fourth role value. */
ha_start_election_async(&ha, NULL, 1, NULL, 100ULL, 50ULL, "ctrl-a");
ctrl_role_t role = (ctrl_role_t)atomic_load_explicit(&ha.role, memory_order_acquire);
TEST_ASSERT_EQUAL_INT_MESSAGE((int)CTRL_PRIMARY, (int)role,
"single-node shortcircuit must enter CTRL_PRIMARY directly (Req 1.1) — "
"no transitional CTRL_PRIMARY_COMMITTING");
"no transitional leader sub-state");
ha_fini(&ha);
printf("test_single_node_shortcircuit_evaluates_immediately: PASS (role=%d == CTRL_PRIMARY)\n",
@ -140,13 +139,13 @@ static void test_single_node_not_primary_directly(void)
ha_start_election_async(&ha, NULL, 1, NULL, 0ULL, 0ULL, "ctrl-a");
/* ha-single-leader-role (Req 1.1): the winning node lands in CTRL_PRIMARY
* immediately — the old "must go through COMMITTING first" contract is gone.
* Promotion arms NO committing-timeout watchdog (Req 1.5); the own-term gate
* (WAL_NOOP + writable_lsn) provides new-leader safety instead. */
* immediately — the old "must go through a transitional state first"
* contract is gone. Promotion arms NO wall-clock watchdog (Req 1.5); the
* own-term gate (WAL_NOOP + writable_lsn) provides new-leader safety. */
ctrl_role_t role = (ctrl_role_t)atomic_load_explicit(&ha.role, memory_order_acquire);
TEST_ASSERT_EQUAL_INT_MESSAGE((int)CTRL_PRIMARY, (int)role,
"single-node shortcircuit must land DIRECTLY in CTRL_PRIMARY (Req 1.1) — "
"no transitional CTRL_PRIMARY_COMMITTING, no committing watchdog (Req 1.5)");
"no transitional leader sub-state, no wall-clock watchdog (Req 1.5)");
ha_fini(&ha);
printf("test_single_node_not_primary_directly: PASS (role=%d == CTRL_PRIMARY)\n", (int)role);

View file

@ -546,34 +546,43 @@ static void test_dispatch_gate_init_allows_heartbeat(void)
printf("test_dispatch_gate_init_allows_heartbeat: PASS\n");
}
/* ── Task 3.1: dispatch gate CTRL_PRIMARY_COMMITTING ─────────────────────── */
/* ── Task 3.1: dispatch gate — primary without write quorum (single leader
* role; the former transitional state is gone, ha-single-leader-role Req 1.2).
* A multi-node primary with no connected standbys lacks write quorum
* (n_peers=2, standby_endpoints empty → ha_has_write_quorum false), so it
* applies the universal commit barrier: job ops are not-ready, replication ops
* that build majority are permitted. */
static void test_dispatch_gate_committing_denies_job_ops(void)
static void test_dispatch_gate_primary_no_quorum_denies_job_ops(void)
{
/* Req 5.1, 8.1: When role is CTRL_PRIMARY_COMMITTING, primary-only
* job operations must be denied with XCE_ERR_NOT_READY.
/* Req 5.1, 8.1 + commit barrier: a primary lacking write quorum denies
* primary-only job operations with XCE_ERR_NOT_READY.
* OPCode_jobsub is a representative job-submission opcode. */
ha_state_t ha;
ha_init(&ha, &g_dispatch_cfg, 0);
atomic_store_explicit(&ha.role, (int32_t)CTRL_PRIMARY_COMMITTING,
atomic_store_explicit(&ha.role, (int32_t)CTRL_PRIMARY,
memory_order_release);
/* Multi-node cluster with zero connected standbys → no write quorum. */
ha.n_peers = 2;
int rc = ha_dispatch_gate(&ha, OPCode_jobsub);
TEST_ASSERT_EQUAL_INT(-XCE_ERR_NOT_READY, rc);
ha_fini(&ha);
printf("test_dispatch_gate_committing_denies_job_ops: PASS\n");
printf("test_dispatch_gate_primary_no_quorum_denies_job_ops: PASS\n");
}
static void test_dispatch_gate_committing_allows_repl_ops(void)
static void test_dispatch_gate_primary_no_quorum_allows_repl_ops(void)
{
/* Req 5.2, 8.1: When role is CTRL_PRIMARY_COMMITTING, replication
* operations must be permitted (return 0).
* OPCode_replCatchUpDone is the key signal that ends the COMMITTING phase. */
/* Req 5.2, 8.1: a primary lacking write quorum still permits replication
* operations (return 0) — they are how the cluster rebuilds majority.
* OPCode_replCatchUpDone is the catch-up completion signal. */
ha_state_t ha;
ha_init(&ha, &g_dispatch_cfg, 0);
atomic_store_explicit(&ha.role, (int32_t)CTRL_PRIMARY_COMMITTING,
atomic_store_explicit(&ha.role, (int32_t)CTRL_PRIMARY,
memory_order_release);
/* Multi-node cluster with zero connected standbys → no write quorum. */
ha.n_peers = 2;
TEST_ASSERT_EQUAL_INT(0, ha_dispatch_gate(&ha, OPCode_replCatchUpDone));
TEST_ASSERT_EQUAL_INT(0, ha_dispatch_gate(&ha, OPCode_heartbeat));
@ -586,7 +595,7 @@ static void test_dispatch_gate_committing_allows_repl_ops(void)
TEST_ASSERT_EQUAL_INT(0, ha_dispatch_gate(&ha, OPCode_replAck));
ha_fini(&ha);
printf("test_dispatch_gate_committing_allows_repl_ops: PASS\n");
printf("test_dispatch_gate_primary_no_quorum_allows_repl_ops: PASS\n");
}
/* ── Task 1.1 (ha-heartbeat-liveness): ha_init() zero-initializes last_activity ─
@ -615,8 +624,8 @@ static void test_ha_init_last_activity_is_zero(void)
* These tests verify the call-sequence contract by checking that:
* 1. ha_start_election_async with n_peers=1 sets ha->n_peers = 1 (single-node
* path — the same path myapp_run takes for a single-node cluster).
* 2. The role after ha_start_election_async(n_peers=1) is CTRL_PRIMARY_COMMITTING
* (not CTRL_PRIMARY) — matching what myapp_run would produce in the new design.
* 2. The role after ha_start_election_async(n_peers=1) is CTRL_PRIMARY
* directly (single leader role) — matching what myapp_run would produce.
* 3. ha_start_election_async completes in < 10ms — confirming myapp_run does not
* block before entering cluster_run.
*
@ -634,7 +643,6 @@ static ha_config_t g_startup_cfg = {
.max_promotion_retries = 5,
.election_jitter_max_ms = 0,
.election_priority_step_ms = 500,
.committing_timeout_ms = 10000,
};
static void test_nonblocking_single_node_election_sets_n_peers(void)
@ -656,12 +664,13 @@ static void test_nonblocking_single_node_election_sets_n_peers(void)
printf("test_nonblocking_single_node_election_sets_n_peers: PASS\n");
}
static void test_nonblocking_single_node_election_enters_committing(void)
static void test_nonblocking_single_node_election_enters_primary_directly(void)
{
/* Req 9.3, 8.1: After ha_start_election_async(n_peers=1) the role must be
* CTRL_PRIMARY_COMMITTING, NOT CTRL_PRIMARY.
/* Req 1.1, 9.3, 8.1: After ha_start_election_async(n_peers=1) the role must
* be CTRL_PRIMARY directly — the election winner is the primary, with no
* transitional leader sub-state (ha-single-leader-role, single leader role).
* This validates that the new myapp_run path (without blocking for(;;) loop)
* correctly transitions through the COMMITTING state. */
* promotes straight to the primary role. */
ha_state_t ha;
ha_init(&ha, &g_startup_cfg, 0);
ha.phase2_done = 0;
@ -671,11 +680,11 @@ static void test_nonblocking_single_node_election_enters_committing(void)
ctrl_role_t role = (ctrl_role_t)atomic_load_explicit(
&ha.role, memory_order_acquire);
TEST_ASSERT_EQUAL_INT_MESSAGE((int)CTRL_PRIMARY_COMMITTING, (int)role,
"myapp_run path: single-node must enter CTRL_PRIMARY_COMMITTING after election");
TEST_ASSERT_EQUAL_INT_MESSAGE((int)CTRL_PRIMARY, (int)role,
"myapp_run path: single-node must enter CTRL_PRIMARY directly after election (Req 1.1)");
ha_fini(&ha);
printf("test_nonblocking_single_node_election_enters_committing: PASS (role=%d)\n",
printf("test_nonblocking_single_node_election_enters_primary_directly: PASS (role=%d)\n",
(int)role);
}
@ -810,13 +819,13 @@ void test_ha_startup(void)
/* Req 1.1 — dispatch gate allows heartbeat in CTRL_INIT */
RUN_TEST(test_dispatch_gate_init_allows_heartbeat);
/* Task 3.1 — dispatch gate: CTRL_PRIMARY_COMMITTING */
RUN_TEST(test_dispatch_gate_committing_denies_job_ops);
RUN_TEST(test_dispatch_gate_committing_allows_repl_ops);
/* Task 3.1 — dispatch gate: primary without write quorum (single leader role) */
RUN_TEST(test_dispatch_gate_primary_no_quorum_denies_job_ops);
RUN_TEST(test_dispatch_gate_primary_no_quorum_allows_repl_ops);
/* Task 5.1 — myapp_run non-blocking startup sequence (Req 1.1, 9.3, 9.4) */
RUN_TEST(test_nonblocking_single_node_election_sets_n_peers);
RUN_TEST(test_nonblocking_single_node_election_enters_committing);
RUN_TEST(test_nonblocking_single_node_election_enters_primary_directly);
RUN_TEST(test_nonblocking_election_returns_in_under_10ms);
/* Task 1.1 (ha-heartbeat-liveness) — ha_init zero-initializes last_activity */

View file

@ -92,8 +92,8 @@ static void test_primary_role_denies_vote_request(void)
static void test_primary_role_denies_fresher_same_term_challenger(void)
{
/* The vote-deny contract that a transitional COMMITTING role used to carry
* now lives entirely on CTRL_PRIMARY (single leader role). An elected
/* The vote-deny contract lives entirely on CTRL_PRIMARY (single leader
* role — there is no transitional leader sub-state). An elected
* primary must deny a same-term challenger even when the challenger looks
* FRESHER (higher config_tag / LSN): the role gate fires before any
* freshness evaluation, protecting the live primary from displacement. */