perf(compaction/gc): cut worker-thread occupation that stalls foreground serving - #455
Conversation
…und serving EloqStore compaction/GC coroutines run on the same shard worker thread (and, in module mode, the same core) that serves foreground requests. A single long non-yielding compaction/GC segment therefore stalls foreground SET/GET for its whole duration -- the cause of deep-tail latency spikes during compaction. - File GC: skip re-reading and replaying the active branch's current manifest in AugmentRetainedFilesFromBranchManifests. Its retained files are already derived from the in-memory mapping by BuildRetainedFiles, and the in-memory RootMeta is at least as current as the disk manifest, so the replay + full mapping-table walk was pure redundant work (the most frequent long GC segment). Only skipped when the in-memory RootMeta is present; non-active branches / archives still replay from disk. - Chunk IouringMgr::DeleteFiles and CloseFiles (128 ops + WaitIo per chunk) so a GC pass over a partition's many accumulated dead files no longer builds all unlink/close SQEs in one uninterrupted loop. - Add a cooperative time-budgeted yield (MaybeYieldForCompaction / eloqstore_compaction_yield_budget_us, default 500us) called from the compaction / file-GC loops. Compaction concurrency is left to the existing eloq_store_max_write_concurrency (BackgroundWrite is a write task counted in num_active_write_); no separate compaction cap. Verified: compaction runs cleanly and data is fully intact across restart recovery (GC does not over-delete retained files). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Ubuntu seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account. You have signed the CLA already but the status is still pending? Let us recheck it. |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds cooperative time-budgeted yielding for compaction and file-GC coroutine segments using a configurable ChangesCooperative Time-Budgeted Yielding for Compaction/File-GC
Sequence Diagram(s)sequenceDiagram
participant BgLoop as Background Loop<br/>(compaction/file-GC)
participant MaybeYield as MaybeYield()
participant Shard
participant Scheduler as ExecuteReadyTasks()
Scheduler->>Shard: cur_resume_start_us_ = ReadTimeMicroseconds()
Scheduler->>BgLoop: resume coroutine
loop per file / entry
BgLoop->>MaybeYield: MaybeYield()
MaybeYield->>Shard: CurResumeElapsedUs()
Shard-->>MaybeYield: elapsed_us
alt elapsed_us >= eloqstore_yield_budget_us (20 µs)
MaybeYield->>BgLoop: YieldToLowPQ()
BgLoop-->>Scheduler: suspend to low-priority queue
Scheduler->>Shard: cur_resume_start_us_ = ReadTimeMicroseconds()
Scheduler->>BgLoop: resume from low-priority queue
end
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related issues
Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| // exceeds this many microseconds. This bounds how long one segment can stall | ||
| // the brpc worker (and the Redis serving it co-drives in module mode), which is | ||
| // the direct cause of foreground SET/GET tail-latency spikes during compaction. | ||
| DEFINE_uint64(eloqstore_compaction_yield_budget_us, |
There was a problem hiding this comment.
we should have an overall yield budget option. This should apply to all sort of tasks. In fact I think we already have similar mechanism, yielding if task exceeds time limit. We should reuse that instead of defining a new one
There was a problem hiding this comment.
Done in 9026744. Renamed the flag to eloqstore_yield_budget_us (default lowered 500→20µs) and generalized it to all long-running task loops.
One nuance worth recording: I did look at reusing the existing time-limit mechanism (max_processing_time_microseconds), but it's a per-round budget — the scheduler only checks it in ExecuteReadyTasks between task resumes (shard.cpp:886/905), so it cannot preempt a single non-yielding coroutine segment, which is exactly the stall we're targeting. So an in-loop poll (MaybeYield) is still required; it can't be replaced by the round budget.
I kept eloqstore_yield_budget_us as a distinct per-segment budget and documented the difference next to the DEFINE. They can legitimately differ in value, and note max_processing_time_microseconds is UINT64_MAX (disabled) in debug builds whereas we want the segment yield active everywhere.
| // worker thread past eloqstore_compaction_yield_budget_us since its last | ||
| // resume. Cheap to call frequently (one rdtsc-based read + compare); only the | ||
| // background paths call it, so foreground latency is unaffected. | ||
| void MaybeYieldForCompaction(); |
There was a problem hiding this comment.
again, should be a common function for all sort of long running loops. Not just for compaction
There was a problem hiding this comment.
Done in 9026744. Renamed MaybeYieldForCompaction → MaybeYield and generalized the doc comment (now: compaction, file GC, manifest replay, large scans, …).
Also replaced the adjacent count-based (round_cnt & 0xFF) coarse yields in DoCompactDataFile / DoCompactSegmentFile with the time-based MaybeYield() (and dropped the now-dead round_cnt), so those loops use the single self-tuning facility instead of two stacked idioms. The time budget self-tunes to actual segment cost, so it strictly subsumes the fixed 256-iteration yield.
- Harden the active-branch manifest-replay skip: require a non-stub in-memory RootMeta (mapper_ != nullptr), matching BuildRetainedFiles' predicate, so a stub RootMeta can no longer skip the disk manifest and over-delete files referenced only on disk. - DeleteFiles: log every failed unlink across chunks, not just the first (mirrors CloseFiles); latch first_error once. - StartTask: stamp cur_resume_start_us_ on dispatch so a task's first segment measures its yield budget from now, not the previous task. - CurResumeElapsedUs: delegate to DurationMicroseconds (reuse + wraparound guard) and group with the other public accessors (drop specifier churn). - DeleteFiles: use a reference local instead of repeated reqs.back(). - Fix inaccurate comments (dangling note cross-ref, false ListLocalFiles claim, non-existent sync_dirty param, per-256-file vs per-256-mapping) and reflow all added comments to <=80 cols (cpplint). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/file_gc.cpp (1)
730-770: 💤 Low valueConsider adding
MaybeYieldForCompaction()in the archive manifest loop for consistency.The regular manifest loop (line 673) has a yield call, but the archive loop does not. While the I/O operations naturally yield, the
ProcessOneManifestcall involves CPU-bound manifest replay that could be significant for large mappings. For consistency and to bound per-resume CPU time when many archives exist, consider adding a yield at the loop start.♻️ Suggested change
// --- Process archive manifests --- for (size_t i = 0; i < archive_files.size(); ++i) { + MaybeYieldForCompaction(); const std::string &filename = archive_files[i];🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/file_gc.cpp` around lines 730 - 770, The archive manifest processing loop that iterates through archive_files is missing a `MaybeYieldForCompaction()` call at the loop start, which is inconsistent with the regular manifest loop and does not bound CPU time for CPU-bound work in ProcessOneManifest. Add a `MaybeYieldForCompaction()` call at the beginning of the for loop that iterates through archive_files, before the filename extraction and file reading operations, to ensure consistent yield behavior and bound per-resume CPU time when processing multiple archives.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/file_gc.cpp`:
- Around line 730-770: The archive manifest processing loop that iterates
through archive_files is missing a `MaybeYieldForCompaction()` call at the loop
start, which is inconsistent with the regular manifest loop and does not bound
CPU time for CPU-bound work in ProcessOneManifest. Add a
`MaybeYieldForCompaction()` call at the beginning of the for loop that iterates
through archive_files, before the filename extraction and file reading
operations, to ensure consistent yield behavior and bound per-resume CPU time
when processing multiple archives.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 8dad2101-33ae-4375-b753-1ec00608b49a
📒 Files selected for processing (7)
include/storage/shard.hinclude/tasks/task.hsrc/async_io_manager.cppsrc/file_gc.cppsrc/storage/shard.cppsrc/tasks/background_write.cppsrc/tasks/task.cpp
7305f16 to
66dade9
Compare
Address review feedback (the threads on eloqstore_compaction_yield_budget_us and MaybeYieldForCompaction): the time-budgeted cooperative yield is not compaction-specific, so make it a general facility for any long-running task loop. - Rename MaybeYieldForCompaction() -> MaybeYield() and eloqstore_compaction_yield_budget_us -> eloqstore_yield_budget_us (default lowered 500 -> 20us). Kept as a distinct per-segment budget, separate from the per-round max_processing_time_microseconds, which the scheduler checks only between task resumes and so cannot preempt a single non-yielding segment. - Replace the adjacent count-based (round_cnt & 0xFF) coarse yields in DoCompactDataFile / DoCompactSegmentFile with the time-based MaybeYield(); the time budget self-tunes to actual segment cost. Drop the dead round_cnt. - Generalize the doc comments accordingly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The cheap per-file scan loops (ClassifyFiles, DeleteUnreferenced{Local,
LocalSegment,CloudSegment}Files) parse one filename per iteration, so calling
MaybeYield() -- which reads the TSC + divides -- every iteration was a large
fraction of the loop body. Poll the budget once per kYieldPollStride (256)
iterations instead; the time-based decision still bounds the stall (a few
hundred parses is tens of us, vs the tens-of-ms segments this targets).
Loops doing a syscall / IO per iteration (ListLocalFiles' readdir+stat, the
Augment manifest replay) keep calling MaybeYield() every iteration -- there the
clock read is already negligible and a coarser poll would loosen the bound.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per review (thweetkomputer): hoist the per-chunk reqs vector out of the chunk loop and clear() it per chunk, so a GC pass over many files allocates the backing buffer once instead of malloc/free per 128-op chunk. clear() runs at the top of each chunk -- after the previous chunk's WaitIo has drained its SQEs -- so the CloseReq/UnlinkReq addresses handed to io_uring stay valid for the chunk that submitted them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The active-branch manifest-replay skip seeded the in-flight guard (least_unflushed_*_file_id_) from RootMeta::first_unflushed_*_fp_id_. Those snapshot fields are only refreshed by a flush whose CoW meta carries the matching mapper, so a data-only flush zeroes first_unflushed_seg_fp_id_ while segment files still exist. The skip then drops the disk manifest's max_segment_file_id_ contribution, leaving the guard at 0 -- so every segment file stays "in-flight" and GC never reclaims dead segments. Seed instead from the live mapper's allocator MaxFilePageId(), the current high-water, which matches the disk manifest's max_*_file_id_ that ProcessOneManifest folds in -- so the skip stays a pure optimization with no behavior change. These fields are read only here, so the change is local to GC. Fixes the segment_compact "reclaims space" and large_value_gc "overwritten ... free their old segment files" CI failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A stub RootMeta (mapper_ == nullptr) is a placeholder with no live mapping and
no in-flight writes -- e.g. a partition mid-reopen/drop on a standby.
SeedActiveBranchGuardFromInMemory seeded least_unflushed=0 for it, marking
every local file (file_id >= 0) "in-flight" and protecting orphaned files from
reclamation forever, so the stale local partition directory is never removed.
Seed no guard for a stub instead: DeleteUnreferenced* then treats the active
branch as having a dropped manifest (its documented missing-guard case) and
reclaims the unretained files. A valid on-disk manifest, if present, still
seeds the guard via ProcessOneManifest.
Fixes the standby "reopen a partition with {nonexistent,empty} dir" and
"reopen without tag ..." CI failures.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
a2c42c4 to
f69d319
Compare
…d isolation Background tasks (batch writes, compaction, GC) degraded foreground read tail latency even after the CPU-side yield work (eloqdata#455): a background task scheduled for one 20us slice could still burst 128 page reads plus writes into io_uring, and nothing distinguished them from foreground reads at the ring or device. Reads had no budget at all; writes only a per-task one. CPU priority was lost at the IO boundary. Design: docs/design/io_qos.md (+ implementation/testing record in docs/design/io_qos_impl_plan.md). Mechanisms (per shard, all in IouringMgr): - IoBudget: in-flight page-IO counters with independent read/write caps (max_inflight_read / max_inflight_write, 4KB-page units; merged append writes cost bytes/page_size). Acquired immediately before SQE prep — after every other blocking acquisition — and released per CQE in PollComplete via new user-data types (KvTaskPageRead/BaseReqPageRead) that distinguish budgeted page reads from metadata ops. Batched reads acquire per page, so a 128-page compaction batch cannot deadlock against a small budget. A request costlier than the cap is admitted alone once the budget drains (merged writes vs small caps). Metadata, manifest, and segment IO are exempt. - Background read sub-budget (bg_read_ratio, KvTask::IsBackground()): background page reads are additionally bounded so compaction/GC/batch- write read bursts cannot crowd foreground point reads out of the device queue. While background waiters queue, their unused entitlement is reserved from new foreground admissions — sustained foreground saturation cannot starve compaction (and vice versa); release wakes background first and forwards unused wake credits (WaitingZone::WakeN now returns the count woken). The ratio parameterization is deliberate: with the total cap sized to the device's bandwidth-delay product, relative tail inflation is device-independent (see Sizing contract in the design doc). - Retired: the per-task max_write_batch_pages drain-to-zero throttle (option deprecated, parsed with a warning); max_inflight_write is now the write QoS cap (default 32768 -> 512, calibrated: natural demand ceiling was 2048 = 8 concurrent 1MB merged writes; 512 binds marginally at equal-or-best throughput and tails). Defaults max_inflight_read = 32, bg_read_ratio = 25 from interference sweeps (bg cap lands on the measured background demand line; write-throughput knee only below it). - Observability: IoQosStats (in-flight/high-watermark/blocked/admitted per budget + bg slice, fdatasync count/latency), EloqStore::GetIoQosStats, TXSERVICE gauges. Benchmarks/tooling: benchmark/interference_bench (baseline vs 90/10 write/read mixed phase; rotating strided partial-overwrite storm so compaction continuously relocates pages — full overwrites generate no move reads; exact percentiles; per-shard QoS deltas) and scripts/io_calibration_sweep.sh (fio read-tail-vs-write-rate curve). On the dev box the sub-budget cut mixed-phase read p99 ~2.5x and p99.9 3-5x at identical write throughput, with flat p50. Tests: tests/io_qos.cpp (11 cases: accounting invariants, oversized batches through tiny caps, BG sub-budget bounds under compaction, concurrent merged-write admission, failed-write budget drain, shutdown while blocked on a budget). Full suite green; db_stress 120/120 with tiny caps in both write modes; ASAN (flag-injection mode) clean. Two OOM tests that implicitly relied on the retired per-task drain to bound write-promotion pins now set explicit write budgets. Also: test harness now enforces one started EloqStore per process (InitStore gained cleanup=false for warm-restart tests; cloud tests no longer construct stores directly) — fixes a flaky teardown SIGSEGV where a test-local store Stop nulled the global eloq_store pointer that the lingering singleton prewarm shutdown later dereferenced. Deployment notes: max_inflight_write semantics changed for configs that set it explicitly (was pool sizing only, now a queue-depth cap); max_write_batch_pages is ignored. Real-device calibration should re-derive max_inflight_read (QD sweep); bg_read_ratio is policy and transfers. The M3 background write rate limiter remains deferred pending real-device evidence.
…d isolation Background tasks (batch writes, compaction, GC) degraded foreground read tail latency even after the CPU-side yield work (eloqdata#455): a background task scheduled for one 20us slice could still burst 128 page reads plus writes into io_uring, and nothing distinguished them from foreground reads at the ring or device. Reads had no budget at all; writes only a per-task one. CPU priority was lost at the IO boundary. Design: docs/design/io_qos.md (+ implementation/testing record in docs/design/io_qos_impl_plan.md). Mechanisms (per shard, all in IouringMgr): - IoBudget: in-flight page-IO counters with independent read/write caps (max_inflight_read / max_inflight_write, 4KB-page units; merged append writes cost bytes/page_size). Acquired immediately before SQE prep — after every other blocking acquisition — and released per CQE in PollComplete via new user-data types (KvTaskPageRead/BaseReqPageRead) that distinguish budgeted page reads from metadata ops. Batched reads acquire per page, so a 128-page compaction batch cannot deadlock against a small budget. A request costlier than the cap is admitted alone once the budget drains (merged writes vs small caps). Metadata, manifest, and segment IO are exempt. - Background read sub-budget (bg_read_ratio, KvTask::IsBackground()): background page reads are additionally bounded so compaction/GC/batch- write read bursts cannot crowd foreground point reads out of the device queue. While background waiters queue, their unused entitlement is reserved from new foreground admissions — sustained foreground saturation cannot starve compaction (and vice versa); release wakes background first and forwards unused wake credits (WaitingZone::WakeN now returns the count woken). The ratio parameterization is deliberate: with the total cap sized to the device's bandwidth-delay product, relative tail inflation is device-independent (see Sizing contract in the design doc). - Retired: the per-task max_write_batch_pages drain-to-zero throttle (option deprecated, parsed with a warning); max_inflight_write is now the write QoS cap (default 32768 -> 512, calibrated: natural demand ceiling was 2048 = 8 concurrent 1MB merged writes; 512 binds marginally at equal-or-best throughput and tails). Defaults max_inflight_read = 32, bg_read_ratio = 25 from interference sweeps (bg cap lands on the measured background demand line; write-throughput knee only below it). - Observability: IoQosStats (in-flight/high-watermark/blocked/admitted per budget + bg slice, fdatasync count/latency), EloqStore::GetIoQosStats, TXSERVICE gauges. Benchmarks/tooling: benchmark/interference_bench (baseline vs 90/10 write/read mixed phase; rotating strided partial-overwrite storm so compaction continuously relocates pages — full overwrites generate no move reads; exact percentiles; per-shard QoS deltas) and scripts/io_calibration_sweep.sh (fio read-tail-vs-write-rate curve). On the dev box the sub-budget cut mixed-phase read p99 ~2.5x and p99.9 3-5x at identical write throughput, with flat p50. Tests: tests/io_qos.cpp (11 cases: accounting invariants, oversized batches through tiny caps, BG sub-budget bounds under compaction, concurrent merged-write admission, failed-write budget drain, shutdown while blocked on a budget). Full suite green; db_stress 120/120 with tiny caps in both write modes; ASAN (flag-injection mode) clean. Two OOM tests that implicitly relied on the retired per-task drain to bound write-promotion pins now set explicit write budgets. Also: test harness now enforces one started EloqStore per process (InitStore gained cleanup=false for warm-restart tests; cloud tests no longer construct stores directly) — fixes a flaky teardown SIGSEGV where a test-local store Stop nulled the global eloq_store pointer that the lingering singleton prewarm shutdown later dereferenced. Deployment notes: max_inflight_write semantics changed for configs that set it explicitly (was pool sizing only, now a queue-depth cap); max_write_batch_pages is ignored. Real-device calibration should re-derive max_inflight_read (QD sweep); bg_read_ratio is policy and transfers. The M3 background write rate limiter remains deferred pending real-device evidence.
…d isolation Background tasks (batch writes, compaction, GC) degraded foreground read tail latency even after the CPU-side yield work (eloqdata#455): a background task scheduled for one 20us slice could still burst 128 page reads plus writes into io_uring, and nothing distinguished them from foreground reads at the ring or device. Reads had no budget at all; writes only a per-task one. CPU priority was lost at the IO boundary. Design: docs/design/io_qos.md (+ implementation/testing record in docs/design/io_qos_impl_plan.md). Mechanisms (per shard, all in IouringMgr): - IoBudget: in-flight page-IO counters with independent read/write caps (max_inflight_read / max_inflight_write, 4KB-page units; merged append writes cost bytes/page_size). Acquired immediately before SQE prep — after every other blocking acquisition — and released per CQE in PollComplete via new user-data types (KvTaskPageRead/BaseReqPageRead) that distinguish budgeted page reads from metadata ops. Batched reads acquire per page, so a 128-page compaction batch cannot deadlock against a small budget. A request costlier than the cap is admitted alone once the budget drains (merged writes vs small caps). Metadata, manifest, and segment IO are exempt. - Background read sub-budget (bg_read_ratio, KvTask::IsBackground()): background page reads are additionally bounded so compaction/GC/batch- write read bursts cannot crowd foreground point reads out of the device queue. While background waiters queue, their unused entitlement is reserved from new foreground admissions — sustained foreground saturation cannot starve compaction (and vice versa); release wakes background first and forwards unused wake credits (WaitingZone::WakeN now returns the count woken). The ratio parameterization is deliberate: with the total cap sized to the device's bandwidth-delay product, relative tail inflation is device-independent (see Sizing contract in the design doc). - Retired: the per-task max_write_batch_pages drain-to-zero throttle (option deprecated, parsed with a warning); max_inflight_write is now the write QoS cap (default 32768 -> 512, calibrated: natural demand ceiling was 2048 = 8 concurrent 1MB merged writes; 512 binds marginally at equal-or-best throughput and tails). Defaults max_inflight_read = 32, bg_read_ratio = 25 from interference sweeps (bg cap lands on the measured background demand line; write-throughput knee only below it). - Observability: IoQosStats (in-flight/high-watermark/blocked/admitted per budget + bg slice, fdatasync count/latency), EloqStore::GetIoQosStats, TXSERVICE gauges. Benchmarks/tooling: benchmark/interference_bench (baseline vs 90/10 write/read mixed phase; rotating strided partial-overwrite storm so compaction continuously relocates pages — full overwrites generate no move reads; exact percentiles; per-shard QoS deltas) and scripts/io_calibration_sweep.sh (fio read-tail-vs-write-rate curve). On the dev box the sub-budget cut mixed-phase read p99 ~2.5x and p99.9 3-5x at identical write throughput, with flat p50. Tests: tests/io_qos.cpp (11 cases: accounting invariants, oversized batches through tiny caps, BG sub-budget bounds under compaction, concurrent merged-write admission, failed-write budget drain, shutdown while blocked on a budget). Full suite green; db_stress 120/120 with tiny caps in both write modes; ASAN (flag-injection mode) clean. Two OOM tests that implicitly relied on the retired per-task drain to bound write-promotion pins now set explicit write budgets. Also: test harness now enforces one started EloqStore per process (InitStore gained cleanup=false for warm-restart tests; cloud tests no longer construct stores directly) — fixes a flaky teardown SIGSEGV where a test-local store Stop nulled the global eloq_store pointer that the lingering singleton prewarm shutdown later dereferenced. Deployment notes: max_inflight_write semantics changed for configs that set it explicitly (was pool sizing only, now a queue-depth cap); max_write_batch_pages is ignored. Real-device calibration should re-derive max_inflight_read (QD sweep); bg_read_ratio is policy and transfers. The M3 background write rate limiter remains deferred pending real-device evidence.
…d isolation Background tasks (batch writes, compaction, GC) degraded foreground read tail latency even after the CPU-side yield work (eloqdata#455): a background task scheduled for one 20us slice could still burst 128 page reads plus writes into io_uring, and nothing distinguished them from foreground reads at the ring or device. Reads had no budget at all; writes only a per-task one. CPU priority was lost at the IO boundary. Design: docs/design/io_qos.md (+ implementation/testing record in docs/design/io_qos_impl_plan.md). Mechanisms (per shard, all in IouringMgr): - IoBudget: in-flight page-IO counters with independent read/write caps (max_inflight_read / max_inflight_write, 4KB-page units; merged append writes cost bytes/page_size). Acquired immediately before SQE prep — after every other blocking acquisition — and released per CQE in PollComplete via new user-data types (KvTaskPageRead/BaseReqPageRead) that distinguish budgeted page reads from metadata ops. Batched reads acquire per page, so a 128-page compaction batch cannot deadlock against a small budget. A request costlier than the cap is admitted alone once the budget drains (merged writes vs small caps). Metadata, manifest, and segment IO are exempt. - Background read sub-budget (bg_read_ratio, KvTask::IsBackground()): background page reads are additionally bounded so compaction/GC/batch- write read bursts cannot crowd foreground point reads out of the device queue. While background waiters queue, their unused entitlement is reserved from new foreground admissions — sustained foreground saturation cannot starve compaction (and vice versa); release wakes background first and forwards unused wake credits (WaitingZone::WakeN now returns the count woken). The ratio parameterization is deliberate: with the total cap sized to the device's bandwidth-delay product, relative tail inflation is device-independent (see Sizing contract in the design doc). - Retired: the per-task max_write_batch_pages drain-to-zero throttle (option deprecated, parsed with a warning); max_inflight_write is now the write QoS cap (default 32768 -> 512, calibrated: natural demand ceiling was 2048 = 8 concurrent 1MB merged writes; 512 binds marginally at equal-or-best throughput and tails). Defaults max_inflight_read = 32, bg_read_ratio = 25 from interference sweeps (bg cap lands on the measured background demand line; write-throughput knee only below it). - Observability: IoQosStats (in-flight/high-watermark/blocked/admitted per budget + bg slice, fdatasync count/latency), EloqStore::GetIoQosStats, TXSERVICE gauges. Benchmarks/tooling: benchmark/interference_bench (baseline vs 90/10 write/read mixed phase; rotating strided partial-overwrite storm so compaction continuously relocates pages — full overwrites generate no move reads; exact percentiles; per-shard QoS deltas) and scripts/io_calibration_sweep.sh (fio read-tail-vs-write-rate curve). On the dev box the sub-budget cut mixed-phase read p99 ~2.5x and p99.9 3-5x at identical write throughput, with flat p50. Tests: tests/io_qos.cpp (11 cases: accounting invariants, oversized batches through tiny caps, BG sub-budget bounds under compaction, concurrent merged-write admission, failed-write budget drain, shutdown while blocked on a budget). Full suite green; db_stress 120/120 with tiny caps in both write modes; ASAN (flag-injection mode) clean. Two OOM tests that implicitly relied on the retired per-task drain to bound write-promotion pins now set explicit write budgets. Also: test harness now enforces one started EloqStore per process (InitStore gained cleanup=false for warm-restart tests; cloud tests no longer construct stores directly) — fixes a flaky teardown SIGSEGV where a test-local store Stop nulled the global eloq_store pointer that the lingering singleton prewarm shutdown later dereferenced. Deployment notes: max_inflight_write semantics changed for configs that set it explicitly (was pool sizing only, now a queue-depth cap); max_write_batch_pages is ignored. Real-device calibration should re-derive max_inflight_read (QD sweep); bg_read_ratio is policy and transfers. The M3 background write rate limiter remains deferred pending real-device evidence.
…d isolation Background tasks (batch writes, compaction, GC) degraded foreground read tail latency even after the CPU-side yield work (#455): a background task scheduled for one 20us slice could still burst 128 page reads plus writes into io_uring, and nothing distinguished them from foreground reads at the ring or device. Reads had no budget at all; writes only a per-task one. CPU priority was lost at the IO boundary. Design: docs/design/io_qos.md (+ implementation/testing record in docs/design/io_qos_impl_plan.md). Mechanisms (per shard, all in IouringMgr): - IoBudget: in-flight page-IO counters with independent read/write caps (max_inflight_read / max_inflight_write, 4KB-page units; merged append writes cost bytes/page_size). Acquired immediately before SQE prep — after every other blocking acquisition — and released per CQE in PollComplete via new user-data types (KvTaskPageRead/BaseReqPageRead) that distinguish budgeted page reads from metadata ops. Batched reads acquire per page, so a 128-page compaction batch cannot deadlock against a small budget. A request costlier than the cap is admitted alone once the budget drains (merged writes vs small caps). Metadata, manifest, and segment IO are exempt. - Background read sub-budget (bg_read_ratio, KvTask::IsBackground()): background page reads are additionally bounded so compaction/GC/batch- write read bursts cannot crowd foreground point reads out of the device queue. While background waiters queue, their unused entitlement is reserved from new foreground admissions — sustained foreground saturation cannot starve compaction (and vice versa); release wakes background first and forwards unused wake credits (WaitingZone::WakeN now returns the count woken). The ratio parameterization is deliberate: with the total cap sized to the device's bandwidth-delay product, relative tail inflation is device-independent (see Sizing contract in the design doc). - Retired: the per-task max_write_batch_pages drain-to-zero throttle (option deprecated, parsed with a warning); max_inflight_write is now the write QoS cap (default 32768 -> 512, calibrated: natural demand ceiling was 2048 = 8 concurrent 1MB merged writes; 512 binds marginally at equal-or-best throughput and tails). Defaults max_inflight_read = 32, bg_read_ratio = 25 from interference sweeps (bg cap lands on the measured background demand line; write-throughput knee only below it). - Observability: IoQosStats (in-flight/high-watermark/blocked/admitted per budget + bg slice, fdatasync count/latency), EloqStore::GetIoQosStats, TXSERVICE gauges. Benchmarks/tooling: benchmark/interference_bench (baseline vs 90/10 write/read mixed phase; rotating strided partial-overwrite storm so compaction continuously relocates pages — full overwrites generate no move reads; exact percentiles; per-shard QoS deltas) and scripts/io_calibration_sweep.sh (fio read-tail-vs-write-rate curve). On the dev box the sub-budget cut mixed-phase read p99 ~2.5x and p99.9 3-5x at identical write throughput, with flat p50. Tests: tests/io_qos.cpp (11 cases: accounting invariants, oversized batches through tiny caps, BG sub-budget bounds under compaction, concurrent merged-write admission, failed-write budget drain, shutdown while blocked on a budget). Full suite green; db_stress 120/120 with tiny caps in both write modes; ASAN (flag-injection mode) clean. Two OOM tests that implicitly relied on the retired per-task drain to bound write-promotion pins now set explicit write budgets. Also: test harness now enforces one started EloqStore per process (InitStore gained cleanup=false for warm-restart tests; cloud tests no longer construct stores directly) — fixes a flaky teardown SIGSEGV where a test-local store Stop nulled the global eloq_store pointer that the lingering singleton prewarm shutdown later dereferenced. Deployment notes: max_inflight_write semantics changed for configs that set it explicitly (was pool sizing only, now a queue-depth cap); max_write_batch_pages is ignored. Real-device calibration should re-derive max_inflight_read (QD sweep); bg_read_ratio is policy and transfers. The M3 background write rate limiter remains deferred pending real-device evidence.
…d isolation Background tasks (batch writes, compaction, GC) degraded foreground read tail latency even after the CPU-side yield work (eloqdata#455): a background task scheduled for one 20us slice could still burst 128 page reads plus writes into io_uring, and nothing distinguished them from foreground reads at the ring or device. Reads had no budget at all; writes only a per-task one. CPU priority was lost at the IO boundary. Design: docs/design/io_qos.md (+ implementation/testing record in docs/design/io_qos_impl_plan.md). Mechanisms (per shard, all in IouringMgr): - IoBudget: in-flight page-IO counters with independent read/write caps (max_inflight_read / max_inflight_write, 4KB-page units; merged append writes cost bytes/page_size). Acquired immediately before SQE prep — after every other blocking acquisition — and released per CQE in PollComplete via new user-data types (KvTaskPageRead/BaseReqPageRead) that distinguish budgeted page reads from metadata ops. Batched reads acquire per page, so a 128-page compaction batch cannot deadlock against a small budget. A request costlier than the cap is admitted alone once the budget drains (merged writes vs small caps). Metadata, manifest, and segment IO are exempt. - Background read sub-budget (bg_read_ratio, KvTask::IsBackground()): background page reads are additionally bounded so compaction/GC/batch- write read bursts cannot crowd foreground point reads out of the device queue. While background waiters queue, their unused entitlement is reserved from new foreground admissions — sustained foreground saturation cannot starve compaction (and vice versa); release wakes background first and forwards unused wake credits (WaitingZone::WakeN now returns the count woken). The ratio parameterization is deliberate: with the total cap sized to the device's bandwidth-delay product, relative tail inflation is device-independent (see Sizing contract in the design doc). - Retired: the per-task max_write_batch_pages drain-to-zero throttle (option deprecated, parsed with a warning); max_inflight_write is now the write QoS cap (default 32768 -> 512, calibrated: natural demand ceiling was 2048 = 8 concurrent 1MB merged writes; 512 binds marginally at equal-or-best throughput and tails). Defaults max_inflight_read = 32, bg_read_ratio = 25 from interference sweeps (bg cap lands on the measured background demand line; write-throughput knee only below it). - Observability: IoQosStats (in-flight/high-watermark/blocked/admitted per budget + bg slice, fdatasync count/latency), EloqStore::GetIoQosStats, TXSERVICE gauges. Benchmarks/tooling: benchmark/interference_bench (baseline vs 90/10 write/read mixed phase; rotating strided partial-overwrite storm so compaction continuously relocates pages — full overwrites generate no move reads; exact percentiles; per-shard QoS deltas) and scripts/io_calibration_sweep.sh (fio read-tail-vs-write-rate curve). On the dev box the sub-budget cut mixed-phase read p99 ~2.5x and p99.9 3-5x at identical write throughput, with flat p50. Tests: tests/io_qos.cpp (11 cases: accounting invariants, oversized batches through tiny caps, BG sub-budget bounds under compaction, concurrent merged-write admission, failed-write budget drain, shutdown while blocked on a budget). Full suite green; db_stress 120/120 with tiny caps in both write modes; ASAN (flag-injection mode) clean. Two OOM tests that implicitly relied on the retired per-task drain to bound write-promotion pins now set explicit write budgets. Also: test harness now enforces one started EloqStore per process (InitStore gained cleanup=false for warm-restart tests; cloud tests no longer construct stores directly) — fixes a flaky teardown SIGSEGV where a test-local store Stop nulled the global eloq_store pointer that the lingering singleton prewarm shutdown later dereferenced. Deployment notes: max_inflight_write semantics changed for configs that set it explicitly (was pool sizing only, now a queue-depth cap); max_write_batch_pages is ignored. Real-device calibration should re-derive max_inflight_read (QD sweep); bg_read_ratio is policy and transfers. The M3 background write rate limiter remains deferred pending real-device evidence.
Problem
EloqStore compaction/GC coroutines run on the same shard worker thread as foreground request serving — and in module mode that worker also drives the TxProcessor on the same core. The scheduler (
WorkOneRound/ExecuteReadyTasks) cannot return to serve foreground requests until the running compaction/GC coroutine yields, so a single long non-yielding compaction/GC segment stalls foreground SET/GET for its whole duration. This is the direct cause of deep-tail latency spikes during compaction (the foreground path does no disk I/O of its own, yet both reads and writes spike symmetrically while compaction runs).Investigation localized the long segments to the file-GC path: per-resume occupation of tens of ms in
AugmentRetainedFilesFromBranchManifests(manifest replay + full mapping-table walk) and inDeleteUnreferencedLocal{,Segment}Files(CloseFiles/DeleteFilesbuilding one io_uring SQE per file in a single uninterrupted loop when a partition has accumulated many dead files).Changes
AugmentRetainedFilesFromBranchManifests, the active branch's retained files are already derived from the in-memory mapping byBuildRetainedFiles, and the in-memoryRootMetais at least as current as the on-disk manifest (same invariantSeedActiveBranchGuardFromInMemoryrelies on). So re-reading + replaying the active branch's current-term manifest from disk was pure redundant work — it was the most frequent long GC segment. Skipped only when the in-memoryRootMetais present; non-active branches / archives still replay from disk (they may be updated by other instances and have no trustworthy in-memory mapping here).IouringMgr::DeleteFilesandCloseFiles(128 ops +WaitIoper chunk) so a GC pass over many accumulated dead files no longer builds all unlink/close SQEs in one uninterrupted stretch;WaitIobetween chunks lets the worker serve foreground.MaybeYieldForCompaction/eloqstore_compaction_yield_budget_us, default 500µs) called from the compaction and file-GC loops.Compaction concurrency is intentionally not given a separate cap:
BackgroundWrite(compaction/GC) is a write task counted innum_active_write_, so the existingeloq_store_max_write_concurrencyalready bounds it together with foreground batch writes.Verification
AugmentRetainedFileslong segments (previously the most frequent, ~20ms) are eliminated.Note / follow-up
The residual long GC segments are now
CloseFiles/DeleteFilesover a partition's accumulated dead files; the lever there is reducing per-GC file count (more incremental compaction/GC). Also, for the non-active-branch path,Replayer::Replay(manifest reconstruction) currently has no yield — a candidate for a follow-up when multi-branch/archive manifests are present.🤖 Generated with Claude Code
Summary by CodeRabbit
eloqstore_yield_budget_us, default 20) based on elapsed time since the task was last resumed.MaybeYield()polling for better responsiveness.fdatasyncis intentionally preserved during cleanup to prevent major performance regressions.