test(ha-commit-barrier-rollout): RED tests for classification totality (task 1.1)

New test_commit_barrier_rollout.c: per-type rows matching the VERIFIED
design table (STOP/RESUME/UPDATE/FAIL RETAIN per supersession), 13-value
totality sweep, fork/SIGABRT unknown-type fail-fast, fact-hook retention
contract against real persist+engine. wal_commit_mode_t + wal_classify
declared in persist.h; inert sentinel stub. 8/8 RED; suite baseline
zero preserved. Finding: RETAIN is registration-free (commit.c:233-239)
— observable contract documented in Implementation Notes.
This commit is contained in:
Rongsong Shen 2026-06-12 04:13:22 +00:00
parent 8fe2f1f96c
commit fec9519960
9 changed files with 568 additions and 92 deletions

View file

@ -121,6 +121,7 @@ controller_unit_test_src = [
'testsuite/test_ha_rdhup_integration.c',
'testsuite/test_ha_liveness.c',
'testsuite/test_commit_engine.c',
'testsuite/test_commit_barrier_rollout.c',
'commit.c',
'startup_validation.c',
'worker.c',

View file

@ -1118,6 +1118,18 @@ static uint32_t detail_to_wal_type(int detail)
}
}
/* Write-path commit classification — INERT RED-phase stub
* (ha-commit-barrier-rollout task 1.1; sanctioned scaffolding so the RED
* tests fail on assertions, not at link time). Returns an out-of-enum
* sentinel: classifies nothing yet. The verified classification table
* (design.md §Write-Path Classification) is implemented in task 1.2. */
wal_commit_mode_t wal_classify(uint32_t wal_type, int detail)
{
(void)wal_type;
(void)detail;
return (wal_commit_mode_t)-1; /* invalid sentinel — task 1.2 replaces */
}
/* P0 payload: just the detail value (4 bytes).
* Full Cap'n Proto serialization is wired in Task 15. */

View file

@ -51,6 +51,7 @@ extern void test_ha_connection_reuse(void);
extern void test_ha_rdhup_integration(void);
extern void test_ha_liveness(void);
extern void test_commit_engine(void);
extern void test_commit_barrier_rollout(void);
void setUp(void) { return; }
@ -171,6 +172,7 @@ int main(int argc, char* argv[])
TEST(test_ha_rdhup_integration);
TEST(test_ha_liveness);
TEST(test_commit_engine);
TEST(test_commit_barrier_rollout);
return UNITY_END();
}

View file

