fix(controller): bug-1433 async-thread fdatasync block + bug-1434 committed_lsn CAS race

bug-1433: raft_cb_become_leader runs synchronously off peer_ctrl_dispatch_to_pool,
which hardcodes VTASK_LIVENESS for every inbound peer-control packet including
vote replies. The bug-1431 round-2 fix (flusher_flush_once force-flush in
persist_promotion_advance_committed) was reachable from that never-blocks task
class -- same bug family as bug-1198. Removed the force-flush; round-3
(durable-capped only) plus the already-shipped bug-1432 immediate-first-heartbeat
fix close the visibility-latency gap non-blockingly. TRACKED RESIDUAL:
persist_apply_pump_run_sync is still reachable on the same VTASK_LIVENESS path
and does bounded pread() -- lower risk (page-cache-hot) but strictly still
blocking I/O; not safe to remove outright (reopens bug-1043-family job loss),
needs its own follow-up (reclassify peer_ctrl_classify's winning-vote-reply case
or dispatch promotion tail onto a fresh VTASK_LIVE_BLOCK vtask).

bug-1434: persist_promotion_advance_committed and ha_standby_advance_committed_and_apply
raced on persist_t.committed_lsn via an unlocked load-then-conditional-store during
the promotion window (before ha->role flips to CTRL_PRIMARY) -- a losing racer could
regress committed_lsn. Added persist_committed_lsn_advance_atomic (CAS retry loop,
lock-free) and routed both call sites through it.

Validated: 19/19 unit test suites, 6-round chaotic soak (0 loss/divergence) + 6-round
sequential+leadership-coverage soak, both PASS. Also folds in raft-apply-decoupling
and raft-ha-core-safety validate-impl phase bumps to "implemented" and clears
config-upgrade-snapshot's now-satisfied entity-bearing-snapshots block.
This commit is contained in:
Rongsong Shen 2026-07-11 12:16:28 +00:00
parent ea0ac81c92
commit 40f510fb14
25 changed files with 1617 additions and 226 deletions

View file

