feat(raft-apply-decoupling): ordered live apply pump activates apply_committed_entry (task 6.1)

Build persist_apply_pump_run()/persist_apply_pump_kick() -- the
ordered walk from last_applied_lsn+1 through committed_lsn, applying
every entry via apply_committed_entry() regardless of commit mode.
This is the first live (non-replay) consumer of that dispatch
function. Kick is wired into commit_resolve_pending() (the single
choke point all 6 internal committed_lsn-advancing paths funnel
through, discovered in task 1.2), dispatched via
vtask_enq_classified(VTASK_LIVE_BLOCK) with a CAS-coalesced
fire-and-forget trigger.

Fixes the two hazards flagged since task 4.1 as required before
going live:

1. Double-append: a thread-local call-provenance guard
   (t_apply_seam_notifying) prevents persist_job_hook_fun/
   persist_queue_hook_fun/persist_worker_hook_fun from re-appending
   entries the apply pump itself just applied, while preserving
   job_ctrl's legitimate live command-path appends (a naive
   detail-based filter would have broken SIGCONT/SIGSTOP). Also
   closes an undocumented instance of the same hazard in the
   queue/worker hook functions.

2. Merge-staleness: record_job_fail_fact/postdone's fact recording
   previously baked a receipt-time-computed final job state into the
   WAL entry, which goes stale under deferred apply -- a FINISH
   followed shortly by an ordinary post-exec FAIL could silently
   clobber a correct DONE completion with a stale RUN|EXIT|PERR, no
   guard catching it. Fixed by repurposing wal_jobstate_t's unused
   _pad field to carry just enough data (FAIL's stage) to recompute
   against the job's actual current state at apply time via the real
   apply_job_fail_transition()/new apply_job_postdone_transition()
   guard cores, instead of overwriting with a stale absolute value.
   Backward compatible: _pad==0 (all prior data) routes to the same
   legacy overwrite behavior.

Also fixes bug-1409 (unbounded busy-loop in apply_pump_drain when
committed_lsn advances without a corresponding on-disk entry) and a
genuine test-flakiness hazard (a second, independent apply-pump
kicker from the fsync_worker thread that existing tests didn't
account for).

Two pre-existing tests in test_commit_barrier_reply.c now fail as
expected -- their own assertions state "no job may be constructed
until task 6's apply pump exists," now correctly invalidated by this
task landing; updating them is task 7.3's explicit job.
This commit is contained in:
Rongsong Shen 2026-07-04 19:00:43 +00:00
parent d3da3ca8bd
commit 2fa955b92e
11 changed files with 1687 additions and 135 deletions

View file

@ -387,6 +387,20 @@ struct persist_s {
* persist_register_pending_apply's doc comment for the full contract. */
void* pending_applies;
pthread_mutex_t pending_applies_lock;
/* raft-apply-decoupling task 6.1: ordered apply pump. apply_pump_lock
* serializes concurrent walk passes -- multiple commit-advance triggers
* (from any of the six commit.c paths that can move committed_lsn) can
* race to kick the pump; only one walk actually runs at a time (see
* persist_apply_pump_run's doc comment). apply_pump_scheduled is a
* CAS-coalescing flag: at most one vtask-dispatched walk is ever
* in-flight per persist_t regardless of how many trigger events land
* concurrently, so a commit burst cannot flood the vtask pool with
* mostly-redundant walk requests. Neither field is persisted -- both
* reset fresh on process restart (restart applies via replay, not this
* pump; see design.md "Restart Replay & Verification"). */
pthread_mutex_t apply_pump_lock;
_Atomic int apply_pump_scheduled;
#ifdef XCE_TESTING
/* Fault-injection seam (ha-commit-durability task 2.1 error-path test):
* when non-zero, every fdatasync site treats the sync as FAILED — it
@ -945,6 +959,93 @@ void persist_register_pending_apply(persist_t* p, uint64_t lsn,
* and remove"), not built by this task. */
int persist_has_pending_apply(persist_t* p, uint64_t lsn);
/* raft-apply-decoupling task 6.1: "take and remove" counterpart to
* persist_register_pending_apply()/persist_has_pending_apply() (task 3.2),
* built for task 6.2 to consume — task 3.2 explicitly left this accessor
* unbuilt ("task 6.1 needs its own 'take and remove' accessor, not built
* here"). Atomically finds the pending-apply hand-off node registered for
* `lsn` (if any), unlinks and frees the list node, and returns its
* `on_applied`/`ctx` pair via the output parameters.
*
* Returns 1 and populates *out_fn and *out_ctx when a registration was found
* and removed; returns 0 and leaves *out_fn and *out_ctx untouched when no
* registration exists for `lsn` (the common case for a fact entry, which
* never registers one).
*
* Contract for task 6.2: call this AFTER apply_committed_entry() has already
* applied `lsn` — this accessor does not itself apply anything, it is pure
* bookkeeping over the hand-off list. When it returns 1, invoke
* `(*out_fn)(*out_ctx, lsn)` exactly once; a second call for the same lsn is
* guaranteed to return 0 (the node is already removed), so the hand-off
* fires exactly once per registration. Safe to call for every applied lsn
* unconditionally (facts and commands alike): it is a cheap linked-list scan
* under pending_applies_lock and a correct no-op (returns 0) when nothing is
* registered. Not yet called by anything — task 6.2's job, not this one's. */
int persist_take_pending_apply(persist_t* p, uint64_t lsn,
pending_apply_notify_fn* out_fn, void** out_ctx);
/* raft-apply-decoupling task 6.1: strict-order walk that applies every newly
* committed entry, from persist_last_applied_lsn()+1 up to p->committed_lsn
* inclusive, invoking apply_committed_entry() for each in ascending LSN
* order — regardless of the entry's commit mode (WAIT or RETAIN): a command
* entry that is *also* still tracked in the pending_applies hand-off list
* (task 3.2) is applied exactly like any other entry here; task 6.2 (not
* built by this function) is responsible for separately calling
* persist_take_pending_apply() and firing that entry's reply continuation
* after this function (or whatever drives it) applies it.
*
* Synchronous, single pass: walks until no further committed-but-unapplied
* entry exists (as of the moment each check is made), then returns. Safe to
* call directly — e.g. from a test, without ever going through
* persist_apply_pump_kick's async dispatch — serializing against any
* concurrent walk via a dedicated lock (never `p->lock`: this performs
* blocking disk reads, and `p->lock` is the hot single-writer append lock —
* see the lock-ordering invariant, persist.c ~3347-3350).
*
* Returns the number of entries applied (>= 0) on success, or a negative
* errno if reading a committed entry's bytes back off the WAL failed — a
* genuine, unexpected condition for an already-committed entry (logged
* loud; per design.md's Error Handling, this is treated as an apply-time
* failure, not a retry-immediately condition). The walk stops at the failed
* LSN; last_applied_lsn is left unadvanced past it, so the SAME entry is
* retried, from the same position, by the next kick — never silently
* skipped. */
int persist_apply_pump_run(persist_t* p);
/* raft-apply-decoupling task 6.1: async trigger for persist_apply_pump_run()
* above. Intended to be called from every commit.c-internal path that can
* advance p->committed_lsn (commit_resolve_pending, after ce->lock is
* released) so the pump reacts to ALL SIX advance paths task 1.2 identified
* (durable-advance, self-quorum commit_submit, ack-driven advance, the
* writable-lsn gate, the raft-library commit-index feed, and membership
* change) — not just the one raft-library-fed path
* (ha_cb_commit_advanced_stub/on_commit_advanced) design.md names
* explicitly, which alone would starve the pump under a single-node/
* self-quorum cluster that never exercises that specific callback.
*
* No-op when p is NULL or p->cluster is NULL (mirrors apply_committed_entry's
* own bare-persist-fixture convention) — every existing commit-engine-only /
* persist-only unit fixture that never wires a cluster_t is completely
* unaffected by this call; the pump only ever activates for a real cluster
* context.
*
* `pool` is a `vtaskmgr_t*` (declared void* here to avoid persist.h
* depending on vtaskmgr.h, mirroring pending_apply_notify_fn's existing
* avoidance of a commit.h dependency) used to dispatch the walk off the
* calling thread — never inline on what may be the replication-ACK/iomgr
* thread — via vtask_enq_classified(VTASK_LIVE_BLOCK): same class and the
* same inline-on-dispatch-failure fallback discipline as commit.c's own
* dispatch_continuation, so a walk is never silently dropped even under OOM
* or a NULL/not-yet-wired pool (a NULL pool falls back to an inline,
* synchronous run — exactly the "commit-only unit fixture" case
* dispatch_continuation's own doc comment already documents).
*
* Never blocks the caller beyond an O(1) CAS + enqueue (the actual walk
* always runs asynchronously on a pool worker, except the documented
* NULL-pool/allocation-failure fallback). Coalesces concurrent kicks: at
* most one walk is ever scheduled or running at a time per persist_t. */
void persist_apply_pump_kick(persist_t* p, void* pool);
int persist_job_hook_fun(int type, int situation, int detail,
void* cls, va_list ap);
int persist_queue_hook_fun(int type, int situation, int detail,
@ -960,13 +1061,29 @@ int persist_queue_hook_fun(int type, int situation, int detail,
* mutation this fact eventually drives, and the AFTER_EVENT notification
* itself, both happen later from apply_committed_entry() once this entry
* commits to a majority (task 2.2's existing dispatch). `state` is the
* caller-computed resultant state (e.g. JOB_STAT_RUN for a START, or the
* PERR-merged value for a FAIL) — this function performs no guard checks or
* merge logic of its own, it only encodes and appends.
* caller-computed resultant state (e.g. JOB_STAT_RUN for a START) — this
* function performs no guard checks or merge logic of its own, it only
* encodes and appends. Thin wrapper over persist_submit_job_fact_ex() with
* pad = 0 (the "legacy absolute state" discriminator — see wal_types.h's
* doc comment on wal_jobstate_t._pad) — unchanged contract, every existing
* caller (START/FINISH/postdone's old shape) keeps working identically.
* Returns the local-append rc (0 on success); facts have no commit timeout
* to report, mirroring wal_hook_append_classified's own contract. */
int persist_submit_job_fact(persist_t* p, uint32_t wal_type, uint64_t jobid,
int state);
/* Full-control sibling of persist_submit_job_fact() (raft-apply-decoupling
* task 6.1 round 2, Req 3.3/7.1-7.3): lets the caller set wal_jobstate_t's
* `_pad` discriminator explicitly, for the two fact producers whose real
* mutation must be re-derived at apply time against the job's CURRENT
* state rather than trusting a receipt-time snapshot (record_job_fail_fact,
* record_job_postdone_fact — see wal_types.h's doc comment on
* wal_jobstate_t._pad for the full rationale). `state` is ignored by
* apply_committed_entry() whenever `pad` selects one of these apply-time-
* recomputed shapes (its value is not part of the wire contract in that
* case, but is still written for forward-compatibility/diagnostics). */
int persist_submit_job_fact_ex(persist_t* p, uint32_t wal_type,
uint64_t jobid, int state, uint32_t pad);
int persist_worker_hook_fun(int type, int situation, int detail,
void* cls, va_list ap);
@ -990,9 +1107,27 @@ int apply_wal_entry(persist_t* p, uint32_t type, uint64_t lsn,
* WAL_JOB_SUBMIT -> restore_job() (existing
* idempotent, jobid-addressable construction primitive; -EEXIST is a
* no-op re-apply, not an error)
* WAL_JOB_{START,FINISH,FAIL,STOP,RESUME,
* UPDATE,RUSAGE,CKPT,PREASON} -> apply_job_update_transition()
* (the guard-only core extracted in task 2.1)
* WAL_JOB_{START,FINISH,STOP,RESUME,
* RUSAGE,CKPT,PREASON} -> apply_job_update_transition()
* (the guard-only core extracted in task 2.1) — these types' payloads
* are deterministic/state-independent, so a `state` value baked at
* receipt time never goes stale
* WAL_JOB_FAIL -> dispatches on wal_jobstate_t.
* _pad (task 6.1 round 2 — see wal_types.h's doc comment on
* wal_jobstate_t._pad): WAL_JOB_FAIL_STAGE_FLAG set -> reconstructs a
* minimal job_failinfo_t{jobid, stage} and calls the REAL
* apply_job_fail_transition() against the job's ACTUAL CURRENT state
* at apply time — fixes a merge-staleness corruption where a FAIL's
* PERR merge depended on prior state that had not actually applied
* yet when the fact was received; _pad == 0 (legacy live path,
* job_ctrl's mark_job_fail) -> apply_job_update_transition() as an
* absolute overwrite, unchanged
* WAL_JOB_UPDATE -> also dispatches on _pad:
* WAL_JOB_UPDATE_POSTDONE_MARK -> apply_job_postdone_transition(),
* which re-derives the postdone merge (conditional EXIT backstop + OR
* JOB_STAT_PDONE) fresh against current state; _pad == 0 (legacy live
* path, job_ctrl's mark_job_update) -> apply_job_update_transition()
* as an absolute overwrite, unchanged
* WAL_QUEUE_CTRL -> restore_queue_state()
* WAL_WORKER_CTRL -> worker_from_capnp()
* and fires the entry type's AFTER_EVENT notification hook exactly once,
@ -1011,12 +1146,14 @@ int apply_wal_entry(persist_t* p, uint32_t type, uint64_t lsn,
* `.data = { .pidinfo = info }`) inside `case HOOK_AFTER_EVENT:`, without
* dereferencing any of its fields at this call site — unlike sched.c's
* unconditional `info->jobid` access in a top-level struct initializer).
* Every job-state fact type therefore routes through the
* guard-only apply_job_update_transition() core and fires
* Every job-state fact type therefore fires the same
* HOOK_DETAIL_JOB_UPDATE, whose subscribers take only (cluster_t*, job_t*) —
* matching what this wire format can actually supply without fabricating
* data. No task in the current plan (2.3, 4.1, 4.2, 6.1, 6.2, 6.3) closes
* this gap — task 4.1 is exclusively about removing state mutation/hook-
* data — regardless of which of the guard-only cores above (apply_job_
* update_transition / apply_job_fail_transition / apply_job_postdone_
* transition) actually performed the mutation. No task in the current plan
* (2.3, 4.1, 4.2, 6.1, 6.2, 6.3) closes this hook-granularity gap — task 4.1
* is exclusively about removing state mutation/hook-
* firing from worker-fact receipt-time handlers, not about enriching the
* wire format. Closing it would require either a design.md correction
* (formally accepting this HOOK_DETAIL_JOB_UPDATE substitution as final —

View file

@ -17,6 +17,43 @@ typedef struct {
uint32_t _pad;
} wal_jobstate_t;
/* wal_jobstate_t._pad discriminator for WAL_JOB_FAIL / WAL_JOB_UPDATE
* (raft-apply-decoupling task 6.1 round 2 — fixes a job-state corruption
* bug). Both types have TWO producers:
*
* - A LIVE, synchronous, mutate-then-notify path (job_ctrl's
* mark_job_fail/mark_job_update): the mutation and the WAL-state capture
* happen back-to-back under the same job write-lock, so `state` is
* already correct and absolute by the time it is written — no staleness
* window exists. This path always leaves _pad == 0 (it zero-inits
* wal_jobstate_t and never sets it).
*
* - A DURABLE-APPEND-ONLY, deferred-apply path (record_job_fail_fact /
* record_job_postdone_fact in job.c): the real mutation depends on the
* job's CURRENT state at the moment it is computed. Precomputing that at
* receipt time and baking the result into `state` is unsafe once apply
* is deferred past the commit barrier — another fact for the same job
* (e.g. a FINISH) can still be in flight and apply AFTER this fact is
* received but BEFORE this fact's own entry applies, making the baked
* snapshot stale by the time it is actually used. This path marks _pad
* nonzero so apply_committed_entry() (persist.c) routes it to a
* dedicated apply-time recomputation against the job's ACTUAL current
* state instead of a blind overwrite.
*
* _pad == 0 always means "legacy: `state` is the absolute value to assign
* as-is". A nonzero _pad's exact interpretation is per-type — see the two
* markers below. */
#define WAL_JOB_FAIL_STAGE_FLAG 0x80000000u
/* WAL_JOB_FAIL: _pad = WAL_JOB_FAIL_STAGE_FLAG | job_failinfo_t.stage.
* Apply time reconstructs a minimal job_failinfo_t from {jobid, stage}
* and calls the real apply_job_fail_transition() against current state. */
#define WAL_JOB_UPDATE_POSTDONE_MARK 1u
/* WAL_JOB_UPDATE: _pad = WAL_JOB_UPDATE_POSTDONE_MARK marks a postdone
* merge-fact (`state` is unused/0) — apply time calls
* apply_job_postdone_transition(), which re-derives the postdone merge
* (conditional EXIT backstop + OR JOB_STAT_PDONE) fresh against current
* state. Any other nonzero value is reserved/unused today. */
/* WAL_QUEUE_CTRL — queue name is a NUL-terminated string immediately after
* this header. Total payload = sizeof(wal_queuehdr_t) + strlen(name)+1. */
typedef struct {