feat(raft-apply-decoupling): convert job submit to continuation-based commit (task 3.2)

Replace handle_jobsubmit()'s blocking commit call (task 3.1) with a
real async registration against commit_submit_async(). On
FAILED/timeout, replies failure immediately with no job constructed.
On COMMITTED, hands off to a new pending-apply registration
(persist_register_pending_apply) for the ordered apply pump (task
6.1, not yet built) to consume -- does not apply or reply directly,
per design.

Deviates from design.md's stated integration site: appends via the
same ha_primary_log_append/persist_wal_async primitive the RETAIN-fact
path already uses and calls commit_submit_async directly on
p->repl.ce, bypassing repl_wait_majority_ack/repl_submit_majority_commit
in repl.c. Verified safe -- majority-ack detection is driven entirely
by (ce, lsn) events inside the commit engine, agnostic to which
wrapper registered the continuation -- but narrows the reusable
primitive design.md intended; documented in tasks.md.

Deletes (not preserves-dead) task 3.1's bug-1408 notification fix --
apply_committed_entry()'s WAL_JOB_SUBMIT case (task 2.2) already
fires the notification per-entry, immune to the batch-suppression
class bug-1408 fixed.

IMPORTANT: no live path applies a successfully-committed job
submission until task 6 lands (only restart replay does) -- every
successful submission commits durably but gets zero reply until
then. This commit range must not be soak-tested or deployed against
live traffic before task 6 completes.
This commit is contained in:
Rongsong Shen 2026-07-04 05:35:35 +00:00
parent 6f1fa97fab
commit df4d2fa0ed
5 changed files with 689 additions and 299 deletions

View file