@ -3441,6 +3441,55 @@ void persist_apply_pump_kick(persist_t* p, void* pool_arg)
}
}
/* persist_apply_pump_run_sync (2026-07-10, chaotic-soak regression fix,
* ROUND 2): synchronously drain the apply pump on THIS thread, GUARANTEEING
* last_applied_lsn reaches committed_lsn (as observed at entry) before
* returning -- regardless of what any concurrent caller is doing.
*
* Round 1 of this fix CAS-claimed apply_pump_scheduled and, on losing that
* claim to a concurrent caller (e.g. a heartbeat-driven kick that fired
* moments before promotion), just returned -- betting the OTHER caller's
* async vtask-dispatched drain would finish in time. A second live chaotic
* soak proved that bet wrong: job loss persisted even with the environmental
* fdatasync noise (bug-924) independently confirmed and eliminated, so this
* was a genuine, separate bug in the fix itself, not noise. The caller here
* (promotion) cannot outsource its guarantee to a drain it doesn't control
* the completion of.
*
* Fix: do NOT use the CAS-coalescing flag as a "someone else has it, I'm
* done" signal -- call persist_apply_pump_run() directly in a loop until
* THIS call observes last_applied_lsn caught up to committed_lsn. Multiple
* concurrent callers (this one and any vtask-dispatched persist_apply_pump_
* kick) safely interleave via persist_apply_pump_run's own internal
* apply_pump_lock and apply_committed_entry's per-LSN idempotency -- at
* worst a redundant no-op pass, never lost or duplicated work. Stops (does
* not busy-loop forever) on a genuine read failure, exactly mirroring
* apply_pump_drain's own error handling: persist_apply_pump_run() already
* logs loudly and leaves last_applied_lsn unadvanced on that path. */
void persist_apply_pump_run_sync(persist_t* p)
{
if (!p || !p->cluster)
return;
if (!atomic_load_explicit(&p->recovery_complete, memory_order_acquire))
return;
for (;;) {
uint64_t committed = atomic_load_explicit(&p->committed_lsn,
memory_order_acquire);
uint64_t applied = atomic_load_explicit(&p->last_applied_lsn,
memory_order_relaxed);
if (applied >= committed)
return; /* caught up -- by us or a concurrent drain */
int rc = persist_apply_pump_run(p);
if (rc < 0)
return; /* already logged loudly; do not busy-loop on the same
* unresolved entry (mirrors apply_pump_drain) */
/* Loop: re-check in case committed_lsn advanced further mid-walk, or
* a concurrent drain made partial progress this pass didn't see. */
}
}
/* Read WAL segment files in LSN order; replay entries with lsn > from_lsn.
*
* Segment filename format: seg-<start_lsn_hex16>.wal
@ -5312,6 +5361,81 @@ int persist_truncate_wal_above(persist_t* p, uint64_t cutoff_lsn)
return rc;
}
/* persist_committed_lsn_advance_atomic (2026-07-10, controller-invariant-audit
* finding): race-safe monotonic-max advance of p->committed_lsn via a CAS
* retry loop. Replaces the naive "load; if target>cur store" pattern, which
* can lose an update if two writers race: persist_promotion_advance_committed
* (below) and ha_standby_advance_committed_and_apply (ha.c) can both run
* during the promotion window before role flips to CTRL_PRIMARY (a
* still-in-flight WAL catch-up entry processed on another thread at the same
* instant as this node's own election completing). No lock required -- CAS
* only succeeds if the value hasn't changed since the read, so a losing
* racer retries with the freshly-observed value and can never clobber a
* larger value with a smaller one. */
uint64_t persist_committed_lsn_advance_atomic(persist_t* p, uint64_t target)
{
uint64_t cur = atomic_load_explicit(&p->committed_lsn, memory_order_acquire);
while (target > cur) {
if (atomic_compare_exchange_weak_explicit(&p->committed_lsn, &cur, target,
memory_order_release,
memory_order_acquire))
return target;
/* cur was refreshed to the current value by the failed CAS; retry. */
}
return cur;
}
/* persist_promotion_advance_committed (2026-07-10, promotion apply-lag fix):
* unconditionally advance p->committed_lsn toward `safe_lsn` at promotion
* time. persist_truncate_wal_above only sets committed_lsn on the branches
* of truncate_wal_above_locked that perform a REAL truncation (partial
* truncate, or the full-segment-purge demotion-convergence case); its no-op
* early return (nothing above cutoff_lsn to remove -- the common case for a
* newly-elected leader whose own log already covers safe_lsn) leaves
* committed_lsn untouched at its pre-election value. Without this call,
* committed_lsn -- and therefore apply, which is commit-gated
* (raft-apply-decoupling) -- stalls until the normal per-entry ack-driven
* commit path organically catches it up, which can take seconds while
* surviving standbys reconnect and re-ACK. During that gap a job already
* committed on a majority is invisible via expect-job/joblist on the very
* primary that is authoritative for it (bug-828 class).
*
* DESIGN NOTE -- why this function never forces a synchronous flush (round 3,
* 2026-07-10, controller-invariant-audit): an earlier round of this fix
* called flusher_flush_once(p) here when durable_lsn lagged safe_lsn, to
* force this node's own fsync to catch up immediately rather than waiting on
* fsync_worker's cadence. That was a real bug-1198-CLASS thread-role
* violation: the real multi-node winning-vote-reply path reaches
* raft_cb_become_leader (and so this function) via peer_ctrl_classify's
* VTASK_LIVENESS dispatch (ha.c ~5935-5940) -- a task class the design
* DOCUMENTS as "never admission-gated" / never expected to block (see the
* bug-1198 hazard box a few hundred lines above peer_ctrl_classify's
* definition, which closed the EXACT same class of bug for
* HA_REPL_SEND_SNAPSHOT's persist_checkpoint). A blocking fdatasync here
* would reintroduce that closed bug class on the pool's liveness-reserved
* workers. The visibility gap the force-flush was trying to close is instead
* closed non-blockingly by ha_start_primary_heartbeat sending its first
* heartbeat synchronously at promotion (bug-1432) -- that lets a reconnecting
* standby's real position get learned, and the entry gets committed via the
* normal ack-driven commit_advance_locked path, within one network RTT
* instead of waiting a full heartbeat interval. A live chaotic soak (10
* rounds) confirmed durable-capped-only + immediate-heartbeat together reach
* committed_job_loss=0 with zero blocking calls added to this function.
*
* Durable-capped (never advances committed_lsn past this node's own
* durable_lsn -- see persistence.md's "acknowledged implies durable on a
* majority" core invariant) and monotonic + race-safe via
* persist_committed_lsn_advance_atomic. */
void persist_promotion_advance_committed(persist_t* p, uint64_t safe_lsn)
{
if (!p)
return;
uint64_t durable = atomic_load_explicit(&p->durable_lsn,
memory_order_acquire);
uint64_t target = safe_lsn < durable ? safe_lsn : durable;
persist_committed_lsn_advance_atomic(p, target);
}
/* Lock-held core of persist_truncate_wal_above (see the forward declaration and
* the conflict-guard reuse near persist_wal_write_replicated). Caller MUST hold
* p->lock; this function never touches the lock so it is reusable from another