myxce/src/controller/testsuite/test_ha_role_lifecycle.c

1187 lines
56 KiB
C

#define _GNU_SOURCE
/*
* test_ha_role_lifecycle.c — ha-single-leader-role lifecycle contract.
*
* Task 1.1 (TDD RED): the WAL_NOOP no-op entry type. Later tasks (2.1, 3.1)
* add direct-promotion and own-term-gate lifecycle cases to this same file.
*
* RED-PHASE APPROACH (b): WAL_NOOP does not exist yet (lands in task 1.2),
* so referencing the symbol would fail to COMPILE. Instead these cases use
* the literal value 40 (the value WAL_NOOP will take in wal_entry_type, per
* design.md §"WAL_NOOP Entry") so the file COMPILES and LINKS into the
* controller test binary; the assertions then fail at RUNTIME because the
* type-40 behavior (explicit replay no-op, RETAIN classification) is not
* implemented. This satisfies task 1.1's "build succeeds + new cases FAIL".
*
* The classification case (wal_classify(40,...)) cannot run inline in the
* current tree: the totality guard does `assert(0)` for any unclassified
* type, which would ABORT the whole test binary. It is isolated via the
* fork() abort-test pattern (cerebrum: child silences stderr + _exit, parent
* inspects exit status) so the RED state is a deterministic, captured
* assertion in the parent rather than a binary crash.
*
* AAA structure throughout.
*/
#include <assert.h>
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
#include <pthread.h>
#include <semaphore.h>
#include <signal.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
#include <unity/unity.h>
#include "config.h"
#include "persist.h"
/* Task 2.1 (RED): direct-promotion + own-term-gate lifecycle contract.
* These headers pull in the promotion seam (ha_*), the commit engine + its
* own-term gate (commit_*), the stub standby registry (repl_*), and the
* full cluster_t layout so a partial-but-real cluster can be promoted. */
#include "commit.h" /* commit_engine_t, commit_submit, commit_set_writable_lsn */
#include "ha.h" /* ha_promote_to_primary, ha_is_primary, ha_should_redirect */
#include "repl.h" /* repl_init / repl_add_standby / repl_set_commit_engine */
#include "xce/cluster_def.h" /* full cluster_t layout (c.commit / c.persist / c.ha / ...) */
#include "xce/errnox.h" /* XCE_ERR_NOT_READY */
/* The value WAL_NOOP will take in wal_entry_type (task 1.2). Using the
* literal keeps THIS file compiling before the enum value exists; task 1.2
* replaces these with the WAL_NOOP symbol once it lands. */
#define WAL_NOOP_VALUE 40u
/* ------------------------------------------------------------------ */
/* Broadcast drop-hook (precedent: test_p2_integration.c:32/313, */
/* test_commit_engine.c:1133/1308; hook defined in repl.c:21). */
/* The lifecycle fixture registers fake int g_lc_eps[] endpoints via */
/* repl_add_standby; the own-term WAL_NOOP append now broadcasts via */
/* repl_broadcast → xce_wal_batch_req_raw(ep), which would dereference */
/* ep->peer past the 4-byte int (ASAN global-buffer-overflow at */
/* request.c). Installing this hook drops the wire send harmlessly. */
/* ------------------------------------------------------------------ */
#ifdef XCE_TESTING
extern int (*repl_broadcast_send_hook)(void *ep,
wal_catchup_entry_t **entries,
int n_entries);
static int lc_drop_send_hook(void *ep, wal_catchup_entry_t **entries,
int n_entries)
{
(void)ep; (void)entries; (void)n_entries;
return 0; /* pretend success, drop the data */
}
#endif
/* ------------------------------------------------------------------ */
/* Test-local fixtures */
/* ------------------------------------------------------------------ */
/* Spin up a real on-disk persist_t in a fresh tmpdir (no cluster wired —
* apply_wal_entry's entity-restore path is skipped when p->cluster == NULL,
* which is exactly the no-effect guarantee we assert). */
static persist_t* make_lifecycle_persist(const char* suffix)
{
persist_t* p = calloc(1, sizeof(persist_t));
if (!p)
return NULL;
persist_config_t cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000; /* slow background fsync — no races in tests */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/xce_noop_%s_XXXXXX", suffix);
if (!mkdtemp(tmpdir)) {
free(p);
return NULL;
}
persist_set_workdir(p, tmpdir, "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;
}
return p;
}
static void teardown_lifecycle_persist(persist_t* p)
{
if (!p)
return;
persist_stop(p);
persist_fini(p);
free(p);
/* tmpdir left on disk — /tmp is ephemeral */
}
/* Read the most-recently-appended WAL entry header back from the active
* segment file. Returns 0 and fills *out on success, negative on error.
* Reads the header at (seg_offset - sizeof(hdr)) — the last entry written. */
static int read_last_wal_hdr(persist_t* p, wal_entry_hdr_t* out)
{
if (p->seg_offset < sizeof(wal_entry_hdr_t))
return -ENOENT;
int fd = open(p->seg_path, O_RDONLY);
if (fd < 0)
return -errno;
off_t at = (off_t)(p->seg_offset - sizeof(wal_entry_hdr_t));
ssize_t r = pread(fd, out, sizeof(*out), at);
close(fd);
if (r != (ssize_t)sizeof(*out))
return -EIO;
return 0;
}
/* Evaluate wal_classify(WAL_NOOP, 0) in a forked child and report whether it
* cleanly returned WAL_MODE_RETAIN. Returns 1 iff the child exited 0 (no
* abort, RETAIN), 0 otherwise (abort/SIGABRT today, since WAL_NOOP is
* unclassified → the totality fail-fast `assert(0)` fires). Isolating the
* call protects the test binary from the abort (cerebrum fork pattern). */
static int noop_classifies_retain(void)
{
pid_t pid = fork();
if (pid < 0)
return -1;
if (pid == 0) {
setenv("ASAN_OPTIONS", "detect_leaks=0", 1);
int devnull = open("/dev/null", O_WRONLY);
if (devnull >= 0) {
dup2(devnull, STDERR_FILENO);
dup2(devnull, STDOUT_FILENO);
}
wal_commit_mode_t mode = wal_classify(WAL_NOOP_VALUE, 0);
_exit(mode == WAL_MODE_RETAIN ? 0 : 1);
}
int status = 0;
if (waitpid(pid, &status, 0) != pid)
return -1;
return (WIFEXITED(status) && WEXITSTATUS(status) == 0) ? 1 : 0;
}
/* ------------------------------------------------------------------ */
/* Task 1.1 — WAL_NOOP no-op entry (Req 2.1) */
/* ------------------------------------------------------------------ */
/* Case 1: persist_wal_async(p, WAL_NOOP, NULL, 0) appends a header-only
* entry (payload_len == 0) and broadcasts it through the production path.
*
* Observables:
* - return 0 (append succeeded);
* - the on-disk last entry has type == WAL_NOOP and payload_len == 0;
* - last_replicated_crc advanced to this entry's CRC (the same field
* persist_wal_async sets immediately alongside repl_broadcast,
* persist.c:359 — proxy for "the entry was handed to the replicator").
*
* RED today: persist_wal_async will write a type-40 entry, but the spec
* requires WAL_NOOP to be appended with payload_len 0 ONLY via this path;
* the binding of the value-40 type to the WAL_NOOP no-op contract does not
* exist until task 1.2 wires the explicit replay/classify handling. The
* payload_len/type-round-trip assertion below pins the entry shape that 1.2
* must produce; it is asserted now so 1.2 has a concrete target. */
static void test_noop_append_is_header_only_and_broadcast(void)
{
/* Arrange */
persist_t* p = make_lifecycle_persist("append");
TEST_ASSERT_NOT_NULL(p);
uint64_t lsn_before = p->current_lsn;
uint32_t crc_before = p->last_replicated_crc;
/* Act: append a no-op (header only, NULL payload, len 0) */
int rc = persist_wal_async(p, WAL_NOOP_VALUE, NULL, 0);
/* Assert: append succeeded and consumed exactly one LSN */
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc,
"WAL_NOOP append must succeed via persist_wal_async");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(lsn_before + 1, p->current_lsn,
"WAL_NOOP append must advance current_lsn by exactly one");
/* Assert: the on-disk entry is header-only of the WAL_NOOP type */
wal_entry_hdr_t hdr;
memset(&hdr, 0, sizeof(hdr));
TEST_ASSERT_EQUAL_INT_MESSAGE(0, read_last_wal_hdr(p, &hdr),
"must be able to read the appended WAL_NOOP header back from disk");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(WAL_NOOP_VALUE, hdr.type,
"appended entry must be of type WAL_NOOP");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(0u, hdr.payload_len,
"WAL_NOOP must be header-only (payload_len == 0)");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(lsn_before, hdr.lsn,
"WAL_NOOP entry LSN must be the pre-append current_lsn");
/* Assert: the entry was broadcast (last_replicated_crc moved off its
* pre-append value to this entry's crc — the replicate-path observable) */
TEST_ASSERT_NOT_EQUAL_MESSAGE(crc_before, p->last_replicated_crc,
"WAL_NOOP append must broadcast (last_replicated_crc advances)");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(hdr.crc, p->last_replicated_crc,
"broadcast CRC must match the appended WAL_NOOP entry CRC");
/* Assert: WAL_NOOP is a CLASSIFIED type. An entry appended through the
* write path must be classifiable (the design lands the enum value and
* its wal_classify row in the SAME change, task 1.2). Today WAL_NOOP is
* unclassified, so this is RED: the classify call aborts under the
* totality guard (observed via the forked-child helper). */
TEST_ASSERT_EQUAL_INT_MESSAGE(1, noop_classifies_retain(),
"an appended WAL_NOOP must be a classified type (wal_classify → RETAIN)");
teardown_lifecycle_persist(p);
printf("test_noop_append_is_header_only_and_broadcast: PASS\n");
}
/* Case 2: apply_wal_entry on a WAL_NOOP advances current_lsn past the
* entry's LSN and touches NO entity state.
*
* With p->cluster == NULL the entity-restore switch is skipped entirely, so
* "no entity state touched" is structurally guaranteed; the contract this
* pins is the LSN advance via the common path (persist.c:719-723) for a
* WAL_NOOP header-only entry.
*
* RED today: the entry applies (type 40 < WAL_ENTRY_TYPE_MAX, so the common
* path advances LSN), BUT task 1.2 must add an EXPLICIT `case WAL_NOOP:
* break;` so the no-op is a recognised type rather than falling through the
* `default:` arm. We additionally require the apply to return 0 AND not
* emit the unknown-type WARN path — pinned here as the apply contract 1.2
* realises. The assertion that the entry is recognised (return 0 with a
* header-only payload and exact LSN advance) is the RED carrier. */
static void test_noop_apply_advances_lsn_no_entity_effect(void)
{
/* Arrange: a persist with NO cluster — entity restore is skipped, so any
* observable effect would have to come from the WAL_NOOP arm itself */
persist_t* p = make_lifecycle_persist("apply");
TEST_ASSERT_NOT_NULL(p);
TEST_ASSERT_NULL_MESSAGE(p->cluster,
"fixture must have no cluster wired (no-effect guarantee depends on it)");
/* Build a header-only WAL_NOOP entry at a known LSN beyond current */
uint64_t noop_lsn = p->current_lsn + 5;
wal_entry_hdr_t hdr;
memset(&hdr, 0, sizeof(hdr));
hdr.term = p->term;
hdr.lsn = noop_lsn;
hdr.timestamp = 0;
hdr.type = WAL_NOOP_VALUE;
hdr.payload_len = 0;
hdr.crc = 0;
hdr.prev_crc = 0;
/* Act: apply the no-op with a NULL payload (header-only) */
int rc = apply_wal_entry(p, &hdr, NULL);
/* Assert: clean apply, LSN advanced past the entry, nothing else */
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc,
"applying a WAL_NOOP must succeed");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(noop_lsn + 1, p->current_lsn,
"applying a WAL_NOOP must advance current_lsn to entry LSN + 1");
/* No cluster → no entity state to inspect; the absence of effect is the
* point. Re-applying must be idempotent on LSN (already advanced). */
int rc2 = apply_wal_entry(p, &hdr, NULL);
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc2,
"re-applying a WAL_NOOP must remain a clean no-op");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(noop_lsn + 1, p->current_lsn,
"re-applying a lower/equal LSN WAL_NOOP must not regress current_lsn");
/* Assert: WAL_NOOP is a recognised, classified entry type — not an
* unknown type the apply/classify paths only tolerate by fallthrough.
* RED today: wal_classify(WAL_NOOP) aborts (unclassified) per the
* totality guard; task 1.2 adds the explicit case + RETAIN row. */
TEST_ASSERT_EQUAL_INT_MESSAGE(1, noop_classifies_retain(),
"an applied WAL_NOOP must be a classified type (wal_classify → RETAIN)");
teardown_lifecycle_persist(p);
printf("test_noop_apply_advances_lsn_no_entity_effect: PASS\n");
}
/* Case 3: boot replay over a segment containing a WAL_NOOP replays cleanly
* with the same no-effect guarantee, and the WAL_NOOP is a RECOGNISED type.
*
* Fixture shape (deliberate): append ONE ordinary, already-classified WAL
* entry (WAL_JOB_START, header-only) FIRST so it takes lsn 0, THEN append the
* WAL_NOOP so it lands at lsn 1. This matters because replay_wal skips any
* entry with `hdr.lsn <= from_lsn` (persist.c:907) and a fresh persist recovers
* with from_lsn == checkpoint_lsn == 0. A WAL_NOOP written as the FIRST entry
* would sit at lsn 0 and be skipped as snapshot-covered — an lsn-0/checkpoint-
* boundary FIXTURE ARTIFACT unrelated to the WAL_NOOP contract (it would fail
* for ANY type at lsn 0 and would NOT be fixed by task 1.2). By seating the
* noop at lsn > 0 we pin the actual boot-replay contract instead.
*
* The leading WAL_JOB_START is side-effect-free here: apply_wal_entry returns
* early at the `if (!p->cluster)` guard (persist.c:727-728) before the entity-
* restore switch, so neither the ordinary entry nor the noop touches entity
* state during replay — preserving the no-effect guarantee.
*
* Observables that PIN THE CONTRACT (these hold both today and after task 1.2,
* because WAL_NOOP_VALUE 40 <= WAL_ENTRY_TYPE_MAX 100 already routes through
* apply_wal_entry, which advances current_lsn):
* - persist_recover returns 0 (replay clean);
* - current_lsn advances STRICTLY past the noop's lsn (>= noop_lsn + 1);
* - no entity side effects (NULL cluster — structurally guaranteed).
*
* The RED PIN is the SHARED classify carrier noop_classifies_retain(): boot
* replay of a WAL_NOOP is only correct when the type is a recognised, RETAIN-
* classified entry — not an unclassified type tolerated by routing alone.
* Today wal_classify(40) aborts under the totality guard (carrier returns 0);
* task 1.2 lands the enum + `case WAL_NOOP: break;` apply arm + the RETAIN
* classify row TOGETHER, flipping the carrier to 1 and this case to green. */
static void test_noop_boot_replay_clean_no_effect(void)
{
char tmpdir[256];
uint64_t noop_lsn;
/* Arrange: write [WAL_JOB_START (lsn 0), WAL_NOOP (lsn 1)] into a fresh
* persist so the noop sits at lsn > 0 (escaping the lsn<=from_lsn skip),
* capture the noop's LSN, flush, then tear down (keeping the on-disk WAL). */
{
persist_t* p = calloc(1, sizeof(persist_t));
TEST_ASSERT_NOT_NULL(p);
persist_config_t cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000;
strcpy(tmpdir, "/tmp/xce_noop_replay_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(tmpdir));
persist_set_workdir(p, tmpdir, "ctrl-test");
TEST_ASSERT_EQUAL_INT(0, persist_init(p, &cfg, NULL));
TEST_ASSERT_EQUAL_INT(0, persist_start(p));
/* Leading ORDINARY entry — already classified, header-only, harmless
* on replay under a NULL cluster. Takes lsn 0 so the noop gets lsn>0. */
TEST_ASSERT_EQUAL_INT_MESSAGE(0,
persist_wal_async(p, WAL_JOB_START, NULL, 0),
"leading ordinary WAL entry must append for the replay fixture");
noop_lsn = p->current_lsn; /* the LSN the noop is about to take (> 0) */
TEST_ASSERT_TRUE_MESSAGE(noop_lsn > 0,
"noop must sit at lsn > 0 so replay does not skip it as "
"snapshot-covered (hdr.lsn <= from_lsn guard, persist.c:907)");
TEST_ASSERT_EQUAL_INT_MESSAGE(0,
persist_wal_async(p, WAL_NOOP_VALUE, NULL, 0),
"WAL_NOOP must append for the replay fixture");
TEST_ASSERT_EQUAL_INT(0, persist_flush(p));
persist_stop(p);
persist_fini(p);
free(p);
}
/* Act: recover a FRESH persist over the same workdir (boot replay).
* persist_init does NOT scan segments / set current_lsn — recovery is
* explicit via persist_recover, so current_lsn starts at the calloc'd 0. */
persist_t* p2 = calloc(1, sizeof(persist_t));
TEST_ASSERT_NOT_NULL(p2);
persist_config_t cfg2 = persist_config_defaults();
cfg2.wal_sync_ms = 5000;
persist_set_workdir(p2, tmpdir, "ctrl-test");
TEST_ASSERT_EQUAL_INT(0, persist_init(p2, &cfg2, NULL));
TEST_ASSERT_EQUAL_INT(0, persist_start(p2));
TEST_ASSERT_NULL_MESSAGE(p2->cluster,
"replay fixture must have no cluster (no-effect guarantee)");
int rc = persist_recover(p2);
/* Assert: replay clean, LSN advanced STRICTLY past the no-op (contract). */
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc,
"boot replay over a WAL_NOOP must succeed");
TEST_ASSERT_TRUE_MESSAGE(p2->current_lsn > noop_lsn,
"boot replay over a WAL_NOOP must advance current_lsn past the no-op");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(noop_lsn + 1, p2->current_lsn,
"boot replay must leave current_lsn at one past the WAL_NOOP's LSN");
persist_stop(p2);
persist_fini(p2);
free(p2);
/* Assert (RED PIN): the replayed WAL_NOOP must be a recognised, RETAIN-
* classified type — not merely tolerated by apply/replay routing. Today
* wal_classify(WAL_NOOP) aborts (unclassified) per the totality guard, so
* the forked carrier returns 0 and this case is RED for the WAL_NOOP
* CONTRACT reason. Task 1.2's enum + apply arm + RETAIN classify row flips
* it to 1, turning this case green. */
TEST_ASSERT_EQUAL_INT_MESSAGE(1, noop_classifies_retain(),
"a replayed WAL_NOOP must be a classified type (wal_classify → RETAIN)");
printf("test_noop_boot_replay_clean_no_effect: PASS\n");
}
/* Case 4: wal_classify(WAL_NOOP, 0) == WAL_MODE_RETAIN.
*
* This keeps barrier-rollout's classification-totality test meaningful: the
* enum value and its classification row must land together (task 1.2).
*
* RED today: WAL_NOOP (40) is unclassified, so wal_classify hits the
* totality fail-fast `assert(0)` under XCE_TESTING and ABORTS. Calling it
* inline would crash the whole test binary, so it is evaluated in a forked
* child (cerebrum fork abort-test pattern). The contract is RETAIN; the
* child computes wal_classify(40,0) and exits 0 ONLY if it returns RETAIN
* without aborting. Today the child aborts (SIGABRT), so the parent's
* "exited cleanly with RETAIN" assertion FAILS — the captured RED state.
*
* detect_leaks=0 set in the child so ASAN does not flag the abort path. */
static void test_noop_classify_is_retain(void)
{
pid_t pid;
int status = 0;
/* Act: classify WAL_NOOP in a child (it may abort in the current tree) */
pid = fork();
TEST_ASSERT_TRUE_MESSAGE(pid >= 0, "fork must succeed");
if (pid == 0) {
/* Child: silence the expected assert/abort noise; ASAN-friendly. */
setenv("ASAN_OPTIONS", "detect_leaks=0", 1);
int devnull = open("/dev/null", O_WRONLY);
if (devnull >= 0) {
dup2(devnull, STDERR_FILENO);
dup2(devnull, STDOUT_FILENO);
}
wal_commit_mode_t mode = wal_classify(WAL_NOOP_VALUE, 0);
/* Reaching here means no abort; encode the result in the exit code:
* exit 0 ⇔ RETAIN (the contract), exit 1 ⇔ classified-but-wrong. */
_exit(mode == WAL_MODE_RETAIN ? 0 : 1);
}
/* Assert: the child must exit cleanly with RETAIN. Today it aborts
* (SIGABRT) because WAL_NOOP is unclassified — this assertion is RED. */
TEST_ASSERT_EQUAL_INT_MESSAGE(pid, waitpid(pid, &status, 0),
"waitpid must reap the classifier child");
TEST_ASSERT_TRUE_MESSAGE(WIFEXITED(status),
"wal_classify(WAL_NOOP) must NOT abort — it must classify the type");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, WEXITSTATUS(status),
"wal_classify(WAL_NOOP, 0) must return WAL_MODE_RETAIN");
printf("test_noop_classify_is_retain: PASS\n");
}
/* ══════════════════════════════════════════════════════════════════════════
* Task 2.1 (TDD RED) — DIRECT promotion + the OWN-TERM GATE.
*
* 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,
* 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
* operation is withheld until committed_lsn >= noop_lsn.
*
* SEAMS (study notes; no new production seam is introduced — RED-only):
* - 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
* 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)`
* (ha.c:1717, public in ha.h) against a partial-but-real cluster_t: a real
* tmpdir persist_t, a wired commit_engine_t (n_peers), a stub standby
* registry (repl_add_standby + repl_set_commit_engine), and a delaymgr
* left un-started. Per design §Promotion Rewrite, ha_promote_to_primary
* becomes the canonical "every road to PRIMARY arms the gate" entry point
* (task 2.2 adds the noop+gate tail there + in the raft callback). TODAY
* ha_promote_to_primary does ha_do_promote + hook-register + heartbeat and
* NEVER touches c->commit, so the own-term gate stays unset (writable_lsn
* == 0) — that is the RED carrier for cases 5-6.
*
* GATE OBSERVABILITY (cerebrum + commit.c:166-171): the engine's WAIT
* completion predicate is `committed_lsn >= lsn && (writable_lsn == 0 ||
* committed_lsn >= writable_lsn)`. writable_lsn is a public commit_engine_t
* field; commit_set_writable_lsn(ce, N) installs the gate. With the gate at N
* a WAIT on an entry M < N blocks past M's own commit, completing only when
* committed_lsn reaches N (test_commit_engine.c test 15 shape). TODAY
* promotion leaves writable_lsn == 0, so that same WAIT completes the instant
* M commits — the behavioral RED carrier.
*
* DELAYMGR TASK-COUNT SEAM (case 4): delay_task_ms enqueues into
* mgr->tasks (an ordq_t) regardless of running state (delay.c) — count the
* `tasks.head` singly-linked chain directly (ordq has no public count API).
* ════════════════════════════════════════════════════════════════════════ */
#ifdef XCE_TESTING
/* The raft become-leader callback shim (defined in ha.c under XCE_TESTING). */
extern void raft_cb_become_leader_for_test(ha_state_t *ha, uint64_t term);
#endif
/* Minimal ha_config_t accepted by ha_init (mirrors test_ha_role.c). */
static ha_config_t lifecycle_ha_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,
.committing_timeout_ms = HA_DEFAULT_COMMITTING_TIMEOUT_MS,
};
return cfg;
}
/* Count pending delaymgr tasks by walking the ordq head chain directly.
* Read-only: takes no lock (single-threaded test context, delaymgr never
* started). This is the "delaymgr task-count seam" the contract pins. */
static int delaymgr_task_count(const delaymgr_t *mgr)
{
int count = 0;
const ordq_item_t *it = mgr->tasks.head;
while (it != NULL) {
count++;
it = it->next;
}
return count;
}
/* ── 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. */
static void test_promotion_lands_directly_in_primary(void)
{
#ifdef XCE_TESTING
ha_state_t ha;
ha_config_t cfg = lifecycle_ha_cfg();
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &cfg, 0));
ha.phase2_done = 1; /* skip phase2 (no real cluster behind the shim) */
atomic_store_explicit(&ha.role, (int32_t)CTRL_ELECTING, memory_order_release);
/* Act: drive the raft become-leader path (shim sets ha.raft_c = NULL) */
raft_cb_become_leader_for_test(&ha, /*term=*/1);
/* Assert: role is CTRL_PRIMARY directly — no transitional value */
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");
TEST_ASSERT_EQUAL_INT_MESSAGE(1, ha_is_primary(&ha),
"ha_is_primary() must be true the instant promotion returns (Req 1.4)");
ha_fini(&ha);
printf("test_promotion_lands_directly_in_primary: PASS\n");
#else
printf("test_promotion_lands_directly_in_primary: SKIP (non-test build)\n");
#endif
}
/* ── 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"). */
static void test_promotion_no_redirect_from_live_primary(void)
{
#ifdef XCE_TESTING
ha_state_t ha;
ha_config_t cfg = lifecycle_ha_cfg();
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &cfg, 0));
ha.phase2_done = 1;
atomic_store_explicit(&ha.role, (int32_t)CTRL_ELECTING, memory_order_release);
/* A stale primary hint is present (would trigger redirect for any
* non-PRIMARY role): host+port set as if learned from a prior leader. */
strncpy(ha.primary_host, "10.0.0.9", sizeof(ha.primary_host) - 1);
ha.primary_port = 9991;
/* Act */
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. */
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");
ha_fini(&ha);
printf("test_promotion_no_redirect_from_live_primary: PASS\n");
#else
printf("test_promotion_no_redirect_from_live_primary: SKIP (non-test build)\n");
#endif
}
/* Case 3 (HOOKS REGISTERED BY THE SHARED PROMOTION SEQUENCE) is defined just
* below the real-cluster fixture (lifecycle_cluster_setup / g_lc) it shares
* with cases 4-7, since C requires those helpers to be declared before use.
* It is still RUN as case 3 (see test_ha_role_lifecycle below). */
/* ------------------------------------------------------------------ */
/* Real-cluster fixture for cases 3-7: a partial-but-real cluster_t */
/* whose persist (real tmpdir), commit engine (wired), stub standby */
/* registry and (un-started) delaymgr are the surfaces promotion drives.*/
/* ------------------------------------------------------------------ */
/* Statics survive Unity's longjmp on assertion failure (test_repl_majority
* pattern); cluster_t is large and must not live on the test stack. */
static cluster_t g_lc;
static int g_lc_eps[MAX_STANDBYS];
/* Build g_lc with: real persist over a fresh tmpdir, current_lsn primed so a
* promotion no-op would land at lsn = appended_lsn (= current_lsn - 1);
* n_standbys dummy standbys in the registry; commit engine wired to the
* registry (n_peers, n_vcpus); delaymgr initialised but NOT started; role
* forced to CTRL_ELECTING. Returns the lsn the next appended entry takes. */
static uint64_t lifecycle_cluster_setup(const char *suffix, int n_peers,
int n_standbys, uint64_t appended_lsn,
int n_vcpus)
{
int i;
char name[32];
char tmpdir[256];
memset(&g_lc, 0, sizeof(g_lc));
/* HA state */
ha_config_t cfg = lifecycle_ha_cfg();
TEST_ASSERT_EQUAL_INT(0, ha_init(&g_lc.ha, &cfg, 0));
g_lc.ha.phase2_done = 1; /* no phase2 wiring in this fixture */
g_lc.ha.n_peers = n_peers;
atomic_store_explicit(&g_lc.ha.role, (int32_t)CTRL_ELECTING,
memory_order_release);
/* Real persist over a private tmpdir (the no-op append target). */
persist_config_t pcfg = persist_config_defaults();
pcfg.wal_sync_ms = 5000; /* slow background fsync — no test races */
snprintf(tmpdir, sizeof(tmpdir), "/tmp/xce_slr_%s_XXXXXX", suffix);
TEST_ASSERT_NOT_NULL(mkdtemp(tmpdir));
persist_set_workdir(&g_lc.persist, tmpdir, "ctrl-test");
TEST_ASSERT_EQUAL_INT(0, persist_init(&g_lc.persist, &pcfg, NULL));
TEST_ASSERT_EQUAL_INT(0, persist_start(&g_lc.persist));
/* Prime the appended watermark: current_lsn = appended_lsn + 1 means the
* highest appended entry is `appended_lsn` and the next append takes it. */
pthread_mutex_lock(&g_lc.persist.lock);
g_lc.persist.current_lsn = appended_lsn + 1;
pthread_mutex_unlock(&g_lc.persist.lock);
atomic_store(&g_lc.persist.committed_lsn, 0);
/* Stub standby registry + wired engine (commit derives from watermarks). */
for (i = 0; i < n_standbys; i++) {
snprintf(name, sizeof(name), "lcsb%d", i);
TEST_ASSERT_TRUE_MESSAGE(
repl_add_standby(&g_lc.persist.repl, &g_lc_eps[i], name,
7400 + (uint32_t)i) >= 0,
"repl_add_standby must return a slot index >= 0");
}
TEST_ASSERT_EQUAL_INT(0,
commit_engine_init(&g_lc.commit, &g_lc.persist, n_peers, n_vcpus));
repl_set_commit_engine(&g_lc.persist.repl, &g_lc.commit);
/* Drop wire sends from the own-term WAL_NOOP broadcast — the fake int
* endpoints have no real ep->peer (see lc_drop_send_hook above). */
#ifdef XCE_TESTING
repl_broadcast_send_hook = lc_drop_send_hook;
#endif
/* delaymgr initialised but deliberately NOT started: promotion's heartbeat
* arm is skipped (ha_start_primary_heartbeat guards on delaymgr.running),
* so any task that appears in tasks.head was armed by promotion itself. */
TEST_ASSERT_EQUAL_INT(0, delaymgr_init(&g_lc.delaymgr, 100, NULL));
return appended_lsn; /* lsn a promotion no-op would take (current_lsn - 1) */
}
static void lifecycle_cluster_teardown(void)
{
#ifdef XCE_TESTING
repl_broadcast_send_hook = NULL;
#endif
commit_engine_fini(&g_lc.commit);
delaymgr_fini(&g_lc.delaymgr);
persist_stop(&g_lc.persist);
persist_fini(&g_lc.persist);
repl_fini(&g_lc.persist.repl);
ha_fini(&g_lc.ha);
/* tmpdir left on disk — /tmp is ephemeral */
}
/* Drive a standby ACK watermark into the registry + engine (production
* repl_on_ack shape: store acked_lsn, then feed commit_on_ack). */
static void lifecycle_standby_ack(int idx, uint64_t acked_lsn)
{
atomic_store(&g_lc.persist.repl.standbys[idx].acked_lsn, acked_lsn);
commit_on_ack(&g_lc.commit, idx, acked_lsn);
}
static uint64_t lifecycle_committed(void)
{
return atomic_load(&g_lc.persist.committed_lsn);
}
static uint64_t lifecycle_mono_ms(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (uint64_t)ts.tv_sec * 1000u + (uint64_t)ts.tv_nsec / 1000000u;
}
static void lifecycle_sleep_ms(uint32_t ms)
{
struct timespec ts;
ts.tv_sec = ms / 1000u;
ts.tv_nsec = (long)(ms % 1000u) * 1000000L;
nanosleep(&ts, NULL);
}
/* WAIT-on-an-entry thread context + body (test_commit_engine.c pattern). */
typedef struct {
uint64_t lsn;
uint32_t timeout_ms;
int rc;
uint64_t elapsed_ms;
atomic_int done;
} lc_wait_arg_t;
static void *lifecycle_wait_thread(void *p)
{
lc_wait_arg_t *a = (lc_wait_arg_t *)p;
uint64_t t0 = lifecycle_mono_ms();
a->rc = commit_submit(&g_lc.commit, a->lsn, COMMIT_MODE_WAIT,
a->timeout_ms);
a->elapsed_ms = lifecycle_mono_ms() - t0;
atomic_store(&a->done, 1);
return NULL;
}
/* ── Case 3: HOOKS REGISTERED BY THE SHARED PROMOTION SEQUENCE — the
* canonical promotion entry point ha_promote_to_primary(c) (ha.c:1717) must
* register the three persist hooks so an idle cluster's new primary is
* writable; observe via c->ha.hooks_registered transitioning 0 → 1 (Req 2.1).
*
* SHARED-SEQUENCE CONTRACT PIN (GREEN now, MUST STAY GREEN — not a behavioral
* RED carrier). After task 2.2 BOTH roads to PRIMARY (the raft become-leader
* callback and ha_repl_ack_received's quorum path) route through this same
* ha_promote_to_primary sequence, which task 2.2 augments with the noop+gate
* tail while KEEPING hook registration. This case is therefore a CONTRACT/
* REGRESSION pin proving the shared promotion sequence registers the persist
* hooks; the behavioral RED signal for 2.2 is carried by cases 1, 2, 5, 6.
*
* It uses the SAME real-cluster fixture cases 4-7 use (lifecycle_cluster_setup
* / g_lc), which is why it is defined here, below that fixture. This is the
* fix for the prior c==NULL shim defect: the raft become-leader shim forces
* ha->raft_c == NULL (ha.c:3121), which routes AROUND the c-dependent
* registration block, so it could never set the flag on that seam — pinning
* it there left an un-turnable RED. ha_promote_to_primary is the real shared
* seam and registers hooks today (ha.c:1722-1730).
*
* Why this is GREEN today: ha_promote_to_primary already registers the three
* persist hooks and sets hooks_registered = 1 (ha.c:1729). The g_lc fixture's
* hooksys is zeroed (memset), so xce_add_hook returns -EINVAL harmlessly
* (typ >= maxtypes==0, hook.c:41) WITHOUT storing the fns — but the flag still
* transitions 0 → 1 (ha.c:1714-1715 documents exactly this). The
* hooks_registered 0 → 1 transition is therefore the clean, observable
* contract pin on the zeroed-hooksys fixture; the three fns are NOT observable
* in g_lc.hooksys here (the zeroed registry rejects the add), so we pin the
* flag transition per the task wording rather than the slot contents. */
static void test_promotion_registers_persist_hooks(void)
{
#ifdef XCE_TESTING
/* Arrange: a partial-but-real cluster (same fixture as cases 4-7).
* n_peers value is not material to the hook contract. */
(void)lifecycle_cluster_setup("hooks", /*n_peers=*/3, /*n_standbys=*/2,
/*appended_lsn=*/5, /*n_vcpus=*/8);
TEST_ASSERT_EQUAL_INT_MESSAGE(0, g_lc.ha.hooks_registered,
"fixture precondition: hooks must be unregistered before promotion");
/* Act: drive the SHARED promotion sequence both roads route through (2.2). */
TEST_ASSERT_EQUAL_INT(0, ha_promote_to_primary(&g_lc));
/* Assert: the shared promotion sequence registered the persist hooks —
* hooks_registered transitioned 0 → 1 (Req 2.1). Contract/regression pin:
* green today (ha.c:1729) and must remain green after task 2.2 augments the
* sequence with the noop+gate tail. */
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_lc.ha.hooks_registered,
"the shared promotion sequence (ha_promote_to_primary) must register "
"the persist hooks (hooks_registered 0→1, Req 2.1) — contract pin, "
"must stay green through task 2.2");
lifecycle_cluster_teardown();
printf("test_promotion_registers_persist_hooks: PASS\n");
#else
printf("test_promotion_registers_persist_hooks: SKIP (non-test build)\n");
#endif
}
/* ── Case 4: NO WATCHDOG — promotion arms no delaymgr task (Req 1.5, 2.3).
*
* The shared promotion sequence must never arm a wall-clock watchdog (the
* deleted committing_timeout_fn). Asserted via the delaymgr task-count seam:
* the count is unchanged across ha_promote_to_primary.
*
* Status note (RED vs already-GREEN): the force-promotion watchdog is armed
* ONLY inside raft_cb_become_leader's `if (c)` block (ha.c:611-619), and that
* block has NO real-cluster unit seam (raft_cb_become_leader is static; its
* only shim forces c == NULL). ha_promote_to_primary — the road this case
* exercises — never armed a watchdog, so this case is ALREADY GREEN today and
* stands as a regression-pin guaranteeing task 2.2's shared sequence does not
* introduce one. The watchdog REMOVAL from raft_cb_become_leader is covered
* at integration scope (task 4.x deletes committing_timeout_fn and the
* test_ha_evaluate_committing committing-timeout cases). */
static void test_promotion_arms_no_delaymgr_watchdog(void)
{
#ifdef XCE_TESTING
/* Arrange: 3-member real cluster, delaymgr started so an armed watchdog
* (if any) would enqueue; heartbeat stays skipped because the static
* one-shot arm + the role no-op keep it from stacking a task here. */
(void)lifecycle_cluster_setup("nowd", /*n_peers=*/3, /*n_standbys=*/2,
/*appended_lsn=*/5, /*n_vcpus=*/8);
int before = delaymgr_task_count(&g_lc.delaymgr);
/* Act: drive the shared promotion sequence */
TEST_ASSERT_EQUAL_INT(0, ha_promote_to_primary(&g_lc));
/* Assert: no wall-clock task was armed by promotion (Req 1.5, 2.3) */
int after = delaymgr_task_count(&g_lc.delaymgr);
TEST_ASSERT_EQUAL_INT_MESSAGE(before, after,
"promotion must arm NO delaymgr watchdog (Req 1.5, 2.3) — "
"the delaymgr task count must be unchanged across promotion");
/* And the road still landed in CTRL_PRIMARY directly (Req 1.1) */
TEST_ASSERT_EQUAL_INT_MESSAGE((int)CTRL_PRIMARY,
(int)atomic_load_explicit(&g_lc.ha.role, memory_order_acquire),
"shared promotion sequence must reach CTRL_PRIMARY directly");
lifecycle_cluster_teardown();
printf("test_promotion_arms_no_delaymgr_watchdog: PASS "
"(delaymgr tasks before=%d after=%d)\n", before, after);
#else
printf("test_promotion_arms_no_delaymgr_watchdog: SKIP (non-test build)\n");
#endif
}
/* ── Case 5: OWN-TERM GATE (real engine, n_peers=3, stub registry) —
* promotion must append a WAL_NOOP at lsn=N, register it RETAIN, and set the
* commit engine gate to N (commit_set_writable_lsn(N)). The behavioral
* consequence: a WAIT for an entry M < N does NOT complete while ACKs cover M
* but not N, and completes only once committed_lsn >= N (Req 2.1).
*
* RED today: ha_promote_to_primary never touches c->commit, so writable_lsn
* stays 0 (no gate) and the WAIT on M completes the instant M commits. Two
* RED carriers are pinned: (a) the gate field writable_lsn == noop_lsn after
* promotion (today it is 0); (b) the behavioral block-until-gate. */
static void test_promotion_appends_noop_and_arms_own_term_gate(void)
{
#ifdef XCE_TESTING
/* Arrange: 3-member cluster. Prime so the promotion no-op lands at lsn
* N=3 (current_lsn-1). Entries 1..3 are appended into the engine so a
* waiter on entry M=1 has a target below the gate. */
const uint64_t noop_lsn = 3;
/* persist_wal_async post-increments current_lsn (persist.c:318), and
* production derives noop_lsn = current_lsn - 1. The fixture primes
* current_lsn = appended_lsn + 1, so passing appended_lsn = noop_lsn - 1
* lands the no-op at exactly lsn = noop_lsn. */
(void)lifecycle_cluster_setup("gate", /*n_peers=*/3, /*n_standbys=*/2,
/*appended_lsn=*/noop_lsn - 1, /*n_vcpus=*/8);
/* Register pre-existing entries strictly BELOW the gate (1..noop_lsn-1);
* the no-op itself occupies noop_lsn. A waiter on M=1 still has a
* target below the gate. */
for (uint64_t lsn = 1; lsn < noop_lsn; lsn++)
(void)commit_submit(&g_lc.commit, lsn, COMMIT_MODE_RETAIN, 0);
/* Act: drive the shared promotion sequence (appends WAL_NOOP + gate). */
TEST_ASSERT_EQUAL_INT(0, ha_promote_to_primary(&g_lc));
/* Assert (gate field): writable_lsn must be the no-op's LSN (Req 2.1).
* RED today: promotion does not touch the engine → writable_lsn == 0. */
TEST_ASSERT_EQUAL_UINT64_MESSAGE(noop_lsn, g_lc.commit.writable_lsn,
"promotion must set the own-term gate writable_lsn = noop_lsn (Req 2.1); "
"current promotion never appends a no-op nor sets the gate — RED");
/* Assert (WAL): a header-only WAL_NOOP must have been appended. RED
* today: nothing is appended, so current_lsn never advanced past the
* primed watermark. */
TEST_ASSERT_EQUAL_UINT64_MESSAGE(noop_lsn + 1, g_lc.persist.current_lsn,
"promotion must append exactly one WAL_NOOP (current_lsn advances to "
"noop_lsn+1, Req 2.1) — current promotion appends nothing — RED");
/* Assert (behavioral gate): a WAIT on entry M=1 < gate must NOT complete
* once entry 1 commits — it must block until committed_lsn >= noop_lsn. */
static lc_wait_arg_t warg;
memset(&warg, 0, sizeof(warg));
warg.lsn = 1; warg.timeout_ms = 3000; warg.rc = -9999;
atomic_store(&warg.done, 0);
pthread_t wtid;
TEST_ASSERT_EQUAL_INT(0,
pthread_create(&wtid, NULL, lifecycle_wait_thread, &warg));
/* Entry 1 gains majority (self + sb0); gate (noop_lsn) still unmet. */
lifecycle_sleep_ms(50);
lifecycle_standby_ack(0, 1);
lifecycle_sleep_ms(150);
int done_after_entry1 = atomic_load(&warg.done);
/* Gate opens: a covering ACK advances committed_lsn to noop_lsn. */
lifecycle_standby_ack(0, noop_lsn);
pthread_join(wtid, NULL);
/* RED today: with writable_lsn == 0 the waiter completed at the first
* ACK (done_after_entry1 == 1). Contract: it must still be blocked. */
TEST_ASSERT_EQUAL_INT_MESSAGE(0, done_after_entry1,
"WAIT on entry 1 must NOT complete while committed_lsn < noop_lsn "
"(own-term gate, Req 2.1); unset gate completes it early — RED");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, warg.rc,
"WAIT must succeed once committed_lsn reaches the own-term gate");
TEST_ASSERT_TRUE_MESSAGE(warg.elapsed_ms >= 180,
"WAIT must block past its own entry's commit until the gate opens");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(noop_lsn, lifecycle_committed(),
"committed_lsn must reach noop_lsn once the gate-covering ACK arrives");
lifecycle_cluster_teardown();
printf("test_promotion_appends_noop_and_arms_own_term_gate: PASS\n");
#else
printf("test_promotion_appends_noop_and_arms_own_term_gate: SKIP (non-test build)\n");
#endif
}
/* ── Case 6: NO THIRD PATH (gate unmet) — with the own-term gate unmet, a
* state-changing WAIT submission resolves ONLY as barrier success, -ETIMEDOUT,
* or -XCE_ERR_NOT_READY — never via any promotion-side completion (Req 2.2).
*
* Here the gate (= noop_lsn) is never satisfied (no covering ACK), so the only
* legitimate outcome is -ETIMEDOUT (the entry cannot reach majority within the
* deadline; the gate is a blocking predicate, not an admission fast-reject).
*
* RED today: promotion sets no gate, so a WAIT on the no-op's own entry can be
* "confirmed" by an ACK covering only that entry — a confirmation the
* own-term rule must withhold until majority of the new-term no-op. The
* carrier: with the contract gate at noop_lsn, an ACK covering ONLY a lower
* entry must leave the WAIT pending → -ETIMEDOUT; today (gate 0) the same ACK
* confirms early (rc == 0). */
static void test_pre_own_term_wait_has_no_third_path(void)
{
#ifdef XCE_TESTING
const uint64_t noop_lsn = 3;
/* appended_lsn = noop_lsn - 1 lands the no-op at lsn = noop_lsn (see
* case 5 comment); entries 1..noop_lsn-1 sit strictly below the gate. */
(void)lifecycle_cluster_setup("nothird", /*n_peers=*/3, /*n_standbys=*/2,
/*appended_lsn=*/noop_lsn - 1, /*n_vcpus=*/8);
for (uint64_t lsn = 1; lsn < noop_lsn; lsn++)
(void)commit_submit(&g_lc.commit, lsn, COMMIT_MODE_RETAIN, 0);
/* Act: promotion (must arm gate = noop_lsn). */
TEST_ASSERT_EQUAL_INT(0, ha_promote_to_primary(&g_lc));
/* A WAIT on entry 1 with a short deadline; only entry 1 ever gets a
* covering ACK — the gate (noop_lsn=3) is never satisfied. */
static lc_wait_arg_t warg;
memset(&warg, 0, sizeof(warg));
warg.lsn = 1; warg.timeout_ms = 250; warg.rc = -9999;
atomic_store(&warg.done, 0);
pthread_t wtid;
TEST_ASSERT_EQUAL_INT(0,
pthread_create(&wtid, NULL, lifecycle_wait_thread, &warg));
lifecycle_standby_ack(0, 1); /* entry 1 committed; gate (3) unmet */
pthread_join(wtid, NULL);
/* Assert: the ONLY admissible outcomes are barrier success (0) on a real
* gate-covering majority [not provided here], -ETIMEDOUT, or NOT_READY.
* With the gate unmet and no covering ACK, the contract outcome is
* -ETIMEDOUT. RED today: writable_lsn==0 admits early success (rc==0),
* which is the forbidden "third path" — a confirmation without the
* own-term entry's majority. */
TEST_ASSERT_TRUE_MESSAGE(
warg.rc == -ETIMEDOUT || warg.rc == -XCE_ERR_NOT_READY,
"pre-own-term WAIT must resolve only as -ETIMEDOUT or -XCE_ERR_NOT_READY "
"while the own-term gate is unmet (Req 2.2); an unset gate confirms it "
"early (rc==0) — the forbidden third path — RED");
TEST_ASSERT_EQUAL_INT_MESSAGE(-ETIMEDOUT, warg.rc,
"gate-unmet WAIT with no covering majority must time out (Req 2.2, 2.3)");
TEST_ASSERT_TRUE_MESSAGE(warg.elapsed_ms >= 200,
"an unmet own-term gate must BLOCK to the deadline, not fast-reject");
lifecycle_cluster_teardown();
printf("test_pre_own_term_wait_has_no_third_path: PASS\n");
#else
printf("test_pre_own_term_wait_has_no_third_path: SKIP (non-test build)\n");
#endif
}
/* ── Case 7: STANDALONE (n_peers=1) — promotion's no-op self-commits at
* registration, so the own-term gate opens immediately and a WAIT returns 0
* as before (Req 2.1, umbrella 6.3 / 7.5: standalone latency unchanged).
*
* Status note: REGRESSION-PIN. On the current tree there is no gate
* (writable_lsn stays 0), and a single-member WAIT already returns 0
* immediately (self is its own majority). So this case is EXPECTED GREEN
* today; it guards the contract that arming the gate in the n_peers=1 path
* (task 2.2) must not regress standalone promotion into a blocking wait.
* The writable_lsn-field assertion below WILL flip from already-satisfied
* behavior (WAIT==0) to additionally pinning writable_lsn==noop_lsn once 2.2
* lands; today writable_lsn==0 makes that one field assertion RED while the
* behavioral WAIT==0 stays GREEN — labelled inline. */
static void test_standalone_promotion_gate_opens_immediately(void)
{
#ifdef XCE_TESTING
const uint64_t noop_lsn = 1; /* standalone: noop is the first entry */
/* appended_lsn = 0 → current_lsn primed to 1 → the no-op lands at lsn=1
* (= noop_lsn). No entries exist below the no-op, so there is no
* pre-existing RETAIN submit. */
(void)lifecycle_cluster_setup("solo", /*n_peers=*/1, /*n_standbys=*/0,
/*appended_lsn=*/0, /*n_vcpus=*/8);
/* Act */
TEST_ASSERT_EQUAL_INT(0, ha_promote_to_primary(&g_lc));
/* Behavioral (EXPECTED GREEN today and after 2.2): single-member WAIT on
* the no-op entry returns 0 without blocking — self is the majority and
* the gate (if set) is self-satisfied at registration. */
uint64_t t0 = lifecycle_mono_ms();
int rc = commit_submit(&g_lc.commit, noop_lsn, COMMIT_MODE_WAIT, 5000);
uint64_t elapsed = lifecycle_mono_ms() - t0;
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc,
"standalone WAIT on the own-term entry must return 0 (self-quorum)");
TEST_ASSERT_TRUE_MESSAGE(elapsed < 1000,
"standalone promotion must not block on the own-term gate (6.3/7.5)");
/* Contract field (RED today: writable_lsn==0; GREEN after task 2.2 sets
* the gate to the self-committed no-op LSN). */
TEST_ASSERT_EQUAL_UINT64_MESSAGE(noop_lsn, g_lc.commit.writable_lsn,
"standalone promotion must still arm the own-term gate at noop_lsn "
"(Req 2.1); current promotion sets no gate — RED for this field");
lifecycle_cluster_teardown();
printf("test_standalone_promotion_gate_opens_immediately: PASS\n");
#else
printf("test_standalone_promotion_gate_opens_immediately: SKIP (non-test build)\n");
#endif
}
/* ── Case 8: LEGACY/RAFT PROMOTION SYMMETRY (ha-single-leader-role task 3.1,
* Req 1.1, 1.5; design §Legacy Path Symmetry). Both entry points to PRIMARY
* must produce IDENTICAL observable state so test-driven and raft-driven
* promotion cannot diverge:
*
* (1) raft road — raft_cb_become_leader_for_test (the XCE_TESTING shim, which
* forces ha->raft_c == NULL): only the c-independent state is observable
* on this seam (role == CTRL_PRIMARY). The hook/gate tail is c!=NULL-
* guarded and skipped here — that half of the contract is pinned on the
* real-cluster road below (matching cases 3/4/5/7, which drive the SAME
* shared helpers ha_register_persist_hooks / ha_arm_own_term_gate).
*
* (2) legacy road — ha_elect_evaluate_and_signal on the real g_lc cluster
* (ctx->c = &g_lc, single-node quorum win): drives the full shared
* sequence. Must land role == CTRL_PRIMARY, hooks_registered == 1,
* writable_lsn == noop_lsn (own-term gate armed), and arm NO delaymgr
* watchdog — byte-for-byte the state ha_promote_to_primary produces.
*
* 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. */
static void test_legacy_and_raft_promotion_are_symmetric(void)
{
#ifdef XCE_TESTING
/* ── Road 1: raft shim (c == NULL) — role contract only ──────────────── */
ha_state_t ha_raft;
ha_config_t cfg = lifecycle_ha_cfg();
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha_raft, &cfg, 0));
ha_raft.phase2_done = 1;
atomic_store_explicit(&ha_raft.role, (int32_t)CTRL_ELECTING,
memory_order_release);
raft_cb_become_leader_for_test(&ha_raft, /*term=*/7);
ctrl_role_t raft_role =
(ctrl_role_t)atomic_load_explicit(&ha_raft.role, memory_order_acquire);
TEST_ASSERT_EQUAL_INT_MESSAGE((int)CTRL_PRIMARY, (int)raft_role,
"raft road must land DIRECTLY in CTRL_PRIMARY (Req 1.1)");
ha_fini(&ha_raft);
/* ── Road 2: legacy ha_elect_evaluate_and_signal on a real cluster ───── */
/* persist_wal_async appends at current_lsn then post-increments, and
* ha_arm_own_term_gate derives noop_lsn = current_lsn - 1; the fixture
* primes current_lsn = appended_lsn + 1, so passing appended_lsn =
* noop_lsn - 1 lands the own-term no-op at exactly lsn = noop_lsn
* (same idiom as case 5). */
const uint64_t noop_lsn = 9;
(void)lifecycle_cluster_setup("sym", /*n_peers=*/1,
/*n_standbys=*/0,
/*appended_lsn=*/noop_lsn - 1,
/*n_vcpus=*/8);
int before = delaymgr_task_count(&g_lc.delaymgr);
TEST_ASSERT_EQUAL_INT_MESSAGE(0, g_lc.ha.hooks_registered,
"fixture precondition: hooks must be unregistered before promotion");
/* Single-node quorum win: n_peers==0 shortcircuit (votes[] empty),
* votes_granted=1 satisfies quorum=1. ctx->c = &g_lc so the shared
* promotion sequence runs against the real cluster. */
ha_elect_ctx_t *ctx = calloc(1, sizeof(*ctx));
TEST_ASSERT_NOT_NULL_MESSAGE(ctx, "ctx calloc failed");
ctx->n_peers = 0;
ctx->votes_granted = 1;
ctx->n_replied = 0;
ctx->result = -1;
ctx->done_posted = 0;
ctx->c = &g_lc;
pthread_mutex_init(&ctx->lock, NULL);
sem_init(&ctx->done, 0, 0);
ctrl_role_t legacy_result = ha_elect_evaluate_and_signal(&g_lc.ha, ctx);
/* Symmetry axis 1: the legacy road reaches PRIMARY (matches the raft road). */
TEST_ASSERT_EQUAL_INT_MESSAGE((int)CTRL_PRIMARY, (int)legacy_result,
"legacy evaluate must RESULT in CTRL_PRIMARY (Req 1.1)");
ctrl_role_t legacy_role =
(ctrl_role_t)atomic_load_explicit(&g_lc.ha.role, memory_order_acquire);
TEST_ASSERT_EQUAL_INT_MESSAGE((int)raft_role, (int)legacy_role,
"legacy and raft promotion must land in the SAME role (CTRL_PRIMARY)");
/* Symmetry axis 2: persist hooks registered by the shared sequence. */
TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_lc.ha.hooks_registered,
"legacy road must register persist hooks via the shared sequence (Req 2.1)");
/* Symmetry axis 3: own-term gate armed at the no-op LSN. */
TEST_ASSERT_EQUAL_UINT64_MESSAGE(noop_lsn, g_lc.commit.writable_lsn,
"legacy road must arm the own-term gate at noop_lsn (Req 1.4, 2.1) — "
"identical to the raft road's shared tail");
/* Symmetry axis 4: NO committing-timeout watchdog armed (Req 1.5). */
int after = delaymgr_task_count(&g_lc.delaymgr);
TEST_ASSERT_EQUAL_INT_MESSAGE(before, after,
"legacy road must arm NO delaymgr watchdog (Req 1.5) — symmetric with "
"the raft road; the committing-timeout arm is deleted from this path");
pthread_mutex_destroy(&ctx->lock);
sem_destroy(&ctx->done);
free(ctx);
lifecycle_cluster_teardown();
printf("test_legacy_and_raft_promotion_are_symmetric: PASS "
"(role=%d both roads; hooks=1; writable_lsn=%" PRIu64
"; watchdog tasks before=%d after=%d)\n",
(int)legacy_role, g_lc.commit.writable_lsn, before, after);
#else
printf("test_legacy_and_raft_promotion_are_symmetric: SKIP (non-test build)\n");
#endif
}
/* ------------------------------------------------------------------ */
/* Entry point (registered in controller_test.c / meson.build) */
/* ------------------------------------------------------------------ */
void test_ha_role_lifecycle(void)
{
/* Task 1.1 — WAL_NOOP no-op entry (Req 2.1) */
RUN_TEST(test_noop_append_is_header_only_and_broadcast);
RUN_TEST(test_noop_apply_advances_lsn_no_entity_effect);
RUN_TEST(test_noop_boot_replay_clean_no_effect);
RUN_TEST(test_noop_classify_is_retain);
/* Task 2.1 — DIRECT promotion + own-term gate (Reqs 1.1,1.3,1.4,1.5,2.1,2.2,2.3) */
RUN_TEST(test_promotion_lands_directly_in_primary); /* case 1 */
RUN_TEST(test_promotion_no_redirect_from_live_primary); /* case 2 */
RUN_TEST(test_promotion_registers_persist_hooks); /* case 3 */
RUN_TEST(test_promotion_arms_no_delaymgr_watchdog); /* case 4 */
RUN_TEST(test_promotion_appends_noop_and_arms_own_term_gate); /* case 5 */
RUN_TEST(test_pre_own_term_wait_has_no_third_path); /* case 6 */
RUN_TEST(test_standalone_promotion_gate_opens_immediately); /* case 7 */
/* Task 3.1 — legacy/raft promotion symmetry (Req 1.1, 1.5) */
RUN_TEST(test_legacy_and_raft_promotion_are_symmetric); /* case 8 */
}