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 —