@ -0,0 +1,426 @@
/*
* test_commit_barrier_rollout.c — RED-phase tests for write-path commit
* classification (ha-commit-barrier-rollout task 1.1).
*
* Covers Requirements 2.1 (every state-changing WAL type explicitly
* classified WAIT/RETAIN/LOCAL), 2.2 (unclassified type fails fast under
* XCE_TESTING), and 3.1 (fact-shaped appends are retained — appended,
* non-blocking, pending purely by lsn > committed_lsn, committed later
* by covering ACKs without redelivery).
*
* The classification table under test is the VERIFIED table from
* .kiro/specs/ha-commit-barrier-rollout/design.md §Write-Path
* Classification (it supersedes the umbrella draft table — research.md §2
* records the call-site-verified deviations: WAL_JOB_STOP/RESUME are
* fact-origin only; WAL_JOB_UPDATE/WAL_JOB_FAIL are dual-origin and
* classify RETAIN at the seam, with the request-shaped guarantee realized
* by the handler barrier in do_jobcontrol_op — tasks 2.x/3.x):
*
* WAL_JOB_SUBMIT request WAIT
* WAL_QUEUE_CTRL request WAIT
* WAL_WORKER_CTRL + JOIN/UPDATE request WAIT
* WAL_WORKER_CTRL + DEAD fact RETAIN
* WAL_JOB_START/FINISH/FAIL/STOP/
* RESUME/UPDATE/RUSAGE/CKPT/
* PREASON fact RETAIN
* WAL_CHECKPOINT local LOCAL (never replicated)
* unknown — XCE_TESTING assert
*
* These cases FAIL (RED) against the task-1.1 inert wal_classify stub in
* persist.c (returns an invalid sentinel mode) and go green with the
* task-1.2 implementation.
*
* Honest-observable note for the RETAIN-registration case (task brief):
* commit_submit(COMMIT_MODE_RETAIN) is registration-free bookkeeping
* (commit.c — returns 0 immediately; commit state is derived entirely
* from registry watermarks at ACK time), so RETAIN routing adds NO
* engine-side observable distinct from the append itself. The fact-hook
* test therefore asserts the full observable retention contract —
* classified RETAIN (the RED carrier), hook returns only the local-append
* rc without blocking, entry in the WAL with lsn > committed_lsn (pending,
* never confirmed early), and committed_lsn advancing over the entry once
* a covering ACK arrives (no redelivery) — rather than inventing a fake
* registration observable.
*
* Fixture patterns from test_commit_engine.c (stub persist_t + replicator
* with dummy endpoints; real tmpdir persist_t for the hook-driven append;
* repl_broadcast_send_hook to drop broadcasts) and test_persist_hooks.c
* (variadic trampoline driving persist_job_hook_fun with a real va_list).
*
* Requirements: 2.1, 2.2, 3.1
*/
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdarg.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
#include <unity/unity.h>
#include "xce/cluster_def.h" /* HOOK_DETAIL_*, XCE_CONTROLLER_HOOK_JOB */
#include "xce/types.h" /* job_t */
#include "commit.h"
#include "hook.h" /* HOOK_AFTER_EVENT */
#include "persist.h" /* wal_commit_mode_t, wal_classify (task 1.1 decls) */
#include "repl.h"
#include "typemap.h" /* wal_catchup_entry_t (send hook signature) */
#include "wal_types.h"
/* Test-injectable send hook (defined in repl.c under XCE_TESTING) — keeps
* repl_broadcast from dereferencing the dummy endpoints. */
extern int (*repl_broadcast_send_hook)(void *ep,
wal_catchup_entry_t **entries,
int n_entries);
static int cbr_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 */
}
/* ── Shared helpers ──────────────────────────────────────────────────────── */
static uint64_t mono_ms(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (uint64_t)ts.tv_sec * 1000u + (uint64_t)ts.tv_nsec / 1000000u;
}
/* Valid-mode predicate for the totality sweep (Req 2.1). */
static int is_valid_mode(wal_commit_mode_t m)
{
return m == WAL_MODE_WAIT || m == WAL_MODE_RETAIN || m == WAL_MODE_LOCAL;
}
/* ── Classification table cases (Req 2.1) ─────────────────────────────────
* All RED against the inert stub: it returns an out-of-enum sentinel, so
* every equality below fails on assertion, not linking. */
static void test_classify_job_submit_is_wait(void)
{
/* Arrange / Act */
wal_commit_mode_t mode = wal_classify(WAL_JOB_SUBMIT, HOOK_DETAIL_JOB_NEW);
/* Assert: client job submit is request-shaped — commit barrier before
* the requester reply (reference impl keeps the in-hook sync path) */
TEST_ASSERT_EQUAL_INT_MESSAGE(WAL_MODE_WAIT, mode,
"WAL_JOB_SUBMIT must classify WAIT (request-shaped)");
printf("test_classify_job_submit_is_wait: PASS\n");
}
static void test_classify_queue_ctrl_is_wait(void)
{
/* Arrange / Act */
wal_commit_mode_t mode = wal_classify(WAL_QUEUE_CTRL,
HOOK_DETAIL_QUEUE_UPDATE);
/* Assert: queue control is client-request-shaped */
TEST_ASSERT_EQUAL_INT_MESSAGE(WAL_MODE_WAIT, mode,
"WAL_QUEUE_CTRL must classify WAIT (request-shaped)");
printf("test_classify_queue_ctrl_is_wait: PASS\n");
}
static void test_classify_worker_ctrl_detail_split(void)
{
/* Arrange / Act: WAL_WORKER_CTRL is the only detail-disambiguated type */
wal_commit_mode_t join_mode =
wal_classify(WAL_WORKER_CTRL, HOOK_DETAIL_WORKER_JOIN);
wal_commit_mode_t update_mode =
wal_classify(WAL_WORKER_CTRL, HOOK_DETAIL_WORKER_UPDATE);
wal_commit_mode_t dead_mode =
wal_classify(WAL_WORKER_CTRL, HOOK_DETAIL_WORKER_DEAD);
/* Assert: join/update have a requester awaiting confirmation (WAIT);
* worker death is controller-detected — a reported fact (RETAIN) */
TEST_ASSERT_EQUAL_INT_MESSAGE(WAL_MODE_WAIT, join_mode,
"WAL_WORKER_CTRL + WORKER_JOIN must classify WAIT");
TEST_ASSERT_EQUAL_INT_MESSAGE(WAL_MODE_WAIT, update_mode,
"WAL_WORKER_CTRL + WORKER_UPDATE must classify WAIT");
TEST_ASSERT_EQUAL_INT_MESSAGE(WAL_MODE_RETAIN, dead_mode,
"WAL_WORKER_CTRL + WORKER_DEAD must classify RETAIN (fact)");
printf("test_classify_worker_ctrl_detail_split: PASS\n");
}
static void test_classify_all_job_state_types_are_retain(void)
{
/* Arrange: every WAL_JOB_* state-transition type with its natural hook
* detail (verified table: all fact-shaped — STOP/RESUME are fact-origin
* only; FAIL/UPDATE dual-origin, request side covered by the handler
* barrier in later tasks) */
static const struct {
uint32_t wal_type;
int detail;
const char *name;
} facts[] = {
{ WAL_JOB_START, HOOK_DETAIL_JOB_START, "WAL_JOB_START" },
{ WAL_JOB_FINISH, HOOK_DETAIL_JOB_EXIT, "WAL_JOB_FINISH" },
{ WAL_JOB_FAIL, HOOK_DETAIL_JOB_FAIL, "WAL_JOB_FAIL" },
{ WAL_JOB_STOP, HOOK_DETAIL_JOB_STOP, "WAL_JOB_STOP" },
{ WAL_JOB_RESUME, HOOK_DETAIL_JOB_RESUME, "WAL_JOB_RESUME" },
{ WAL_JOB_UPDATE, HOOK_DETAIL_JOB_UPDATE, "WAL_JOB_UPDATE" },
{ WAL_JOB_RUSAGE, HOOK_DETAIL_JOB_RUSAGE_UPDATE, "WAL_JOB_RUSAGE" },
{ WAL_JOB_CKPT, HOOK_DETAIL_JOB_CKPT, "WAL_JOB_CKPT" },
{ WAL_JOB_PREASON, HOOK_DETAIL_SETPREASON, "WAL_JOB_PREASON" },
};
size_t i;
/* Act + Assert: each is a reported fact — retained until committed */
for (i = 0; i < sizeof(facts) / sizeof(facts[0]); i++) {
char msg[96];
snprintf(msg, sizeof(msg), "%s must classify RETAIN (fact-shaped)",
facts[i].name);
TEST_ASSERT_EQUAL_INT_MESSAGE(
WAL_MODE_RETAIN, wal_classify(facts[i].wal_type, facts[i].detail),
msg);
}
printf("test_classify_all_job_state_types_are_retain: PASS\n");
}
static void test_classify_checkpoint_is_local(void)
{
/* Arrange / Act: WAL_CHECKPOINT is a local WAL marker, never replicated */
wal_commit_mode_t mode = wal_classify(WAL_CHECKPOINT, 0);
/* Assert */
TEST_ASSERT_EQUAL_INT_MESSAGE(WAL_MODE_LOCAL, mode,
"WAL_CHECKPOINT must classify LOCAL (never replicated)");
printf("test_classify_checkpoint_is_local: PASS\n");
}
static void test_classify_is_total_over_wal_entry_type(void)
{
/* Arrange: EVERY value of wal_entry_type (wal_types.h:44-58, verified
* 2026-06-11: 13 values, no WAL_NOOP yet). When a new type is added,
* this sweep plus the unknown-type guard force it to be classified. */
static const struct {
uint32_t wal_type;
int detail;
const char *name;
} all_types[] = {
{ WAL_JOB_SUBMIT, HOOK_DETAIL_JOB_NEW, "WAL_JOB_SUBMIT" },
{ WAL_JOB_START, HOOK_DETAIL_JOB_START, "WAL_JOB_START" },
{ WAL_JOB_FINISH, HOOK_DETAIL_JOB_EXIT, "WAL_JOB_FINISH" },
{ WAL_JOB_FAIL, HOOK_DETAIL_JOB_FAIL, "WAL_JOB_FAIL" },
{ WAL_JOB_STOP, HOOK_DETAIL_JOB_STOP, "WAL_JOB_STOP" },
{ WAL_JOB_RESUME, HOOK_DETAIL_JOB_RESUME, "WAL_JOB_RESUME" },
{ WAL_JOB_UPDATE, HOOK_DETAIL_JOB_UPDATE, "WAL_JOB_UPDATE" },
{ WAL_JOB_RUSAGE, HOOK_DETAIL_JOB_RUSAGE_UPDATE, "WAL_JOB_RUSAGE" },
{ WAL_JOB_CKPT, HOOK_DETAIL_JOB_CKPT, "WAL_JOB_CKPT" },
{ WAL_JOB_PREASON, HOOK_DETAIL_CLRPREASON, "WAL_JOB_PREASON" },
{ WAL_QUEUE_CTRL, HOOK_DETAIL_QUEUE_UPDATE, "WAL_QUEUE_CTRL" },
{ WAL_WORKER_CTRL, HOOK_DETAIL_WORKER_DEAD, "WAL_WORKER_CTRL" },
{ WAL_CHECKPOINT, 0, "WAL_CHECKPOINT" },
};
size_t i;
/* Act + Assert: classification is total — every enum value maps to a
* valid mode (no silent barrier bypass for any state-changing type) */
for (i = 0; i < sizeof(all_types) / sizeof(all_types[0]); i++) {
char msg[96];
snprintf(msg, sizeof(msg),
"%s must map to a valid commit mode (totality)",
all_types[i].name);
TEST_ASSERT_TRUE_MESSAGE(
is_valid_mode(wal_classify(all_types[i].wal_type,
all_types[i].detail)),
msg);
}
printf("test_classify_is_total_over_wal_entry_type: PASS\n");
}
/* ── Unknown type fails fast under XCE_TESTING (Req 2.2) ──────────────────
* The guard contract is assert(0) in testing builds (abort), production
* ERROR + RETAIN fallback. The abort is observed from a forked child so
* the test runner survives (fork pattern precedent: lib testsuite test.c).
* RED: the inert stub returns its sentinel without asserting, so the child
* exits normally and the SIGABRT assertion fails. */
static void test_classify_unknown_type_fails_fast_in_testing(void)
{
pid_t pid;
int status = 0;
/* Arrange: a WAL type far outside wal_entry_type */
const uint32_t unknown_type = 777u;
/* Act: evaluate the unknown type in a child process */
pid = fork();
TEST_ASSERT_TRUE_MESSAGE(pid >= 0, "fork must succeed");
if (pid == 0) {
/* Child: silence the expected assert/abort noise, then classify.
* Reaching _exit(0) means the fail-fast path did NOT trigger. */
int devnull = open("/dev/null", O_WRONLY);
if (devnull >= 0) {
dup2(devnull, STDERR_FILENO);
dup2(devnull, STDOUT_FILENO);
}
(void)wal_classify(unknown_type, 0);
_exit(0);
}
/* Assert: the child must die on the XCE_TESTING fail-fast path */
TEST_ASSERT_EQUAL_INT_MESSAGE(pid, waitpid(pid, &status, 0),
"waitpid must reap the classifier child");
TEST_ASSERT_TRUE_MESSAGE(
WIFSIGNALED(status) && WTERMSIG(status) == SIGABRT,
"unknown WAL type must trip the XCE_TESTING fail-fast (abort)");
printf("test_classify_unknown_type_fails_fast_in_testing: PASS\n");
}
/* ── Fact-type append through the hook seam is RETAIN-routed (Req 3.1) ────
* Drives the production persist_job_hook_fun with a worker-reported job
* finish (fact) against a real tmpdir persist_t with a wired commit engine
* (n_peers=3) and ACKs withheld. Observable retention contract:
* - wal_classify routes the type RETAIN (RED carrier — fails against the
* inert stub; the behavioral assertions below pass on unmodified
* persist.c and pin the contract task 1.2 must preserve)
* - the hook returns the local-append rc (0) WITHOUT blocking on commit
* progress (no timeout applies to facts)
* - the entry is in the WAL and pending purely by lsn > committed_lsn
* (never confirmed early)
* - a later covering ACK advances committed_lsn over the entry with no
* redelivery of the fact
* commit_submit(RETAIN) itself is registration-free (commit.c) — see the
* file header note; there is deliberately no fake "registered" observable. */
/* Variadic trampoline so the test passes typed args through a real va_list
* (pattern from test_persist_hooks.c). */
static int call_job_hook(int type, int sit, int detail, persist_t *p, ...)
{
va_list ap;
int rc;
va_start(ap, p);
rc = persist_job_hook_fun(type, sit, detail, p, ap);
va_end(ap);
return rc;
}
/* Statics survive Unity's longjmp on assertion failure. */
static persist_t *g_fp;
static commit_engine_t g_fce;
static job_t g_fact_job;
static int g_fep_a, g_fep_b; /* dummy endpoints — replicator compares pointers */
static void test_fact_hook_append_is_retained_and_nonblocking(void)
{
persist_config_t cfg;
char tmpdir[256];
uint64_t t0, elapsed_ms, fact_lsn;
int rc;
/* Arrange: real persist over a private tmpdir; sync timeout large so a
* misrouted blocking wait is detectable as elapsed time */
cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000;
cfg.sync_timeout_ms = 3000;
snprintf(tmpdir, sizeof(tmpdir), "/tmp/xce_cbr11_fact_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(tmpdir));
g_fp = calloc(1, sizeof(persist_t));
TEST_ASSERT_NOT_NULL(g_fp);
persist_set_workdir(g_fp, tmpdir, "ctrl");
TEST_ASSERT_EQUAL_INT(0, persist_init(g_fp, &cfg, NULL));
TEST_ASSERT_EQUAL_INT(0, persist_start(g_fp));
/* 3-member cluster: 2 standbys registered, engine wired, ACKs withheld */
TEST_ASSERT_TRUE(repl_add_standby(&g_fp->repl, &g_fep_a, "sb_a", 7200) >= 0);
TEST_ASSERT_TRUE(repl_add_standby(&g_fp->repl, &g_fep_b, "sb_b", 7201) >= 0);
TEST_ASSERT_EQUAL_INT(0, commit_engine_init(&g_fce, g_fp, 3, 8));
repl_set_commit_engine(&g_fp->repl, &g_fce);
repl_broadcast_send_hook = cbr_drop_send_hook;
/* Priming async append so the fact entry lands at LSN 1 (the engine
* rejects lsn == 0 — accepted interim LSN-0 edge, ha-commit-engine) */
TEST_ASSERT_EQUAL_INT(0, persist_wal_async(g_fp, WAL_JOB_UPDATE, "p", 1));
fact_lsn = g_fp->current_lsn; /* the LSN the fact append will get */
TEST_ASSERT_EQUAL_UINT64(1, fact_lsn);
memset(&g_fact_job, 0, sizeof(g_fact_job));
g_fact_job.job.jobid = 42;
g_fact_job.job.state = 0x10;
/* Act: worker-reported job finish (fact) through the production hook.
* JOB_EXIT AFTER_EVENT args: cluster_t*, xce_jobfinish_t*, job_t* */
t0 = mono_ms();
rc = call_job_hook(XCE_CONTROLLER_HOOK_JOB, HOOK_AFTER_EVENT,
HOOK_DETAIL_JOB_EXIT, g_fp,
(void *)NULL, (void *)NULL, &g_fact_job);
elapsed_ms = mono_ms() - t0;
/* Assert: the seam classifies the fact RETAIN (RED carrier — the routing
* contract the behavioral assertions below depend on) */
TEST_ASSERT_EQUAL_INT_MESSAGE(
WAL_MODE_RETAIN, wal_classify(WAL_JOB_FINISH, HOOK_DETAIL_JOB_EXIT),
"fact-type WAL_JOB_FINISH must be RETAIN-routed at the hook seam");
/* ...the hook propagated only the local-append rc, without blocking on
* commit progress (facts have no commit timeout) */
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc,
"fact hook must return the local-append rc only (no commit error)");
TEST_ASSERT_TRUE_MESSAGE(elapsed_ms < 1000,
"fact append must not block on commit progress (no timeout applies)");
/* ...the entry is in the WAL, pending purely by lsn > committed_lsn */
TEST_ASSERT_EQUAL_UINT64_MESSAGE(fact_lsn + 1, g_fp->current_lsn,
"the fact entry must be appended to the WAL");
TEST_ASSERT_TRUE_MESSAGE(
atomic_load(&g_fp->committed_lsn) < fact_lsn,
"the uncommitted fact must be pending (lsn > committed_lsn), "
"never confirmed early");
/* ...and a covering ACK later commits the retained entry — engine
* accounting advances committed_lsn over it, no redelivery needed */
repl_on_ack(&g_fp->repl, &g_fep_a, fact_lsn);
TEST_ASSERT_EQUAL_UINT64_MESSAGE(
fact_lsn, atomic_load(&g_fp->committed_lsn),
"a covering ACK must advance committed_lsn over the retained fact");
/* Teardown */
repl_broadcast_send_hook = NULL;
commit_engine_fini(&g_fce);
persist_stop(g_fp);
persist_fini(g_fp);
free(g_fp);
g_fp = NULL;
printf("test_fact_hook_append_is_retained_and_nonblocking: PASS\n");
}
/* ── Test group entry point ──────────────────────────────────────────────── */
void test_commit_barrier_rollout(void)
{
UNITY_BEGIN();
/* Task 1.1 — classification totality + RETAIN routing (Req 2.1, 2.2, 3.1) */
RUN_TEST(test_classify_job_submit_is_wait);
RUN_TEST(test_classify_queue_ctrl_is_wait);
RUN_TEST(test_classify_worker_ctrl_detail_split);
RUN_TEST(test_classify_all_job_state_types_are_retain);
RUN_TEST(test_classify_checkpoint_is_local);
RUN_TEST(test_classify_is_total_over_wal_entry_type);
RUN_TEST(test_classify_unknown_type_fails_fast_in_testing);
RUN_TEST(test_fact_hook_append_is_retained_and_nonblocking);
UNITY_END();
}