test(ha-commit-barrier-rollout): RED tests for mark_job_* guards (task 6.1)

3 RED carriers (duplicate FINISH on DONE appends a 2nd WAL entry +
overwrites state; START on terminal clobbers it; replay non-equivalent)
+ 2 preservation pins (forward postdone PDONE-merge, fail-after-finish
PERR-merge) that 6.2 must not break. Reviewer-confirmed: the
UPDATE-clears-terminal arm is production-unreachable (pinned, not
driven). 22 tests / 3 RED; census 508 distinct triples, only those 3
fail.
This commit is contained in:
Rongsong Shen 2026-06-13 04:20:05 +00:00
parent 5a2fae1d37
commit 2308389eb1
4 changed files with 365 additions and 1 deletions

View file

@ -79,7 +79,7 @@
- _Requirements: 4.1, 4.2, 4.3, 4.4_
- [ ] 6. Idempotency and ordering guards for job transitions
- [ ] 6.1 Write RED tests for mark_job_* guards
- [x] 6.1 Write RED tests for mark_job_* guards
- Tests: duplicate FINISH on a DONE job → state unchanged, INFO-level no-op, no AFTER_EVENT hook fired (no WAL entry appended); START on a DONE/EXIT job → rejected with WARN, terminal state preserved; UPDATE attempting to clear terminal bits → rejected; double application of the same transition yields state identical to single application (replay equivalence); fail-after-finish keeps the existing PERR-merge behavior (job.c:702-716 anchor); guard rejections return a code all existing callers treat as success-no-op
- Observable completion: new cases FAIL (red) against the unguarded `mark_job_start/finish/fail/update` (job.c:583/638/696/726 anchors)
- _Requirements: 5.1, 5.2, 5.3_
@ -112,6 +112,7 @@
- (1.1) wal_entry_type has 13 values (wal_types.h:44-58), no WAL_NOOP yet (lands in ha-single-leader-role, which must add a classification row + sweep entry — the totality sweep will catch it).
- (1.1) Suite baseline is ZERO failures since ha-commit-engine 6.1. detect_leaks=0 remains the harness norm (6 inventoried pre-existing leak groups).
- (1.2) CENSUS METHOD: only ~9 of 48 groups emit Unity summary blocks; the rest report per-test PASS lines only — count distinct Unity results (~465) and grep FAIL lines, never sum summary blocks. commit_submit(RETAIN) early-returns BEFORE taking ce->lock (commit.c:233-239) — registration is lock-free, safe with entity write locks held. wal_hook_append_classified is the single classified-append seam; appended_lsn read = current_lsn - 1 under p->lock (benign race, commented).
- (6.1) For 6.2 (reviewer-confirmed): the "UPDATE clears terminal bits" guard arm (Req 5.2) is UNREACHABLE in production — mark_job_update's 3 callers (job.c:400 SIGCONT clears only PSUSP|USUSP, :410 SIGSTOP ORs PSUSP, :876 postdone ORs PDONE/EXIT) never produce terminal→non-terminal. 6.2 will most naturally share ONE backward-guard predicate across START+UPDATE; the START-on-terminal RED test exercises it, and the forward-update PIN protects the one reachable terminal UPDATE (postdone PDONE-merge) from over-rejection. on_job_* wrappers return 0 unconditionally — 6.2's caller audit must keep them returning 0 (success) on guarded-out transitions; the pins lock this. Guard placement: top of each mark_job_*, under the caller-held WRITE lock, before BEFORE_EVENT. Live anchors drifted: mark_job_start:592, finish:647, fail:705 (PERR-merge :711-721), update:735.
- (5.2) **REJECTED then remediated (r1)** — two CRITICAL findings the first pass missed, both fixed: (F1) the config-ACK signal was never produced in production (repl_on_config_ack had zero callers; the standby's replconfigpushreplymsg reply was dropped on the primary) so the new authoritative barrier would have BRICKED every multi-member config upgrade — this was the unlanded half of ha-config-bootstrap task 8.1. Fixed by a replconfigpushreplymsg decode branch in repl_ack_decode_and_dispatch (ha.c) → repl_on_config_ack on the repl_add_standby-registered promoted-vote ep (production-normal path), + a defensive cluster.c OPCode_reply ingestion for inbound standbys (no-ops against unregistered slots, forward-looking). Schema unchanged (ReplConfigPushReply already carried versionTag). (F2) the 500ms barrier wait reached the iomgr epoll thread via 5 ha.c push sites. Fixed by exporting push_config_transport_only (non-blocking fan-out) for all best-effort ha.c/join.c streaming sites; push_config_to_standbys (transport+barrier) now has exactly ONE caller — do_upgrade_config_with_bytes in vtask context. LESSON: the design's "handlers run on vtask threads" claim was only true for cluster.c-dispatched opcodes, not the ha.c peer-channel push sites the design never analyzed (ha.c was outside its modified-files list). Wire-path test drives real capnp ACK bytes through the production decode branch.
- (3.3) **bug-451 DISCOVERED (reviewer-verified byte-precise, NOT fixed — boundary)**: update_job_rusages fires HOOK_DETAIL_JOB_RUSAGE_UPDATE with (cluster_t*, xce_job_rusages_t*) (job.c:1014-1015) but persist_job_hook_fun's default arm (persist.c:1355-1359) reads vararg 2 as job_t* and dereferences j->job.state at offset 56 — one past the end of the exactly-56-byte xce_job_rusages_t → 4-byte OOB read + garbage state field in every WAL_JOB_RUSAGE payload (jobid lines up by accident). Production fix candidate for task 7.1's sweep or a follow-up: persist arm needs a RUSAGE-specific va_list signature. Census now 488.
- (3.2) bug-447 FIXED via rc normalization inside the three ctrl fns (real errors/misses preserved; reviewer-verified per-fn). Semantics change shipped intentionally: idempotent no-op control actions now return 0 (was leaked 1); queue/host mutations now append WAL + replicate. Join barrier failure → error reply + on_worker_join SKIPPED (coherent reading of design; worker retries via idempotent rejoin; documented at join.c:108-113). PRE-EXISTING ref leak: job_ctrl takes reference(j) (job.c:317) but normal exit never dereferences (bug-448) — out of scope, future fix.

View file

@ -51,6 +51,7 @@
- key_find (xkvmgr_find) returns 1 when found, NOT 0 — queue_ctrl/worker_ctrl/job_ctrl leak this into rc: `if (rc == 0)` hook gates never fire on the found path (bug-447); any code reusing key_find's rc for success checks must normalise it.
- Controller test binary reply observation: no xce_reply hook exists in src/lib; observe replies on a zeroed no-iomgr endpoint_t — xce_reply endpoint_enq()s onto ep->outq; drain with endpoint_deq + raw capnp read_Request/read_RequestReply (test_commit_barrier_reply.c).
- makeJob needs job_obj_init() once per process AND xce_job_t.submit != NULL; makeString needs xstring_table_init (test_worker_join.c inits it unguarded at group entry — probe makeString for NULL before initialising to stay -t-selection safe).
- (2026-06-13, barrier-rollout 6.1) mark_job_start/finish/fail/update are STATIC in job.c — testsuite reaches them ONLY via the non-static wrappers on_job_start/on_job_finish/on_job_fail (and on_job_postdone → mark_job_update). Those wrappers `return 0` unconditionally regardless of mark_job_* rc, so the guard's "success-no-op rc" observable is asserted on the wrapper rc (== 0). No public caller produces a terminal→non-terminal UPDATE (on_job_postdone OR's PDONE = forward; job_ctrl SIGCONT only clears suspend bits) — mark_job_start (state := JOB_STAT_RUN) is the faithful publicly-reachable terminal-clearing witness for the shared backward rule. current_lsn is the "did a hook fire / WAL append" oracle: a guarded-out no-op runs no hooks → no persist_wal_async → current_lsn frozen; combine with cbrp_count_jobstate_entries (on-disk scan) + a key_find live-state read. Controller suite baseline 2026-06-13: 502 distinct PASS triples pre-6.1; +3 with 6.1's 2 preservation pins landing green (forward-update, PERR-merge) = 505, 3 FAIL = the 3 new RED guard tests.
## Do-Not-Repeat