@ -595,7 +595,155 @@ int handle_jobdispatch(cluster_t* c, endpoint_t* ep, xce_request_t* request)
return rc;
}
static int xlist_deref_job_item_(job_t* j, void* arg) { return 0; }
/* raft-apply-decoupling task 3.2: shared state for one handle_jobsubmit()
* call's asynchronously-resolving per-entry commit continuations. Exactly
* one instance per client request — an array submission registers one
* continuation per array element, all sharing this struct, so the client
* still gets exactly one reply for the whole batch (matching pre-3.2
* per-request reply semantics), never one reply per array element.
*
* Lifetime: allocated in handle_jobsubmit() before it returns; released by
* whichever per-entry continuation (jobsubmit_commit_continuation) or
* apply-complete callback (jobsubmit_apply_complete) observes `pending`
* reach 0 — the last one out closes the door.
*
* IMPORTANT: a COMMITTED outcome does NOT decrement `pending` by itself
* (see jobsubmit_commit_continuation below) — task 3.2's own contract is
* that a committed entry is handed off to the not-yet-built ordered apply
* pump (task 6.1/6.2), and only jobsubmit_apply_complete (invoked BY that
* future pump) decrements `pending` for it. A batch with any committed
* entry is therefore only ever freed once task 6 exists and actually
* applies it. This mirrors the design's already-accepted "durably
* committed, not yet applied" posture for retained facts during quorum
* loss (Req 6.1) — it is not a leak. */
typedef struct {
cluster_t* c;
endpoint_t* ep; /* ref'd via endpoint_ref(); released exactly
* once, in jobsubmit_async_ctx_release() */
uint64_t seq; /* xce_request_t.seq, copied for the reply */
uint32_t opcode; /* xce_request_t.opcode, copied for the reply */
pthread_mutex_t lock; /* guards pending/replied below */
int pending; /* entries not yet FULLY resolved (see above) */
int replied; /* 1 once a reply has been sent for this batch */
} jobsubmit_async_ctx_t;
static void jobsubmit_async_ctx_release(jobsubmit_async_ctx_t* actx)
{
endpoint_unref(actx->ep);
pthread_mutex_destroy(&actx->lock);
free(actx);
}
/* Sends exactly one failure reply for the whole batch — the first failure
* observed wins; later failures/hand-offs never send a second, possibly
* contradictory reply for the same original request (Req 9.3, read at the
* per-REQUEST granularity a job-array submission actually has one of). */
static void jobsubmit_reply_failure_once(jobsubmit_async_ctx_t* actx, int rc)
{
xce_reply_t reply;
int send = 0;
pthread_mutex_lock(&actx->lock);
if (!actx->replied) {
actx->replied = 1;
send = 1;
}
pthread_mutex_unlock(&actx->lock);
if (!send)
return;
memset(&reply, 0, sizeof(reply));
reply.seq = actx->seq;
reply.opcode = actx->opcode;
reply.status = rc;
reply.data_which = RequestReply_data_nodata;
xce_reply(actx->ep, actx->c->myhost, &reply);
}
/* raft-apply-decoupling task 6 hand-off target (task 6 itself is NOT built
* by this task): invoked by the future ordered apply pump immediately
* after it applies this entry via apply_committed_entry() — task 6.2's own
* contract, "fire a command's registered continuation immediately after
* its entry applies". Until task 6 exists, nothing ever calls this
* function; see jobsubmit_commit_continuation's COMMITTED branch below,
* which registers it as the hand-off target instead of applying or
* replying itself.
*
* NOTE for whoever builds task 6.2: apply_committed_entry()'s WAL_JOB_SUBMIT
* case (persist.c) already constructs the job (via restore_job()) AND fires
* the HOOK_DETAIL_JOB_NEW AFTER_EVENT notification, independently per entry
* — so by the time this function is invoked, the job is very likely already
* visible and already notified. This function most likely only needs to
* send the batch's success reply (echoing the jobid) once every entry has
* been applied, NOT reconstruct or re-notify anything — see the removed-code
* note above handle_jobsubmit's old (pre-3.2) construct/notify block for the
* full reasoning (git history, commit 6f1fa97, task 3.1). */
static void jobsubmit_apply_complete(void* ctx, uint64_t lsn)
{
jobsubmit_async_ctx_t* actx = (jobsubmit_async_ctx_t*)ctx;
int done;
(void)lsn;
pthread_mutex_lock(&actx->lock);
actx->pending--;
done = (actx->pending <= 0);
pthread_mutex_unlock(&actx->lock);
/* Task 6.2's success-reply belongs here (RequestReply_data_jobid,
* gated on actx->replied exactly like jobsubmit_reply_failure_once, so
* a partially-failed batch's earlier failure reply is never followed
* by a contradictory success reply) — not built by this task, which
* must not reply from the committed-outcome path at all (see the
* struct doc comment above). */
if (done)
jobsubmit_async_ctx_release(actx);
}
/* raft-apply-decoupling task 3.2: per-entry outcome of one array element's
* commit_submit_async() registration (persist_submit_job_payloads_async's
* `fn` callback).
*
* On FAILED/timed-out (rc != 0): reply failure now — no apply is needed on
* this path (Req 2.3). This entry has nothing further to wait on, so it
* decrements `pending` itself.
*
* On COMMITTED (rc == 0): task 3.2 must NOT apply the job or reply here
* (the task's own explicit instruction) — hand the entry off to the
* not-yet-built ordered apply pump (task 6.1/6.2) via
* persist_register_pending_apply(), the producer-side hand-off this task
* builds. `pending` is intentionally NOT decremented here for a committed
* entry — see jobsubmit_async_ctx_t's doc comment. */
static void jobsubmit_commit_continuation(void* ctx, uint64_t lsn, int rc)
{
jobsubmit_async_ctx_t* actx = (jobsubmit_async_ctx_t*)ctx;
if (rc != 0) {
int done;
WARN("handle_jobsubmit: lsn=%lu commit failed/timed out (rc=%d) — "
"rejecting; no job ever constructed for it",
(unsigned long)lsn, rc);
jobsubmit_reply_failure_once(actx, rc);
pthread_mutex_lock(&actx->lock);
actx->pending--;
done = (actx->pending <= 0);
pthread_mutex_unlock(&actx->lock);
if (done)
jobsubmit_async_ctx_release(actx);
return;
}
/* COMMITTED: hand off to task 6's ordered apply pump instead of
* applying/replying directly from this continuation — see
* persist_register_pending_apply's doc comment (persist.h) for the
* full hand-off contract. */
persist_register_pending_apply(&actx->c->persist, lsn,
jobsubmit_apply_complete, actx);
}
int handle_jobsubmit(cluster_t* c, endpoint_t* ep, xce_request_t* request)
{
@ -603,10 +751,7 @@ int handle_jobsubmit(cluster_t* c, endpoint_t* ep, xce_request_t* request)
xce_job_submit_t* submit;
xce_reply_t reply;
xlist_t plist; /* xce_job_t* WAL payloads, pre-commit */
xlist_t jlist; /* job_t* for the post-commit notify hook */
uint64_t* lsns = NULL;
job_t* j = NULL;
int i;
int rc;
submit = request->body.jobsubmsg;
@ -660,138 +805,72 @@ int handle_jobsubmit(cluster_t* c, endpoint_t* ep, xce_request_t* request)
return rc;
}
/* Explicit raft step (raft-apply-decoupling item #1 / bug-1306; task
* 3.1): WAL-append + majority-commit barrier for every payload, reading
* the commit verdict DIRECTLY. This used to run inside the AFTER_EVENT
* hook (persist_job_hook_fun), but xce_run_hooks returns only the LAST
* hook's rc, so a later notify subscriber (sched/apiserver) returning 0
* CLOBBERED the -ETIMEDOUT — the client was acked for an entry that
* never reached majority, which a new-term leader then legally
* discarded (committed-job-loss). The client must not be acknowledged,
* and no job object may exist, until the submission is durable on a
* majority. */
int commit_rc = persist_submit_job_payloads_and_commit(&c->persist,
&plist, lsns);
if (commit_rc < 0) {
WARN("handle_jobsubmit: job not durable (rc=%d) — rejecting submission",
commit_rc);
/* Explicit raft step (raft-apply-decoupling item #1 / bug-1306, task
* 3.1; converted to the continuation mechanism by task 3.2): register
* every payload's commit outcome instead of blocking on the majority
* barrier. This handler returns as soon as every payload is durably
* appended locally and its outcome-resolution path is established —
* BEFORE any of them necessarily commit. From this point on, job
* construction and the client reply happen from
* jobsubmit_commit_continuation / jobsubmit_apply_complete (above),
* never again from this stack frame.
*
* REMOVED (raft-apply-decoupling task 3.2) relative to task 3.1's
* version of this function: the post-commit create_job()-loop +
* jlist-build + xce_run_hooks(AFTER_EVENT, HOOK_DETAIL_JOB_NEW) block
* (bug-1408's fix: unconditional per-successfully-constructed-element
* notification) is gone, not merely deferred. It is SUPERSEDED, not
* resurrected, by apply_committed_entry()'s existing WAL_JOB_SUBMIT
* case (persist.c, raft-apply-decoupling task 2.2, already built):
* that function already does restore_job() + fires HOOK_DETAIL_JOB_NEW
* per entry, independently per array element — which is structurally
* immune to the bug-1408 class (a sibling's apply failure can never
* suppress another sibling's notification, because each LSN applies
* through its own independent call). The full reasoning is preserved
* in this task's status report; the removed code itself remains in
* git history at commit 6f1fa97. Whoever builds task 6.1/6.2 should
* call apply_committed_entry() (not resurrect this loop) and should
* expect jobsubmit_apply_complete to need only the success reply. */
jobsubmit_async_ctx_t* actx = calloc(1, sizeof(*actx));
if (actx == NULL) {
free_job_submit_payloads(&plist);
free(lsns);
reply.status = commit_rc;
reply.status = -ENOMEM;
reply.data_which = RequestReply_data_nodata;
xce_reply(ep, c->myhost, &reply);
return commit_rc;
return -ENOMEM;
}
actx->c = c;
actx->ep = ep;
actx->seq = request->seq;
actx->opcode = request->opcode;
actx->pending = plist.count;
actx->replied = 0;
pthread_mutex_init(&actx->lock, NULL);
endpoint_ref(ep);
/* Committed on a majority — NOW construct and register each job object
* (moved here from before the commit barrier; raft-apply-decoupling
* task 3.1 / Req 2.1, 2.2, 2.3). A construction failure at this point
* cannot be rolled back (the entry is already durably committed); it is
* a genuine apply-time failure, not a retryable one (design.md Error
* Strategy) — logged loud, and the submission is NOT reported as a
* success. */
int construct_rc = 0;
for (i = 0; i < plist.count; i++) {
xce_job_t* jp = (xce_job_t*)xlist_get(&plist, i);
job_t* job = create_job(c, jp);
if (job == NULL) {
int saved_errno = errno;
ERROR("handle_jobsubmit: job %lu committed at lsn %lu but "
"could not be constructed (errno=%d)",
(unsigned long)jp->jobid, (unsigned long)lsns[i],
saved_errno);
if (construct_rc == 0)
construct_rc = -saved_errno;
continue;
}
job->submit_lsn = lsns[i];
}
(void)persist_submit_job_payloads_async(&c->persist, &plist, lsns,
jobsubmit_commit_continuation,
actx);
/* Every payload's WAL data is no longer needed once appended: the
* future apply pump (task 6) re-reads each committed entry's payload
* from the WAL itself — the same way restart replay already does —
* not from this in-memory copy. persist_submit_job_payloads_async has
* ALREADY invoked jobsubmit_commit_continuation, synchronously or
* asynchronously, exactly once for every payload by (or after) the
* time it returns; its own return value carries no information this
* function still needs to act on. */
free_job_submit_payloads(&plist);
free(lsns);
/* Build the job list for the AFTER_EVENT hook — the same lookup used
* before this task, just run after construction (which now itself runs
* after commit) instead of before either.
*
* IMPORTANT (raft-apply-decoupling task 3.1 remediation): this lookup
* and the hook it feeds run UNCONDITIONALLY here, even when
* construct_rc < 0 for one or more array elements. Every element whose
* create_job() succeeded above is already durably committed AND already
* inserted into c->jobmgr — key_find below naturally finds only those
* (a failed element was never inserted, so it is silently absent from
* jlist with no extra bookkeeping needed). Both the scheduler and the
* apiserver are purely event-driven off this hook with no periodic
* reconciliation, so returning early here (as a previous version of
* this function did whenever ANY sibling failed to construct) would
* strand the successfully constructed siblings — real, durable jobs —
* invisible until the next controller restart replays the WAL. A
* whole-batch construction failure must never suppress notification for
* the elements that individually succeeded. */
xlist_init(&jlist, submit->array_size);
for (i = 0; i < submit->array_size; i++) {
lock(c->jobmgr, RTTI_LOCK_READ);
rc = key_find(c->jobmgr, (void*)(jobid + i), (void**)&j);
if (j != NULL) {
reference(j);
}
unlock(c->jobmgr);
if ((rc <= 0) || (j == NULL)) {
char idstring[128];
fmt_jobid(idstring, sizeof(idstring) - 1, jobid);
ERROR("%s fail to lock job %s (%lu)", __func__, idstring, jobid);
} else {
if (xlist_store(&jlist, j) < 0) {
PERROR("xlist_store");
}
}
}
/* Committed + constructed — fire the AFTER_EVENT hook as NOTIFICATION
* only (scheduler push, apiserver SSE), observing already-committed,
* already-applied state. Its return no longer gates durability: a
* notify-hook failure cannot un-commit an already-durable job, so it is
* logged but never fails the client reply. Fired for every
* successfully constructed element regardless of construct_rc (see the
* comment above jlist's construction). */
int notify_rc = xce_run_hooks(&(c->hooksys), XCE_CONTROLLER_HOOK_JOB,
HOOK_AFTER_EVENT,
HOOK_DETAIL_JOB_NEW, c, &jlist);
if (notify_rc != 0)
WARN("handle_jobsubmit: post-commit notification hook rc=%d "
"(job already durable; not failing the reply)", notify_rc);
xlist_iter(&jlist, (xlist_iter_fn_t)xlist_deref_job_item_, NULL);
xlist_fini(&jlist);
if (construct_rc < 0) {
/* At least one array element durably committed but could not be
* constructed (see the ERROR logged at the point of failure above).
* The client learns the submission did not fully succeed; the
* siblings that DID construct successfully were already notified
* above and must never have that notification gated on this. */
reply.status = construct_rc;
reply.data_which = RequestReply_data_nodata;
xce_reply(ep, c->myhost, &reply);
return construct_rc;
}
/* Reply success only AFTER WAL write + majority commit + construction. */
reply.status = 0;
reply.data_which = RequestReply_data_jobid;
reply.data.jobid = jobid;
xce_reply(ep, c->myhost, &reply);
/* The calling thread returns here, before any payload's commit outcome
* is necessarily known (raft-apply-decoupling task 3.2, Req 1.1, 1.4,
* 2.1, 2.3) — every reply from this point on is sent by
* jobsubmit_reply_failure_once or (once task 6 exists)
* jobsubmit_apply_complete, off this stack frame. */
return 0;
}

View file

@ -38,6 +38,20 @@ extern int worker_from_capnp(cluster_t* c,
extern int restore_queue_state(cluster_t* c,
const char* name, uint32_t status);
/* raft-apply-decoupling task 3.2: pending command-apply hand-off list node
* (producer side; the not-yet-built ordered apply pump, task 6.1/6.2, is
* the consumer). Declared here (rather than persist.h) so the list stays
* an implementation detail — persist.h exposes only the opaque `void*`
* head field plus persist_register_pending_apply/persist_has_pending_apply.
* See persist.h's doc comment on persist_register_pending_apply for the
* full contract. */
typedef struct pending_command_apply {
uint64_t lsn;
pending_apply_notify_fn on_applied;
void* ctx;
struct pending_command_apply* next;
} pending_command_apply_t;
/* ---- Cap'n Proto serialization helpers (WAL payload encoding) ---------- */
/* Serialize xce_job_t to a malloc'd Cap'n Proto buffer.
@ -617,6 +631,12 @@ int persist_init(persist_t* p, persist_config_t* cfg, cluster_t* c)
p->term_index_count = 0;
p->term_index_cap = 0;
/* Pending command-apply hand-off list starts empty (raft-apply-
* decoupling task 3.2): no committed-outcome continuations have
* registered yet. Freed in persist_fini. */
p->pending_applies = NULL;
pthread_mutex_init(&p->pending_applies_lock, NULL);
/* Create WAL and snapshot directories */
if (mkdir_p(p->wal_path, 0700) != 0) return -errno;
if (mkdir_p(p->snap_path, 0700) != 0) return -errno;
@ -723,6 +743,23 @@ void persist_fini(persist_t* p)
p->term_index = NULL;
p->term_index_count = 0;
p->term_index_cap = 0;
/* Free any pending command-apply hand-off entries never drained by a
* (not-yet-built) task 6 apply pump (raft-apply-decoupling task 3.2).
* Each node's `ctx` is opaque to persist.c (owned by the registering
* caller, e.g. job.c's jobsubmit_async_ctx_t) — freeing the node here
* does NOT free ctx; a caller shutting down with entries still pending
* is responsible for its own ctx lifetime (test harnesses that tear
* down mid-flight must account for this). */
pending_command_apply_t* pn =
(pending_command_apply_t*)p->pending_applies;
while (pn != NULL) {
pending_command_apply_t* next = pn->next;
free(pn);
pn = next;
}
p->pending_applies = NULL;
pthread_mutex_destroy(&p->pending_applies_lock);
}
/* Forward declaration — defined after checkpoint internals */
@ -3706,6 +3743,194 @@ int persist_submit_job_payloads_and_commit(persist_t* p, xlist_t* payloads,
return rc;
}
/* raft-apply-decoupling task 3.2: append-only counterpart of
* submit_job_wal_entry_and_commit — writes the entry via the SAME
* primary-append primitive wal_hook_append_classified already uses for
* RETAIN-mode facts (ha_primary_log_append / persist_wal_async), WITHOUT
* waiting on the majority-commit barrier. Deliberately does NOT reuse
* submit_job_wal_entry_and_commit itself: that helper is shared with
* persist_submit_wal_and_commit's still-synchronous, still-blocking
* contract (relied on directly by test_egress_filter.c / test_e2e_hooks.c /
* test_persist_hooks.c), which this task must not disturb. */
static int append_job_wal_entry_local(persist_t* p, const xce_job_t* job,
uint64_t* out_lsn)
{
uint8_t* buf = NULL;
size_t sz = 0;
int rc;
uint64_t lsn;
if (capnp_encode_job(job, &buf, &sz) != 0)
return -ENOMEM;
/* Snapshot the LSN this entry will receive BEFORE the append (same
* convention as submit_job_wal_entry_and_commit / wal_hook_append_
* classified: persist_wal_async / ha_primary_log_append assigns
* hdr.lsn = current_lsn then increments). */
pthread_mutex_lock(&p->lock);
lsn = p->current_lsn;
pthread_mutex_unlock(&p->lock);
if (p->cluster != NULL)
rc = ha_primary_log_append(p->cluster, WAL_JOB_SUBMIT, buf,
(uint32_t)sz, NULL);
else
rc = persist_wal_async(p, WAL_JOB_SUBMIT, buf, (uint32_t)sz);
free(buf);
if (rc == 0)
*out_lsn = lsn;
return rc;
}
/* raft-apply-decoupling task 3.2: bridges commit_submit_async's
* commit_continuation_fn shape (commit_outcome_t + rc) to
* persist_job_commit_notify_fn's shape (a single rc) so persist.h's public
* declaration never needs commit.h's commit_outcome_t (see persist.h's
* doc comment on persist_job_commit_notify_fn). One adapter is heap-
* allocated per registered lsn and frees itself after its single
* invocation — safe because commit_resolve guarantees exactly-once
* delivery. */
typedef struct {
persist_job_commit_notify_fn fn;
void* ctx;
} job_commit_adapter_ctx_t;
static void job_commit_resolve_adapter(void* adapter_ctx, uint64_t lsn,
commit_outcome_t outcome, int rc)
{
job_commit_adapter_ctx_t* a = (job_commit_adapter_ctx_t*)adapter_ctx;
int final_rc = (outcome == COMMIT_OUTCOME_COMMITTED) ? 0 : rc;
a->fn(a->ctx, lsn, final_rc);
free(a);
}
int persist_submit_job_payloads_async(persist_t* p, xlist_t* payloads,
uint64_t* out_lsns,
persist_job_commit_notify_fn fn,
void* ctx)
{
int first_rc = 0;
if (p == NULL || payloads == NULL || out_lsns == NULL || fn == NULL)
return -EINVAL;
for (int i = 0; i < payloads->count; i++) {
xce_job_t* j = (xce_job_t*)xlist_get(payloads, i);
uint64_t lsn = 0;
int rc;
if (first_rc != 0) {
/* A prior payload in this batch already failed locally: every
* remaining payload resolves with that SAME failure now,
* synchronously — so `fn` is invoked exactly once per payload
* regardless of where the failure occurred, and the caller
* never has to separately track "never going to be called"
* entries. */
fn(ctx, 0, first_rc);
continue;
}
rc = append_job_wal_entry_local(p, j, &lsn);
if (rc != 0) {
first_rc = rc;
fn(ctx, 0, rc);
continue;
}
out_lsns[i] = lsn;
/* LSN-0 edge (documented once-per-cluster-lifetime interim
* behavior — ha-commit-engine Implementation Notes, mirrored by
* repl_wait_majority_ack's walAck branch): the first-ever WAL
* entry of a fresh cluster is structurally un-commitable and
* commit_submit_async rejects lsn == 0, so resolve it inline.
* Engine-not-wired (bootstrap / engine-less contexts) mirrors
* persist_commit_barrier's own "no engine -> no-op success"
* convention — there is no async completion authority to
* register with. */
if (lsn == 0 || p->repl.ce == NULL) {
fn(ctx, lsn, 0);
continue;
}
job_commit_adapter_ctx_t* a = calloc(1, sizeof(*a));
if (a == NULL) {
first_rc = -ENOMEM;
fn(ctx, lsn, -ENOMEM);
continue;
}
a->fn = fn;
a->ctx = ctx;
int reg_rc = commit_submit_async(p->repl.ce, lsn,
job_commit_resolve_adapter, a);
if (reg_rc != 0) {
free(a);
first_rc = reg_rc;
ERROR("persist_submit_job_payloads_async: lsn=%lu appended but "
"commit_submit_async registration failed (rc=%d) -- the "
"entry remains durable and will still be caught up "
"eventually via replay, but this caller will not be told "
"its outcome via this path",
(unsigned long)lsn, reg_rc);
fn(ctx, lsn, reg_rc);
continue;
}
}
return first_rc;
}
void persist_register_pending_apply(persist_t* p, uint64_t lsn,
pending_apply_notify_fn on_applied,
void* ctx)
{
pending_command_apply_t* node;
if (p == NULL || on_applied == NULL)
return;
node = calloc(1, sizeof(*node));
if (node == NULL) {
ERROR("persist_register_pending_apply: OOM registering the apply "
"hand-off for lsn=%lu -- the entry is still durably committed "
"and will still be applied by a future restart replay, but no "
"continuation will fire for it via this path",
(unsigned long)lsn);
return;
}
node->lsn = lsn;
node->on_applied = on_applied;
node->ctx = ctx;
pthread_mutex_lock(&p->pending_applies_lock);
node->next = (pending_command_apply_t*)p->pending_applies;
p->pending_applies = node;
pthread_mutex_unlock(&p->pending_applies_lock);
}
int persist_has_pending_apply(persist_t* p, uint64_t lsn)
{
pending_command_apply_t* n;
int found = 0;
if (p == NULL)
return 0;
pthread_mutex_lock(&p->pending_applies_lock);
for (n = (pending_command_apply_t*)p->pending_applies; n != NULL;
n = n->next) {
if (n->lsn == lsn) {
found = 1;
break;
}
}
pthread_mutex_unlock(&p->pending_applies_lock);
return found;
}
/* P0 payload: just the detail value (4 bytes).
* Full Cap'n Proto serialization is wired in Task 15. */

