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:
parent
16b890b1af
commit
40196be2e5
3 changed files with 192 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue