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:
parent
ea0ac81c92
commit
40f510fb14
25 changed files with 1617 additions and 226 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue