diff --git a/.kiro/specs/ha-single-leader-role/tasks.md b/.kiro/specs/ha-single-leader-role/tasks.md index c43caa6a..69fc5deb 100644 --- a/.kiro/specs/ha-single-leader-role/tasks.md +++ b/.kiro/specs/ha-single-leader-role/tasks.md @@ -5,7 +5,7 @@ > Verify exact line numbers with grep before editing — research.md §3 has the 2026-06-11 verified anchor table, but barrier-rollout landing first WILL shift ha.c line numbers. - [ ] 1. WAL_NOOP entry type (additive foundation) -- [ ] 1.1 Write RED tests for the no-op entry +- [x] 1.1 Write RED tests for the no-op entry - Create `testsuite/test_ha_role_lifecycle.c` (XCE_TESTING pattern, AAA structure) and register it in `controller_unit_test_src` (src/controller/meson.build:74-131); first cases cover the entry type only - Tests: `persist_wal_async(p, WAL_NOOP, NULL, 0)` appends a header-only entry (payload_len 0) and broadcasts it (stub replicator observes the entry); `apply_wal_entry` on a WAL_NOOP advances `current_lsn` past the entry's LSN and touches no entity state (jobmgr/queue/worker untouched); boot `replay_wal` over a segment containing WAL_NOOP replays cleanly with the same no-effect guarantee; `wal_classify(WAL_NOOP, 0) == WAL_MODE_RETAIN` (keeps barrier-rollout's totality test green) - Observable completion: build succeeds with the new test file; all new cases FAIL (red) — `WAL_NOOP` does not exist yet diff --git a/src/controller/meson.build b/src/controller/meson.build index 87433af6..efe4d6bc 100644 --- a/src/controller/meson.build +++ b/src/controller/meson.build @@ -89,6 +89,7 @@ controller_unit_test_src = [ 'testsuite/test_e2e_gc.c', 'testsuite/test_e2e_capnp.c', 'testsuite/test_ha_role.c', + 'testsuite/test_ha_role_lifecycle.c', 'testsuite/test_ha_cluster.c', 'testsuite/test_ha_redirect.c', 'testsuite/test_p1_integration.c', diff --git a/src/controller/testsuite/controller_test.c b/src/controller/testsuite/controller_test.c index a41e6edf..18926e2f 100644 --- a/src/controller/testsuite/controller_test.c +++ b/src/controller/testsuite/controller_test.c @@ -20,6 +20,7 @@ extern void test_e2e_hooks(void); extern void test_e2e_gc(void); extern void test_e2e_capnp(void); extern void test_ha_role(void); +extern void test_ha_role_lifecycle(void); extern void test_ha_cluster(void); extern void test_ha_redirect(void); extern void test_p1_integration(void); @@ -142,6 +143,7 @@ int main(int argc, char* argv[]) TEST(test_e2e_gc); TEST(test_e2e_capnp); TEST(test_ha_role); + TEST(test_ha_role_lifecycle); TEST(test_ha_cluster); TEST(test_ha_redirect); TEST(test_p1_integration); diff --git a/src/controller/testsuite/test_ha_role_lifecycle.c b/src/controller/testsuite/test_ha_role_lifecycle.c new file mode 100644 index 00000000..049c1d5c --- /dev/null +++ b/src/controller/testsuite/test_ha_role_lifecycle.c @@ -0,0 +1,440 @@ +#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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "config.h" +#include "persist.h" + +/* 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 + +/* ------------------------------------------------------------------ */ +/* 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"); +} + +/* ------------------------------------------------------------------ */ +/* 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); +}