perf(raft-apply-decoupling): fix O(n^2) apply-pump WAL rescan, measure throughput (task 7.4)

Measured sustained worker-fact throughput against the pre-feature baseline.
Commit throughput is unregressed (~2.5% delta, within noise) and no per-fact
synchronous commit barrier exists, confirming Req 11.4's core claim. But
apply/observable throughput -- the new hop this spec introduces -- regressed
56% with clearly superlinear scaling (2x volume -> 5.2x elapsed).

Root cause: apply_pump_read_entry has no memory across calls -- every LSN
lookup re-lists WAL segments and rescans its segment from byte 0 -- while its
only caller applies committed entries one at a time in strict ascending
order, turning a sequential access pattern into O(n^2) total work.

Fixed with a minimal segment-read cache on persist_t (fd + offset + expected
next LSN, guarded by the existing apply_pump_lock): a fast path reads the
next expected LSN directly at a remembered offset, falling back unchanged to
full rediscovery on any miss. Correctness never depends on the cache, only
performance does. Invalidated on init, fini, recovery, and WAL truncation --
the four points a stale cursor could otherwise misread from.

Two review rounds: round 1 caught a missing lock guard in the recovery
invalidation path and an inaccurate risk claim about the cache/trim
interaction (a real, bounded behavior change deferred to the existing
APPLY_DIVERGENT fence, not "no worse than before") -- both corrected and
re-verified in round 2. Post-fix apply-throughput scales sublinearly (1.46x
elapsed for 2x volume) and exceeds the pre-feature baseline. Zero new test
failures across the pump, correctness-verification, recovery, and truncation
groups.

An intermittent, pre-existing client hang in the synchronous jobsub path and
a trim-floor/apply-lag architectural gap were surfaced but are explicitly
out of scope for this fix -- flagged for separate follow-up.
This commit is contained in:
Rongsong Shen 2026-07-07 07:14:15 +00:00
parent 16b890b1af
commit 40196be2e5
3 changed files with 192 additions and 4 deletions

View file

@ -433,6 +433,52 @@ struct persist_s {
* pump; see design.md "Restart Replay & Verification"). */
pthread_mutex_t apply_pump_lock;
_Atomic int apply_pump_scheduled;
/* raft-apply-decoupling task 7.4 perf-fix: apply_pump_read_entry's
* segment-lookup cache. apply_pump_read_entry's only caller
* (persist_apply_pump_run) calls it once per LSN in strict ascending
* order with no gaps in the common case; without this cache every single
* lookup redid a full opendir/readdir/segment-sort plus a pread scan
* from byte offset 0, making N contiguous applies O(N^2). These fields
* cache the currently-open segment fd + byte offset positioned to read
* whichever LSN the pump is expected to ask for next, giving amortized
* O(1) per entry on the common contiguous-advance path.
*
* Guarded ENTIRELY by apply_pump_lock (never p->lock) -- the pump's sole
* caller already holds apply_pump_lock across the whole walk, including
* every apply_pump_read_entry call, so no additional locking is needed
* here. apply_pump_cache_fd == -1 means "no cache" -- this is always a
* safe, correct fallback trigger (falls through to full segment
* rediscovery), never a bug to fall back to. Reset to -1 in persist_init,
* persist_fini, persist_recover (a stale pre-recovery cursor must never
* be trusted), and persist_truncate_wal_above (a real truncation may
* ftruncate()/unlink() the segment the cache still points into).
*
* Known, accepted, bounded tradeoff vs. checkpoint-driven trim
* (trim_wal_segments): trim only unlinks segments below its own trim
* floor, and POSIX unlink-keeps-fd-alive semantics mean an fd this cache
* already has open into a segment stays readable even after trim unlinks
* it on disk. If the pump is lagging behind checkpoint/trim when trim
* fires -- reachable in this feature, since local-apply-lagging-behind-
* commit is raft-apply-decoupling's entire premise -- the cache can keep
* successfully reading that stale, deleted-on-disk data and silently
* advance last_applied_lsn past it. Before this cache existed, every
* lookup did a fresh opendir/readdir, so the equivalent situation failed
* loudly and immediately with -ENOENT on the very next lookup. This is a
* real behavior change -- NOT "no worse than before" -- but it is bounded:
* a crash before the next checkpoint leaves in-memory apply state ahead
* of what disk can prove on restart, and the existing APPLY_DIVERGENT
* boot fence (tasks 5.1/5.2, persist_recover) catches exactly this at the
* next restart and fences the node loudly rather than serve wrong state.
* So the loud failure is deferred from "immediate, at read-time" to
* "delayed, at next-restart-boot-time," not eliminated. Redesigning
* trim/cache invalidation to close this gap at read-time is a distinct,
* pre-existing architectural item (tracked separately) and explicitly
* out of scope for this perf fix. */
int apply_pump_cache_fd;
uint64_t apply_pump_cache_seg_lsn;
uint32_t apply_pump_cache_off;
uint64_t apply_pump_cache_next_lsn;
#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