View file

@ -834,13 +834,23 @@ static void test_worker_join_no_hostconfig_error_reply_immediate(void)
printf("test_worker_join_no_hostconfig_error_reply_immediate: PASS\n");
}
/* ── handle_jobsubmit (raft-apply-decoupling task 3.1: apply-after-commit) ──
/* ── handle_jobsubmit (raft-apply-decoupling task 3.1/3.2) ───────────────
*
* Req 2.1/2.3: a job submission whose commit fails or times out must leave
* NO job object in c->jobmgr — construction is deferred until after the
* commit barrier succeeds. Req 2.2: a successful submission still replies
* synchronously with the jobid, and by the time that reply is observable the
* job is already visible in c->jobmgr. */
* task 3.1 (Req 2.1/2.3): a job submission whose commit fails or times out
* must leave NO job object in c->jobmgr — construction is deferred until
* after the commit barrier succeeds.
*
* task 3.2 (Req 1.1, 1.4, 2.1, 2.3): the synchronous commit call from 3.1 is
* replaced with a registration against the commit engine's continuation
* mechanism (commit_submit_async, via persist_submit_job_payloads_async).
* handle_jobsubmit now returns BEFORE any commit outcome is known. A
* failed/timed-out outcome still replies failure directly, with no job ever
* constructed (same observable as 3.1). A COMMITTED outcome is handed off to
* the not-yet-built ordered apply pump (task 6.1/6.2) via
* persist_register_pending_apply() instead of applying or replying directly
* — so a successful submission has NO independently observable behavior
* (no reply, no job in c->jobmgr) until task 6 exists; the test below
* asserts only that the hand-off itself was registered for the correct lsn. */
static job_startinfo_t g_jobsub_startinfo;
static job_submit_data_t g_jobsub_data;
@ -902,121 +912,24 @@ static void test_jobsubmit_withheld_acks_no_phantom_job(void)
printf("test_jobsubmit_withheld_acks_no_phantom_job: PASS\n");
}
static void test_jobsubmit_success_reply_and_job_visible_after_commit(void)
/* raft-apply-decoupling task 3.2: supersedes the pre-3.2
* test_jobsubmit_success_reply_and_job_visible_after_commit (which drove
* handle_jobsubmit through cbrp_drive_acked, a helper that asserts the
* handler blocks until its reply is observable — no longer true here by
* this task's own explicit contract). This test asserts ONLY what task 3.2
* itself makes observable on the committed-outcome path: the calling
* thread returns before the commit resolves, and the continuation hands
* the entry off to persist's pending-apply registry keyed by the correct
* lsn — NOT a reply, NOT a constructed job (task 6 owns both; neither
* exists yet). */
static void test_jobsubmit_returns_before_commit_and_registers_pending_apply(void)
{
cbrp_setup(1500);
uint64_t lsn_before, watermark, t0, join_elapsed_ms, t1;
cbrp_reply_obs_t obs = { 0 };
bool has_pending, job_exists;
cbrp_setup(2000); /* generous timeout: must never be needed */
cbrp_arrange_jobsubmit(9101, 1);
cbrp_drive_acked(handle_jobsubmit, &g_jobsub_req);
TEST_ASSERT_TRUE_MESSAGE(cbrp_job_exists(9101),
"a successful job submission must construct the job object once "
"its commit succeeds, and still reply synchronously with the "
"jobid (raft-apply-decoupling task 3.1, Req 2.2)");
cbrp_teardown();
printf("test_jobsubmit_success_reply_and_job_visible_after_commit: PASS\n");
}
/* Captures every jobid delivered via the AFTER_EVENT/HOOK_DETAIL_JOB_NEW
* notification hook (job.c's post-commit notify list). Needed by the
* array-partial-construction-failure remediation test below to observe
* that a durably committed AND successfully constructed sibling is still
* notified even when another array element in the same submission fails to
* construct (raft-apply-decoupling task 3.1 remediation). xce_run_hooks
* also fires this same hook TYPE for HOOK_BEFORE_EVENT with a different
* vararg shape (xce_job_submit_t*, not xlist_t*) — filtered out below
* before touching ap. */
#define CBRP_MAX_JOBNEW_NOTIFY 8
static int g_jobnew_notify_count;
static uint64_t g_jobnew_notify_jobids[CBRP_MAX_JOBNEW_NOTIFY];
static void cbrp_jobnew_capture_reset(void)
{
g_jobnew_notify_count = 0;
memset(g_jobnew_notify_jobids, 0, sizeof(g_jobnew_notify_jobids));
}
static int cbrp_jobnew_capture_fn(int type, int situation, int detail,
void *data, va_list ap)
{
(void)type; (void)data;
if (detail != HOOK_DETAIL_JOB_NEW || situation != HOOK_AFTER_EVENT)
return 0;
{
xlist_t *jlist;
int i;
(void)va_arg(ap, cluster_t *);
jlist = va_arg(ap, xlist_t *);
if (jlist == NULL)
return 0;
for (i = 0; i < jlist->count
&& g_jobnew_notify_count < CBRP_MAX_JOBNEW_NOTIFY; i++) {
job_t *j = (job_t *)xlist_get(jlist, i);
if (j != NULL)
g_jobnew_notify_jobids[g_jobnew_notify_count++] = j->job.jobid;
}
}
return 0;
}
static bool cbrp_jobnew_notified(uint64_t jobid)
{
int i;
for (i = 0; i < g_jobnew_notify_count; i++) {
if (g_jobnew_notify_jobids[i] == jobid)
return true;
}
return false;
}
/* Array-partial-construction-failure remediation (round-2 fix, task 3.1):
* when ONE array element's post-commit create_job() fails (a jobid
* collision that slipped past jobid_range_exists()'s pre-check, or any
* other construct-time failure), the OTHER array elements that constructed
* successfully — and are already durably committed — must still fire the
* AFTER_EVENT/JOB_NEW notification hook. Both scheduler and apiserver are
* purely event-driven off this hook with no periodic reconciliation, so a
* withheld notification strands an otherwise-healthy, already-durable job
* until the next controller restart replays the WAL.
*
* The colliding element's failure is reproduced deterministically (no real
* race) by exploiting the actual TOCTOU window between
* jobid_range_exists()'s pre-check (a READ-locked snapshot taken BEFORE any
* WAL work) and makeJob_'s own key_find (a WRITE-locked check made fresh at
* construction time, AFTER the commit barrier): a stand-in job for the
* second array element's jobid is inserted directly into c->jobmgr from the
* test thread strictly after the pre-check has already run (its own WAL
* entry is visible, which cannot happen until after the pre-check passes)
* and strictly before the construction loop can run (its commit is still
* pending on a withheld ACK). */
static void test_jobsubmit_array_partial_construct_failure_notifies_survivors(void)
{
cbrp_reply_obs_t obs = { 0 };
uint64_t lsn_before, wm0, wm1;
job_submit_data_t collide_submit;
bool elem0_wal_appeared, elem1_wal_appeared;
bool elem0_exists, elem0_notified;
/* Every TEST_ASSERT_* below is deferred until after cbrp_teardown().
* A failing Unity assertion longjmps out of this function (TEST_ABORT),
* which would otherwise skip cbrp_teardown() and leak live
* persist/commit-engine background threads into the NEXT test's setup,
* deadlocking the whole group (see bug-1408's round-2 correction: this
* exact failure mode was originally reproduced as a hang, not a clean
* FAIL, under the standard --run test_commit_barrier_reply invocation).
* Capture every observable into a local first, teardown unconditionally,
* then assert on the captured locals. */
cbrp_setup(2000);
cbrp_jobnew_capture_reset();
TEST_ASSERT_EQUAL_INT(0, xce_add_hook(&g_hc.hooksys, XCE_CONTROLLER_HOOK_JOB,
cbrp_jobnew_capture_fn, NULL));
cbrp_arrange_jobsubmit(9200, 2); /* 9200 survives, 9201 collides */
pthread_mutex_lock(&g_hc.persist.lock);
lsn_before = g_hc.persist.current_lsn;
@ -1028,70 +941,146 @@ static void test_jobsubmit_array_partial_construct_failure_notifies_survivors(vo
TEST_ASSERT_EQUAL_INT(0, pthread_create(&g_op_tid, NULL,
cbrp_op_thread_fn, &g_op_arg));
/* Element 0's (jobid 9200) WAL entry appears first; ACK it so the
* sequential payload loop advances to element 1. The settling sleep
* before each ACK mirrors cbrp_drive_acked's proven pattern — it gives
* the handler thread time to actually register its commit wait for the
* just-appended LSN before the ACK is delivered. */
wm0 = cbrp_poll_append(&g_hc.persist, lsn_before, 2000);
elem0_wal_appeared = (wm0 > 0);
cbrp_sleep_ms(150);
if (elem0_wal_appeared)
repl_on_ack(&g_hc.persist.repl, &g_hep_a, wm0);
/* No ACK is delivered before this join. If handle_jobsubmit still
* blocked on the majority-commit barrier (the pre-3.2 behavior), this
* join could not return before the 2000ms sync_timeout elapses; a fast
* return proves registration replaced blocking (Req 1.1, 1.4). */
t0 = cbrp_mono_ms();
TEST_ASSERT_EQUAL_INT(0, pthread_join(g_op_tid, NULL));
join_elapsed_ms = cbrp_mono_ms() - t0;
/* Element 1's (jobid 9201) WAL entry appears once element 0 commits.
* Its own ACK is deliberately withheld until the collision stand-in
* below is in place. cbrp_poll_append's lsn_before is a current_lsn
* SNAPSHOT (see its first use above, seeded from persist.current_lsn),
* not an entry's assigned LSN — element 0 already advanced current_lsn
* to wm0 + 1, so that (not wm0 itself) is the correct baseline to wait
* past for element 1's append. */
wm1 = cbrp_poll_append(&g_hc.persist, wm0 + 1, 2000);
elem1_wal_appeared = (wm1 > wm0);
cbrp_sleep_ms(150);
watermark = cbrp_poll_append(&g_hc.persist, lsn_before, 2000);
/* Deterministic TOCTOU-outcome injection (see block comment above). */
memset(&collide_submit, 0, sizeof(collide_submit));
cbrp_make_job(9201, &collide_submit);
/* Deliver the covering ACK, then poll for the hand-off artifact
* (raft-apply-decoupling task 3.2's producer-side registration) —
* NOT for a reply or a constructed job, neither of which this task
* produces on the committed-outcome path. */
repl_on_ack(&g_hc.persist.repl, &g_hep_a, watermark);
if (elem1_wal_appeared)
repl_on_ack(&g_hc.persist.repl, &g_hep_a, wm1);
has_pending = false;
t1 = cbrp_mono_ms();
while (cbrp_mono_ms() - t1 < 2000) {
if (persist_has_pending_apply(&g_hc.persist, watermark)) {
has_pending = true;
break;
}
cbrp_sleep_ms(5);
}
cbrp_poll_reply(&g_req_ep, 2000, &obs);
pthread_join(g_op_tid, NULL);
elem0_exists = cbrp_job_exists(9200);
elem0_notified = cbrp_jobnew_notified(9200);
/* Give the (non-existent) rest of the pipeline a beat, then confirm
* nothing leaked through — the task's own explicit "no independently
* observable behavior" contract for the committed-outcome path. */
cbrp_sleep_ms(200);
cbrp_scan_replies(&g_req_ep, &obs);
job_exists = cbrp_job_exists(9101);
cbrp_teardown();
TEST_ASSERT_TRUE_MESSAGE(elem0_wal_appeared,
"array element 0's WAL entry must appear");
TEST_ASSERT_TRUE_MESSAGE(elem1_wal_appeared,
"array element 1's WAL entry must appear");
TEST_ASSERT_TRUE_MESSAGE(join_elapsed_ms < 500,
"handle_jobsubmit must return before any commit outcome is known "
"(raft-apply-decoupling task 3.2, Req 1.1/1.4) — no ACK was ever "
"delivered before this join");
TEST_ASSERT_TRUE_MESSAGE(watermark > 0,
"job submission must still append its WAL entry");
TEST_ASSERT_TRUE_MESSAGE(has_pending,
"a committed entry must be handed off to the ordered apply pump "
"via persist_register_pending_apply, keyed by its lsn "
"(raft-apply-decoupling task 3.2)");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, obs.n_replies,
"no reply may be sent on the committed-outcome path until task 6's "
"apply pump exists (raft-apply-decoupling task 3.2's own contract)");
TEST_ASSERT_FALSE_MESSAGE(job_exists,
"no job may be constructed on the committed-outcome path until "
"task 6's apply pump exists (raft-apply-decoupling task 3.2)");
TEST_ASSERT_TRUE_MESSAGE(obs.n_replies > 0,
"the requester must still receive a reply when one array element "
"fails post-commit construction");
TEST_ASSERT_TRUE_MESSAGE(obs.last_status < 0,
"a partially-failed array submission must report the construction "
"failure to the client");
TEST_ASSERT_TRUE_MESSAGE(elem0_exists,
"the non-colliding array element must still be constructed and "
"present in c->jobmgr despite its sibling failing to construct");
TEST_ASSERT_TRUE_MESSAGE(elem0_notified,
"the successfully constructed sibling must fire the AFTER_EVENT/"
"JOB_NEW notification hook even though array element 9201 failed "
"to construct post-commit (raft-apply-decoupling task 3.1 "
"remediation) — otherwise it is durably committed and constructed "
"but invisible to the purely event-driven scheduler/apiserver "
"until the next controller restart replays the WAL");
printf("test_jobsubmit_array_partial_construct_failure_notifies_survivors:"
printf("test_jobsubmit_returns_before_commit_and_registers_pending_apply:"
" PASS\n");
}
/* raft-apply-decoupling task 3.2: the failure/timeout path must ALSO prove
* the handler returns immediately — cbrp_drive_withheld (used by
* test_jobsubmit_withheld_acks_no_phantom_job below) only proves the
* EVENTUAL reply/no-phantom-job observable, not that the calling thread
* stopped blocking well before the commit timeout elapses. */
static void test_jobsubmit_returns_before_commit_on_failure_path_too(void)
{
uint64_t lsn_before, watermark, t0, join_elapsed_ms;
cbrp_reply_obs_t obs = { 0 };
bool job_exists;
cbrp_setup(1000);
cbrp_arrange_jobsubmit(9104, 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;
TEST_ASSERT_EQUAL_INT(0, pthread_create(&g_op_tid, NULL,
cbrp_op_thread_fn, &g_op_arg));
/* No ACK is EVER delivered in this test. The 1000ms sync_timeout_ms is
* how long the ENTRY takes to resolve as FAILED — the HANDLER itself
* must return immediately, well before that timeout. */
t0 = cbrp_mono_ms();
TEST_ASSERT_EQUAL_INT(0, pthread_join(g_op_tid, NULL));
join_elapsed_ms = cbrp_mono_ms() - t0;
watermark = cbrp_poll_append(&g_hc.persist, lsn_before, 500);
/* Let the timeout sweep resolve the entry FAILED and confirm the
* (eventual) failure reply + no-phantom-job observable — same
* contract test_jobsubmit_withheld_acks_no_phantom_job already pins,
* verified here alongside the non-blocking-return proof. */
cbrp_poll_reply(&g_req_ep, 1000 + 1500, &obs);
job_exists = cbrp_job_exists(9104);
cbrp_teardown();
TEST_ASSERT_TRUE_MESSAGE(join_elapsed_ms < 500,
"handle_jobsubmit must return immediately after registering its "
"continuation, without waiting for the commit timeout to elapse "
"(raft-apply-decoupling task 3.2, Req 1.1/1.4)");
TEST_ASSERT_TRUE_MESSAGE(watermark > 0,
"job submission must still append its WAL entry before the "
"handler returns");
TEST_ASSERT_TRUE_MESSAGE(obs.n_replies > 0,
"a forced timeout must still eventually reply to the client");
TEST_ASSERT_EQUAL_INT_MESSAGE(-ETIMEDOUT, obs.last_status,
"a timed-out commit must reply with -ETIMEDOUT, the same "
"vocabulary the synchronous path already used (Req 2.3)");
TEST_ASSERT_FALSE_MESSAGE(job_exists,
"no job may ever be constructed for an entry whose commit timed "
"out (raft-apply-decoupling task 3.2, Req 2.3)");
printf("test_jobsubmit_returns_before_commit_on_failure_path_too: 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
* — it ACKed array element 0 and only then observed element 1's WAL entry
* appear, exploiting that strict ordering to inject a deterministic jobid
* collision between the two elements' commits. Task 3.2 appends every array
* element back-to-back, non-blocking, with no per-element ACK gate between
* them, so that ordering assumption no longer holds — the test's whole
* premise is gone, not just its expected outcome.
*
* More importantly, the CODE this test exercised (handle_jobsubmit's own
* post-commit create_job()-loop + jlist-build + xce_run_hooks(AFTER_EVENT)
* block) no longer exists in job.c at all (see the "REMOVED" comment above
* handle_jobsubmit's async registration block) — it is superseded, not
* relocated, by apply_committed_entry()'s existing WAL_JOB_SUBMIT case
* (persist.c, task 2.2), which already does restore_job() + fires
* HOOK_DETAIL_JOB_NEW per entry, independently per array element, and is
* therefore structurally immune to the bug-1408 class already. Coverage for
* that invariant now belongs to persist.c's own apply-seam test suite
* (test_apply_seam.c), not here. See this task's status report for the
* full reasoning; git history (commit 6f1fa97) preserves the removed test
* verbatim. */
/* ════════════════════════════════════════════════════════════════════════
* Receipt-ACK verdict pins (ha-commit-barrier-rollout task 3.3, Req 2.3)
*
@ -2133,8 +2122,8 @@ void test_commit_barrier_reply(void)
RUN_TEST(test_worker_join_no_hostconfig_error_reply_immediate);
/* handle_jobsubmit (raft-apply-decoupling task 3.1, Req 2.1/2.2/2.3) */
RUN_TEST(test_jobsubmit_withheld_acks_no_phantom_job);
RUN_TEST(test_jobsubmit_success_reply_and_job_visible_after_commit);
RUN_TEST(test_jobsubmit_array_partial_construct_failure_notifies_survivors);
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 */
RUN_TEST(test_jobdispatch_receipt_ack_no_wal_append);
RUN_TEST(test_set_preason_receipt_reply_fact_retained);