View file

@ -1011,3 +1011,10 @@
|------|--------|---------|---------|--------|
| 20:45 | Edited .kiro/specs/ha-commit-barrier-rollout/tasks.md | inline fix | ~37 |
| 20:46 | Edited .kiro/specs/ha-commit-barrier-rollout/tasks.md | 1→2 lines | ~383 |
| 20:56 | Edited src/controller/testsuite/test_commit_barrier_reply.c | added 2 condition(s) | ~4126 |
| 20:56 | Edited src/controller/testsuite/test_commit_barrier_reply.c | expanded (+9 lines) | ~216 |
| 20:58 | Edited src/controller/testsuite/test_commit_barrier_reply.c | TEST_ASSERT_EQUAL_INT() → WAL_JOB_START() | ~111 |
| 04:07 | task 6.1 RED: 5 mark_job_* guard tests (3 red, 2 preservation pins) | testsuite/test_commit_barrier_reply.c | full suite 3 FAIL=new RED, 505 PASS | ~14k |
| 04:18 | task 6.1 review: RED tests for mark_job_* guards verified | test_commit_barrier_reply.c | APPROVED — 22/3 group, 508/3 census, boundary clean | ~38k |
| 21:19 | Edited .kiro/specs/ha-commit-barrier-rollout/tasks.md | inline fix | ~13 |
| 21:19 | Edited .kiro/specs/ha-commit-barrier-rollout/tasks.md | modified 2() | ~243 |

View file

@ -1498,6 +1498,352 @@ static void test_fact_retention_across_quorum_loss(void)
printf("test_fact_retention_across_quorum_loss: PASS\n");
}
/* ════════════════════════════════════════════════════════════════════════
* Idempotency / ordering guards for mark_job_* (task 6.1, Req 5.1/5.2/5.3)
* ════════════════════════════════════════════════════════════════════════
*
* RED-phase tests for the transition guards that task 6.2 adds at the top
* of mark_job_start / mark_job_finish / mark_job_fail / mark_job_update
* (job.c:583/638/696/726 pre-task anchors). Design §Idempotency Guards:
* - duplicate (already-reflected transition) → INFO log, NO-OP: state
* unchanged, BEFORE/AFTER_EVENT hooks NOT fired → NO WAL entry appended
* (current_lsn unmoved), caller-visible rc treated as success-no-op (5.1)
* - backward (terminal current state, non-terminal requested: START on a
* finished job, UPDATE clearing terminal bits) → WARN log, REJECT,
* terminal state preserved (5.2)
* - replay: re-applying the same transition converges to single-
* application state — no second WAL entry, identical final state (5.3)
* - mark_job_fail's existing fail-after-finish PERR-merge (job.c:702-716)
* is forward-compatible and must be PRESERVED by the guard (pin)
*
* Drivers: the static mark_job_* are reached through their non-static
* callers on_job_start / on_job_finish / on_job_fail (job.c) — exactly the
* fact path used by test_fact_retention_across_quorum_loss, minus the
* opcode decode. These wrappers each return 0 unconditionally today, so the
* "success-no-op rc" observable (every caller treats a guarded-out
* transition as success) is asserted directly on the wrapper rc.
*
* Observability of state: the per-job WAL append count and the LAST
* recorded post-transition state come from cbrp_count_jobstate_entries
* (direct on-disk scan, defined above); the job's LIVE in-memory state is
* read via cbrp_job_live_state (key_find on g_hc.jobmgr). current_lsn is
* the "did a hook fire / WAL entry append" oracle (a guarded-out no-op runs
* no hooks → no persist_wal_async → current_lsn frozen).
*
* RED expectation against the UNGUARDED mark_job_* (no guard yet):
* - duplicate FINISH re-fires the AFTER_EVENT hook → a 2nd WAL_JOB_FINISH
* append, current_lsn advances → the "current_lsn unmoved / count==1"
* assertions FAIL.
* - START on a DONE job unconditionally sets state = JOB_STAT_RUN and
* appends WAL_JOB_START → the "state preserved DONE / no new append"
* assertions FAIL.
* - replay FINISH appends a duplicate entry → the "single append"
* assertion FAILS.
* The fail-after-finish PERR-merge pin and the success-no-op rc pins lock
* behavior that must hold both before and after the guard (preservation
* pins, like the task-3.1 validation pins); they ride this group so 6.2
* cannot silently regress the merge or the wrapper return contract.
*
* NOTE on the UPDATE-clears-terminal arm (5.2): mark_job_update is static
* with no public caller that produces a terminal→non-terminal state
* (on_job_postdone's update is forward — it OR's PDONE, preserving terminal;
* job_ctrl SIGCONT only clears suspend bits). mark_job_start IS the
* canonical "set state to the non-terminal JOB_STAT_RUN" overwrite, so the
* START-on-terminal case below is the faithful, publicly-reachable witness
* of the shared backward rule the design lists for START and UPDATE
* together. test_guard_forward_update_on_terminal_preserved additionally
* pins that a FORWARD update on a terminal job (on_job_postdone) is NOT
* over-rejected — the boundary the guard must respect.
*/
/* Read a job's live in-memory state from g_hc.jobmgr (post-transition).
* Returns the state flags, or -1 if the job is absent. */
static int cbrp_job_live_state(uint64_t jobid)
{
job_t *j = NULL;
int st = -1;
lock(g_hc.jobmgr, RTTI_LOCK_READ);
key_find(g_hc.jobmgr, (void *)jobid, (void **)&j);
if (j != NULL) {
reference(j);
}
unlock(g_hc.jobmgr);
if (j != NULL) {
lock(j, RTTI_LOCK_READ);
st = (int)j->job.state;
unlock(j);
dereference(j);
}
return st;
}
/* Drive a job to terminal DONE via start(jobid) → finish(jobid, exit 0).
* Returns current_lsn after the two forward facts (the watermark the
* duplicate/backward cases must NOT advance). */
static uint64_t cbrp_drive_to_done(uint64_t jobid)
{
static unsigned int gd_pids[1] = { 9999 };
job_pidinfo_t start;
xce_jobfinish_t fin;
uint64_t lsn;
memset(&start, 0, sizeof(start));
start.jobid = jobid;
start.npids = 1;
start.pids = &gd_pids[0];
TEST_ASSERT_EQUAL_INT(0, on_job_start(&g_hc, &start));
memset(&fin, 0, sizeof(fin));
fin.jobid = jobid;
fin.exit_code = 0; /* → JOB_STAT_DONE */
TEST_ASSERT_EQUAL_INT(0, on_job_finish(&g_hc, &fin));
TEST_ASSERT_EQUAL_INT_MESSAGE(JOB_STAT_DONE, cbrp_job_live_state(jobid),
"precondition: job must be DONE after start→finish(0)");
pthread_mutex_lock(&g_hc.persist.lock);
lsn = g_hc.persist.current_lsn;
pthread_mutex_unlock(&g_hc.persist.lock);
return lsn;
}
/* 5.1 — duplicate FINISH on a DONE job is a logged no-op: state unchanged,
* NO AFTER_EVENT hook fired (NO new WAL entry → current_lsn frozen), exactly
* one FINISH entry on disk, and on_job_finish returns success-no-op (0). */
static void test_guard_duplicate_finish_on_done_is_noop(void)
{
xce_jobfinish_t fin;
uint64_t lsn_after_done, lsn_after_dup;
uint32_t st_finish = 0;
int rc;
cbrp_setup(3000);
cbrp_make_job(70, &g_job_submit);
memset(&g_job_submit, 0, sizeof(g_job_submit));
lsn_after_done = cbrp_drive_to_done(70);
TEST_ASSERT_EQUAL_INT_MESSAGE(1,
cbrp_count_jobstate_entries(&g_hc.persist, WAL_JOB_FINISH, 70, NULL),
"exactly one FINISH entry must exist after the first finish");
/* Act: a duplicate FINISH on the already-DONE job (retransmit / replay) */
memset(&fin, 0, sizeof(fin));
fin.jobid = 70;
fin.exit_code = 0;
rc = on_job_finish(&g_hc, &fin);
pthread_mutex_lock(&g_hc.persist.lock);
lsn_after_dup = g_hc.persist.current_lsn;
pthread_mutex_unlock(&g_hc.persist.lock);
/* Assert: success-no-op rc; state unchanged; NO hook fired (no append) */
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc,
"a guarded-out duplicate FINISH must return success-no-op (5.1)");
TEST_ASSERT_EQUAL_INT_MESSAGE(JOB_STAT_DONE, cbrp_job_live_state(70),
"duplicate FINISH must leave the job's state unchanged (5.1)");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(lsn_after_done, lsn_after_dup,
"duplicate FINISH must fire NO AFTER_EVENT hook → NO WAL entry "
"appended → current_lsn unmoved (5.1)");
TEST_ASSERT_EQUAL_INT_MESSAGE(1,
cbrp_count_jobstate_entries(&g_hc.persist, WAL_JOB_FINISH, 70,
&st_finish),
"duplicate FINISH must not append a second FINISH entry (5.1)");
TEST_ASSERT_EQUAL_UINT32(JOB_STAT_DONE, st_finish);
cbrp_teardown();
printf("test_guard_duplicate_finish_on_done_is_noop: PASS\n");
}
/* 5.2 — START on a finished (DONE) job is rejected: the terminal state is
* preserved (never overwritten with JOB_STAT_RUN), NO WAL_JOB_START entry is
* appended, and on_job_start returns a success-no-op code that the caller
* (do_notify) treats as success. mark_job_start is the canonical
* terminal→non-terminal overwrite, so this also witnesses the UPDATE-clears-
* terminal arm of the shared backward rule (5.2). */
static void test_guard_start_on_terminal_rejected_state_preserved(void)
{
static unsigned int rs_pids[1] = { 7001 };
job_pidinfo_t restart;
uint64_t lsn_after_done, lsn_after_start;
int rc;
cbrp_setup(3000);
cbrp_make_job(71, &g_job_submit);
memset(&g_job_submit, 0, sizeof(g_job_submit));
lsn_after_done = cbrp_drive_to_done(71);
/* Precondition: drive_to_done's start→finish appended exactly one
* WAL_JOB_START (the forward start). The rejected backward START below
* must not add a second one. */
TEST_ASSERT_EQUAL_INT_MESSAGE(1,
cbrp_count_jobstate_entries(&g_hc.persist, WAL_JOB_START, 71, NULL),
"precondition: exactly one START entry from the forward start");
/* Act: a stale/duplicate START arrives for the already-finished job */
memset(&restart, 0, sizeof(restart));
restart.jobid = 71;
restart.npids = 1;
restart.pids = &rs_pids[0];
rc = on_job_start(&g_hc, &restart);
pthread_mutex_lock(&g_hc.persist.lock);
lsn_after_start = g_hc.persist.current_lsn;
pthread_mutex_unlock(&g_hc.persist.lock);
/* Assert: terminal preserved, no append, success-no-op rc */
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc,
"a rejected backward START must return success-no-op to the caller "
"(5.2)");
TEST_ASSERT_EQUAL_INT_MESSAGE(JOB_STAT_DONE, cbrp_job_live_state(71),
"START on a DONE job must preserve the terminal state — never "
"overwrite it with JOB_STAT_RUN (5.2)");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(lsn_after_done, lsn_after_start,
"a rejected backward START must append NO WAL entry (5.2)");
TEST_ASSERT_EQUAL_INT_MESSAGE(1,
cbrp_count_jobstate_entries(&g_hc.persist, WAL_JOB_START, 71, NULL),
"a rejected backward START must not append a second START entry "
"(5.2)");
cbrp_teardown();
printf("test_guard_start_on_terminal_rejected_state_preserved: PASS\n");
}
/* 5.3 — replay equivalence: applying the same FINISH transition twice yields
* state identical to a single application AND a single WAL entry. The second
* application is the guarded-out duplicate; the on-disk FINISH count staying
* at 1 with current_lsn frozen is the replay-once invariant. */
static void test_guard_replay_finish_equals_single_application(void)
{
xce_jobfinish_t fin;
uint64_t lsn_single, lsn_replay;
int state_single, state_replay;
uint32_t st_disk = 0;
cbrp_setup(3000);
cbrp_make_job(72, &g_job_submit);
memset(&g_job_submit, 0, sizeof(g_job_submit));
/* Single application baseline */
lsn_single = cbrp_drive_to_done(72);
state_single = cbrp_job_live_state(72);
TEST_ASSERT_EQUAL_INT(1,
cbrp_count_jobstate_entries(&g_hc.persist, WAL_JOB_FINISH, 72, NULL));
/* Replay the FINISH (retry / WAL replay re-application) */
memset(&fin, 0, sizeof(fin));
fin.jobid = 72;
fin.exit_code = 0;
TEST_ASSERT_EQUAL_INT_MESSAGE(0, on_job_finish(&g_hc, &fin),
"replayed FINISH must return success-no-op (5.3)");
pthread_mutex_lock(&g_hc.persist.lock);
lsn_replay = g_hc.persist.current_lsn;
pthread_mutex_unlock(&g_hc.persist.lock);
state_replay = cbrp_job_live_state(72);
/* Assert: state and the WAL log are identical to single application */
TEST_ASSERT_EQUAL_INT_MESSAGE(state_single, state_replay,
"replayed transition must converge to single-application state "
"(5.3)");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(lsn_single, lsn_replay,
"replaying a committed transition must append no second WAL entry "
"(5.3)");
TEST_ASSERT_EQUAL_INT_MESSAGE(1,
cbrp_count_jobstate_entries(&g_hc.persist, WAL_JOB_FINISH, 72,
&st_disk),
"the FINISH fact must remain on disk exactly once after replay "
"(5.3)");
TEST_ASSERT_EQUAL_UINT32(JOB_STAT_DONE, st_disk);
cbrp_teardown();
printf("test_guard_replay_finish_equals_single_application: PASS\n");
}
/* 5.2 boundary pin — a FORWARD update on a terminal job must NOT be
* over-rejected by the guard. on_job_postdone is the reachable mark_job_update
* caller: on a DONE job it OR's JOB_STAT_PDONE (terminal preserved, strictly
* forward). This must still apply (state gains PDONE, an entry appends) after
* the guard lands — the guard rejects backward, never a forward merge.
* Preservation pin: green before and after task 6.2. */
static void test_guard_forward_update_on_terminal_preserved(void)
{
uint64_t lsn_after_done, lsn_after_pdone;
int st;
cbrp_setup(3000);
cbrp_make_job(73, &g_job_submit);
memset(&g_job_submit, 0, sizeof(g_job_submit));
lsn_after_done = cbrp_drive_to_done(73);
/* Act: post-done arrives — forward update merging PDONE onto DONE */
TEST_ASSERT_EQUAL_INT(0, on_job_postdone(&g_hc, &g_req_ep, 73));
pthread_mutex_lock(&g_hc.persist.lock);
lsn_after_pdone = g_hc.persist.current_lsn;
pthread_mutex_unlock(&g_hc.persist.lock);
st = cbrp_job_live_state(73);
/* Assert: terminal preserved, PDONE merged forward, the update appended */
TEST_ASSERT_TRUE_MESSAGE((st & JOB_STAT_DONE) != 0,
"a forward update on a terminal job must preserve the terminal bit");
TEST_ASSERT_TRUE_MESSAGE((st & JOB_STAT_PDONE) != 0,
"the forward update must merge JOB_STAT_PDONE (not be rejected)");
TEST_ASSERT_TRUE_MESSAGE(lsn_after_pdone > lsn_after_done,
"a forward update on a terminal job must still append its WAL entry "
"— the guard rejects backward transitions, never forward merges");
cbrp_teardown();
printf("test_guard_forward_update_on_terminal_preserved: PASS\n");
}
/* 5.2 pin — mark_job_fail's existing fail-after-finish PERR-merge
* (job.c:702-716) must be PRESERVED by the guard: a POST_EXEC-stage fail on a
* DONE job keeps DONE and OR's JOB_STAT_PERR (it does NOT reset the job to a
* bare JOB_STAT_EXIT). Forward-compatible terminal→terminal merge — the guard
* must not reject it. Preservation pin: green before and after task 6.2. */
static void test_guard_fail_after_finish_preserves_perr_merge(void)
{
job_failinfo_t failinfo;
uint64_t lsn_after_done, lsn_after_fail;
int st;
cbrp_setup(3000);
cbrp_make_job(74, &g_job_submit);
memset(&g_job_submit, 0, sizeof(g_job_submit));
lsn_after_done = cbrp_drive_to_done(74);
/* Act: a post-exec-stage failure for the already-finished job */
memset(&failinfo, 0, sizeof(failinfo));
failinfo.jobid = 74;
failinfo.stage = JOB_EXEC_STAGE_POST_EXEC;
failinfo.code = 1;
failinfo.detail = (char *)"post-exec fail after finish";
TEST_ASSERT_EQUAL_INT(0, on_job_fail(&g_hc, &failinfo));
pthread_mutex_lock(&g_hc.persist.lock);
lsn_after_fail = g_hc.persist.current_lsn;
pthread_mutex_unlock(&g_hc.persist.lock);
st = cbrp_job_live_state(74);
/* Assert: DONE preserved, PERR merged in — not reset to bare EXIT */
TEST_ASSERT_TRUE_MESSAGE((st & JOB_STAT_DONE) != 0,
"fail-after-finish must keep the DONE terminal bit (PERR-merge "
"behavior, job.c:702-716)");
TEST_ASSERT_TRUE_MESSAGE((st & JOB_STAT_PERR) != 0,
"fail-after-finish must OR JOB_STAT_PERR onto the existing terminal "
"state (PERR-merge behavior, job.c:702-716)");
TEST_ASSERT_TRUE_MESSAGE(lsn_after_fail > lsn_after_done,
"the PERR-merge fail must still append its WAL entry (forward "
"terminal→terminal merge — not a guarded-out no-op)");
cbrp_teardown();
printf("test_guard_fail_after_finish_preserves_perr_merge: PASS\n");
}
/* ── Test group entry point ──────────────────────────────────────────── */
void test_commit_barrier_reply(void)
@ -1534,5 +1880,14 @@ void test_commit_barrier_reply(void)
/* Fact retention across quorum loss (task 4.3, Req 3.1, 3.3, 3.4) —
* green on landing against tasks 1.2 + 4.2 output */
RUN_TEST(test_fact_retention_across_quorum_loss);
/* mark_job_* idempotency / ordering guards (task 6.1, Req 5.1/5.2/5.3)
* — the no-op / backward-reject / replay cases FAIL (red) against the
* unguarded mark_job_*; the forward-merge and PERR-merge cases are
* preservation pins (green before and after task 6.2) */
RUN_TEST(test_guard_duplicate_finish_on_done_is_noop);
RUN_TEST(test_guard_start_on_terminal_rejected_state_preserved);
RUN_TEST(test_guard_replay_finish_equals_single_application);
RUN_TEST(test_guard_forward_update_on_terminal_preserved);
RUN_TEST(test_guard_fail_after_finish_preserves_perr_merge);
UNITY_END();
}