test(raft-apply-decoupling): command failure path leaves no trace under repetition (task 3.3)

Add 4 tests to test_commit_barrier_reply.c (25->29), no production
changes: explicit-FAILED via truncation (distinct from timeout),
repeated failure loops (5 cycles) for both timeout and truncation,
and an array_size>1 mixed-outcome test proving a committed sibling's
apply hand-off isn't blocked by failing siblings while producing
zero phantom jobs for the failures and exactly one batch-level
failure reply.

Closes out task-group 3 (command apply-after-commit ordering).
This commit is contained in:
Rongsong Shen 2026-07-04 06:05:06 +00:00
parent df4d2fa0ed
commit f357215a6d
2 changed files with 342 additions and 3 deletions

View file

@ -1058,6 +1058,337 @@ static void test_jobsubmit_returns_before_commit_on_failure_path_too(void)
printf("test_jobsubmit_returns_before_commit_on_failure_path_too: PASS\n");
}
/* ════════════════════════════════════════════════════════════════════════
* raft-apply-decoupling task 3.3 (Req 2.1, 2.3): the failure-path guarantee
* task 3.2 built, and the single-timeout tests above already proved, now
* needs proof that it (a) also holds for an explicit, non-timeout FAILED
* outcome, and (b) holds across REPEATED forced failures, not just once.
* Every test below follows the task-3.1-round-3 lesson: assertion-relevant
* observables are captured into locals, cbrp_teardown() runs exactly once
* and unconditionally, and every TEST_ASSERT fires only after teardown
* returns.
*
* Demotion is deliberately NOT used to force the non-timeout FAILED case
* here: raft_cb_become_follower's demotion branch resolves continuations
* on `c->commit` (the cluster_t-embedded commit_engine_t), but this file's
* cbrp_setup fixture wires job submission's continuations through a
* SEPARATE standalone `g_hce` (via repl_set_commit_engine) exactly like
* every other handler in this file -- calling the demotion seam against
* g_hc would touch the wrong, never-initialized engine and prove nothing.
* Truncation resolves through persist_truncate_wal_above ->
* p->on_truncate -> the SAME g_hce commit_engine_init wires
* (raft-apply-decoupling task 1.4), so it is the correct and only
* deterministic way to force an explicit FAILED outcome for job
* submission in this harness. Generic exactly-once demotion-resolve
* behavior already has dedicated coverage at the commit-engine level in
* test_demotion_convergence.c (task 1.4/1.5).
* ════════════════════════════════════════════════════════════════════════ */
/* Single-shot: an explicit FAILED outcome (rc=-ENOENT, forced via
* log-tail truncation) must leave no phantom job, exactly like the
* -ETIMEDOUT path -- but the reply status must carry the DISTINCT
* truncation vocabulary, not the timeout one, proving the continuation
* genuinely observed a different cause. */
static void test_jobsubmit_truncation_forces_failed_no_phantom_job(void)
{
uint64_t lsn_before, watermark;
int create_rc, join_rc, trunc_rc;
cbrp_reply_obs_t obs = { 0 };
bool job_exists;
cbrp_setup(5000); /* generous: truncation, not the timeout sweep, must
* be what resolves this entry */
cbrp_arrange_jobsubmit(9110, 1);
pthread_mutex_lock(&g_hc.persist.lock);
lsn_before = g_hc.persist.current_lsn;
pthread_mutex_unlock(&g_hc.persist.lock);
g_op_arg.fn = handle_jobsubmit;
g_op_arg.req = &g_jobsub_req;
g_op_arg.rc = -9999;
create_rc = pthread_create(&g_op_tid, NULL, cbrp_op_thread_fn, &g_op_arg);
join_rc = (create_rc == 0) ? pthread_join(g_op_tid, NULL) : -1;
watermark = cbrp_poll_append(&g_hc.persist, lsn_before, 2000);
/* Force an explicit FAILED outcome (rc=-ENOENT), NOT a timeout:
* truncate the entry that was just appended. Exercises
* commit_on_truncate -> commit_resolve_pending_above(ce, cutoff,
* -ENOENT) (task 1.4) through the real job-submit continuation
* (task 3.2), distinct from the -ETIMEDOUT path already covered by
* test_jobsubmit_returns_before_commit_on_failure_path_too. */
trunc_rc = (watermark > 0)
? persist_truncate_wal_above(&g_hc.persist, watermark - 1)
: -1;
cbrp_poll_reply(&g_req_ep, 2000, &obs);
job_exists = cbrp_job_exists(9110);
cbrp_teardown();
TEST_ASSERT_EQUAL_INT_MESSAGE(0, create_rc, "pthread_create must succeed");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, join_rc, "pthread_join must succeed");
TEST_ASSERT_TRUE_MESSAGE(watermark > 0,
"job submission must append its WAL entry before truncation can "
"force its continuation FAILED");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, trunc_rc,
"forcing truncation must succeed");
TEST_ASSERT_TRUE_MESSAGE(obs.n_replies > 0,
"a truncated commit must still reply to the client");
TEST_ASSERT_EQUAL_INT_MESSAGE(-ENOENT, obs.last_status,
"truncation must reply with -ENOENT, the existing "
"commit_resolve_pending_above error vocabulary (raft-apply-"
"decoupling task 1.4) -- distinct from the -ETIMEDOUT timeout "
"path already covered elsewhere in this file");
TEST_ASSERT_FALSE_MESSAGE(job_exists,
"no job may ever be constructed for an entry whose commit failed "
"via truncation, not just via timeout (raft-apply-decoupling task "
"3.3, Req 2.1/2.3)");
printf("test_jobsubmit_truncation_forces_failed_no_phantom_job: PASS\n");
}
#define CBRP_JOBSUBMIT_REPEAT_N 5
/* Proves the failure-path guarantee holds across MULTIPLE forced timeouts
* in sequence, not just once -- and that nothing about the fixture (the
* jobmgr, the per-request jobsubmit_async_ctx_t lifecycle) leaks or
* accumulates state across repeated failures. */
static void test_jobsubmit_repeated_timeout_failures_no_phantom_jobs(void)
{
const uint64_t base_jobid = 9120;
int create_rc[CBRP_JOBSUBMIT_REPEAT_N];
int join_rc[CBRP_JOBSUBMIT_REPEAT_N];
uint64_t watermark[CBRP_JOBSUBMIT_REPEAT_N];
int n_replies[CBRP_JOBSUBMIT_REPEAT_N];
int32_t last_status[CBRP_JOBSUBMIT_REPEAT_N];
bool job_exists[CBRP_JOBSUBMIT_REPEAT_N];
int i;
cbrp_setup(200); /* short timeout: bound the loop's wall time */
for (i = 0; i < CBRP_JOBSUBMIT_REPEAT_N; i++) {
uint64_t jobid = base_jobid + (uint64_t)i;
uint64_t lsn_before;
cbrp_reply_obs_t obs = { 0 };
cbrp_arrange_jobsubmit(jobid, 1);
pthread_mutex_lock(&g_hc.persist.lock);
lsn_before = g_hc.persist.current_lsn;
pthread_mutex_unlock(&g_hc.persist.lock);
g_op_arg.fn = handle_jobsubmit;
g_op_arg.req = &g_jobsub_req;
g_op_arg.rc = -9999;
create_rc[i] = pthread_create(&g_op_tid, NULL, cbrp_op_thread_fn,
&g_op_arg);
join_rc[i] = (create_rc[i] == 0) ? pthread_join(g_op_tid, NULL) : -1;
watermark[i] = cbrp_poll_append(&g_hc.persist, lsn_before, 1000);
/* No ACK is ever delivered: this iteration's entry must resolve
* FAILED via the timeout sweep (task 1.3), not majority ack. */
cbrp_poll_reply(&g_req_ep, 200 + 1500, &obs);
n_replies[i] = obs.n_replies;
last_status[i] = obs.last_status;
job_exists[i] = cbrp_job_exists(jobid);
}
cbrp_teardown();
for (i = 0; i < CBRP_JOBSUBMIT_REPEAT_N; i++) {
TEST_ASSERT_EQUAL_INT_MESSAGE(0, create_rc[i],
"pthread_create must succeed for every repeated iteration");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, join_rc[i],
"pthread_join must succeed for every repeated iteration");
TEST_ASSERT_TRUE_MESSAGE(watermark[i] > 0,
"each repeated submission must append its own WAL entry");
TEST_ASSERT_TRUE_MESSAGE(n_replies[i] > 0,
"every repeated forced-timeout submission must still reply");
TEST_ASSERT_EQUAL_INT_MESSAGE(-ETIMEDOUT, last_status[i],
"every repeated forced-timeout submission must reply "
"-ETIMEDOUT");
TEST_ASSERT_FALSE_MESSAGE(job_exists[i],
"no phantom job may exist for any repeated forced-timeout "
"failure (raft-apply-decoupling task 3.3, Req 2.1/2.3) -- "
"proves the guarantee is not a one-shot fluke");
}
printf("test_jobsubmit_repeated_timeout_failures_no_phantom_jobs: PASS\n");
}
/* The explicit-FAILED (non-timeout) sibling of the repeated-timeout test
* above: repeats the truncation-forced FAILED path
* CBRP_JOBSUBMIT_REPEAT_N times in the same fixture, proving the
* no-phantom-job guarantee holds for "repeated commit failures" in the
* literal sense the task text names (not just "repeated timeouts"), and
* that repeated FAILED resolutions release their jobsubmit_async_ctx_t
* cleanly each time (a fresh entry always starts from the same
* current_lsn==1 baseline the previous iteration's truncation left
* behind -- if a context or WAL state leaked, later iterations would
* observe a growing lsn_before/current_lsn or a stuck reply queue). */
static void test_jobsubmit_repeated_truncation_forced_failures_no_phantom_jobs(void)
{
const uint64_t base_jobid = 9130;
int create_rc[CBRP_JOBSUBMIT_REPEAT_N];
int join_rc[CBRP_JOBSUBMIT_REPEAT_N];
uint64_t watermark[CBRP_JOBSUBMIT_REPEAT_N];
int trunc_rc[CBRP_JOBSUBMIT_REPEAT_N];
int n_replies[CBRP_JOBSUBMIT_REPEAT_N];
int32_t last_status[CBRP_JOBSUBMIT_REPEAT_N];
bool job_exists[CBRP_JOBSUBMIT_REPEAT_N];
int i;
cbrp_setup(5000); /* generous: truncation, not the timeout sweep, must
* be what resolves each iteration */
for (i = 0; i < CBRP_JOBSUBMIT_REPEAT_N; i++) {
uint64_t jobid = base_jobid + (uint64_t)i;
uint64_t lsn_before;
cbrp_reply_obs_t obs = { 0 };
cbrp_arrange_jobsubmit(jobid, 1);
pthread_mutex_lock(&g_hc.persist.lock);
lsn_before = g_hc.persist.current_lsn;
pthread_mutex_unlock(&g_hc.persist.lock);
g_op_arg.fn = handle_jobsubmit;
g_op_arg.req = &g_jobsub_req;
g_op_arg.rc = -9999;
create_rc[i] = pthread_create(&g_op_tid, NULL, cbrp_op_thread_fn,
&g_op_arg);
join_rc[i] = (create_rc[i] == 0) ? pthread_join(g_op_tid, NULL) : -1;
watermark[i] = cbrp_poll_append(&g_hc.persist, lsn_before, 1000);
trunc_rc[i] = (watermark[i] > 0)
? persist_truncate_wal_above(&g_hc.persist, watermark[i] - 1)
: -1;
cbrp_poll_reply(&g_req_ep, 2000, &obs);
n_replies[i] = obs.n_replies;
last_status[i] = obs.last_status;
job_exists[i] = cbrp_job_exists(jobid);
}
cbrp_teardown();
for (i = 0; i < CBRP_JOBSUBMIT_REPEAT_N; i++) {
TEST_ASSERT_EQUAL_INT_MESSAGE(0, create_rc[i],
"pthread_create must succeed for every repeated iteration");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, join_rc[i],
"pthread_join must succeed for every repeated iteration");
TEST_ASSERT_TRUE_MESSAGE(watermark[i] > 0,
"each repeated submission must append its own WAL entry");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, trunc_rc[i],
"forcing truncation must succeed for every repeated iteration");
TEST_ASSERT_TRUE_MESSAGE(n_replies[i] > 0,
"every repeated forced-truncation submission must still "
"reply");
TEST_ASSERT_EQUAL_INT_MESSAGE(-ENOENT, last_status[i],
"every repeated forced-truncation submission must reply "
"-ENOENT, the explicit non-timeout FAILED vocabulary "
"(raft-apply-decoupling task 1.4)");
TEST_ASSERT_FALSE_MESSAGE(job_exists[i],
"no phantom job may exist for any repeated forced-truncation "
"failure (raft-apply-decoupling task 3.3, Req 2.1/2.3)");
}
printf("test_jobsubmit_repeated_truncation_forced_failures_no_phantom_jobs:"
" PASS\n");
}
/* Closes task 3.2's flagged gap ("no array_size > 1 mixed-outcome test
* exists yet"): element 1 commits (covering ACK) while elements 2 and 3
* are left to time out FAILED. Confirms the failed elements produce zero
* phantom jobs and that the committed element's independent resolution
* (hand-off to persist_register_pending_apply) is not blocked or skipped
* by its failing siblings -- task 3.2's per-element independence, proven
* by a real test rather than by code inspection alone. */
static void test_jobsubmit_array_mixed_outcome_no_phantom_job_for_failed_elements(void)
{
const uint64_t base_jobid = 9140;
uint64_t lsn_before, watermark, lsn1, t1;
int create_rc, join_rc;
bool has_pending1;
cbrp_reply_obs_t obs = { 0 };
bool job1_exists, job2_exists, job3_exists;
cbrp_setup(300); /* short: elements 2/3 must resolve via the timeout
* sweep within a bounded window */
cbrp_arrange_jobsubmit(base_jobid, 3);
pthread_mutex_lock(&g_hc.persist.lock);
lsn_before = g_hc.persist.current_lsn;
pthread_mutex_unlock(&g_hc.persist.lock);
lsn1 = lsn_before;
g_op_arg.fn = handle_jobsubmit;
g_op_arg.req = &g_jobsub_req;
g_op_arg.rc = -9999;
create_rc = pthread_create(&g_op_tid, NULL, cbrp_op_thread_fn, &g_op_arg);
join_rc = (create_rc == 0) ? pthread_join(g_op_tid, NULL) : -1;
/* All three array elements append back-to-back (task 3.2's
* non-blocking, no per-element ACK gate) before handle_jobsubmit
* returns -- by the time pthread_join above returns, every element's
* WAL entry already exists. */
watermark = cbrp_poll_append(&g_hc.persist, lsn_before + 2, 2000);
/* Cover ONLY the first element with a majority ACK; elements 2 and 3
* are left pending -- they must resolve FAILED via the timeout sweep,
* independently of element 1's success. */
repl_on_ack(&g_hc.persist.repl, &g_hep_a, lsn1);
has_pending1 = false;
t1 = cbrp_mono_ms();
while (cbrp_mono_ms() - t1 < 2000) {
if (persist_has_pending_apply(&g_hc.persist, lsn1)) {
has_pending1 = true;
break;
}
cbrp_sleep_ms(5);
}
cbrp_poll_reply(&g_req_ep, 300 + 1500, &obs);
job1_exists = cbrp_job_exists(base_jobid);
job2_exists = cbrp_job_exists(base_jobid + 1);
job3_exists = cbrp_job_exists(base_jobid + 2);
cbrp_teardown();
TEST_ASSERT_EQUAL_INT_MESSAGE(0, create_rc, "pthread_create must succeed");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, join_rc, "pthread_join must succeed");
TEST_ASSERT_EQUAL_UINT64_MESSAGE(lsn_before + 2, watermark,
"all three array elements must append before this poll returns");
TEST_ASSERT_TRUE_MESSAGE(has_pending1,
"a sibling array element that commits must still be handed off to "
"the pending-apply registry even though other siblings in the "
"same batch are failing (raft-apply-decoupling task 3.2's "
"per-element independent resolution)");
TEST_ASSERT_EQUAL_INT_MESSAGE(1, obs.n_replies,
"a partially-failed array batch must produce exactly one failure "
"reply for the whole request (jobsubmit_reply_failure_once)");
TEST_ASSERT_EQUAL_INT_MESSAGE(-ETIMEDOUT, obs.last_status,
"the batch's single reply must carry the failing elements' "
"-ETIMEDOUT outcome");
TEST_ASSERT_FALSE_MESSAGE(job1_exists,
"no element may have a constructed job yet -- task 6's apply pump "
"does not exist, so even the committed element has no job; this "
"test proves independent per-element RESOLUTION, not apply");
TEST_ASSERT_FALSE_MESSAGE(job2_exists,
"a failed array element must never have a phantom job");
TEST_ASSERT_FALSE_MESSAGE(job3_exists,
"a failed array element must never have a phantom job");
printf("test_jobsubmit_array_mixed_outcome_no_phantom_job_for_failed_elements:"
" PASS\n");
}
/* REMOVED (raft-apply-decoupling task 3.2): the array-partial-construction-
* failure remediation test (task 3.1 round-2 fix, bug-1408) that lived here
* drove handle_jobsubmit's OLD sequential, per-element BLOCKING append loop
@ -2124,7 +2455,14 @@ void test_commit_barrier_reply(void)
RUN_TEST(test_jobsubmit_withheld_acks_no_phantom_job);
RUN_TEST(test_jobsubmit_returns_before_commit_and_registers_pending_apply);
RUN_TEST(test_jobsubmit_returns_before_commit_on_failure_path_too);
/* Receipt-ACK verdict pins (task 3.3, Req 2.3) — green on landing */
/* raft-apply-decoupling task 3.3 (Req 2.1, 2.3): repeated/explicit-
* FAILED failure-path coverage beyond the single-timeout tests above */
RUN_TEST(test_jobsubmit_truncation_forces_failed_no_phantom_job);
RUN_TEST(test_jobsubmit_repeated_timeout_failures_no_phantom_jobs);
RUN_TEST(test_jobsubmit_repeated_truncation_forced_failures_no_phantom_jobs);
RUN_TEST(test_jobsubmit_array_mixed_outcome_no_phantom_job_for_failed_elements);
/* Receipt-ACK verdict pins (ha-commit-barrier-rollout task 3.3, Req 2.3)
* — green on landing */
RUN_TEST(test_jobdispatch_receipt_ack_no_wal_append);
RUN_TEST(test_set_preason_receipt_reply_fact_retained);
RUN_TEST(test_clr_preason_receipt_reply_fact_retained);