Skip to content

perf(storage): task #59 read-vs-spill fairness — off-loop manifest fsync + spill-write pacing - #361

Merged
pilotspacex-byte merged 12 commits into
mainfrom
fix/t59-offloop-manifest-fsync
Jul 17, 2026
Merged

perf(storage): task #59 read-vs-spill fairness — off-loop manifest fsync + spill-write pacing#361
pilotspacex-byte merged 12 commits into
mainfrom
fix/t59-offloop-manifest-fsync

Conversation

@pilotspacex-byte

@pilotspacex-byte pilotspacex-byte commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Task #59 — close the read-vs-spill fairness gap: under a sustained spill flood, cold-plane GETs (and every other command on the same shard) inherited multi-hundred-ms tails because the shard event-loop thread was doing durable manifest commits (up to 2 fsyncs) once per spilled file. strace over an 8s flood window: 163–224 fsync calls per shard thread, 0.68–2.1s cumulative block time, single fsyncs up to 1.0s.

Two levers, both in this PR:

  1. Off-loop manifest writer (src/persistence/manifest_sync.rs): ShardManifest splits into loop-owned RAM state + a per-shard manifest-sync-{id} thread that owns the file handle. Spill-completion commits are deferred and coalescing (newest-root-wins; acks only fire after a persist that supersedes them — superset durability). This is correct because the async spill path only runs under --appendonly yes, where AOF replay + the orphan sweep reconstruct anything a lost manifest commit recorded. commit() keeps its exact blocking durability for the checkpoint protocol and the no-AOF durable batch path. The crash-safety write order (overflow pages fsync'd before the root that references them; dual-slot atomic root flip) is preserved verbatim, now single-threaded inside the agent.
  2. Spill-write pacing (src/storage/tiered/spill_thread.rs): after each durable batch flush, the spill writer yields a 4ms quantum only while a cold read is in flight (COLD_READS_INFLIGHT RAII signal from both the async read pool and the sync MGET/MULTI/Lua paths) and never when the request backlog is deep (≥ 2048 = half queue cap — pacing must not push eviction try_send into -OOM).

Deferred (needs explicit approval for new unsafe): ioprio_set on the spill thread — raw libc syscall, no nix 0.31 wrapper. Measure-first call: the structural fix already removed all loop stalls.

A/B evidence (moon-dev VM, artifact-free raw-socket probes + per-thread strace)

Metric (spill flood) base @ e41aa67 this branch
shard-thread fsyncs / 8s window 222–224 calls, 0.68–0.87s blocked 0 (all on manifest-sync-*)
fresh-conn PING p99 / max 32ms / 55ms 14ms / 36ms
fresh-conn cold GET max 228ms 122ms
idle PING p99 9ms 4ms
spill files created 834 158 (pacing → larger batches)

The fix leg ran on a ~3× slower disk window and still halved the maxima. Residual tail is raw device queueing on the shared VM vdisk (single fsyncs up to 0.75s observed at device level); production NVMe should approach the sub-10ms goal.

Measurement disclosure: the published G2 "205ms worst cold GET" contained a ~100ms client-side constant — fresh redis-cli execs major-page-fault their binary back in under VM page-cache pressure (invisible to strace). The A/B above uses raw /dev/tcp probes timed in-process. Bench protocol note added to project memory.

Gate battery (repl/persistence rule: parity + kill-9 + A/B)

  • Crash matrix crash_matrix_cross_plane: 46/46 cells green (--ignored --test-threads=1, monoio binary)
  • Full tokio suite (--no-default-features --features runtime-tokio,jemalloc, MOON_NO_URING=1): 167 test binaries, 0 failures (3549 lib + integration)
  • Full monoio suite (default features, --release): 166 test binaries, 0 failures
  • cargo clippy -- -D warnings (default + tokio configs, incl. --tests), cargo fmt --check: clean
  • Loom-style unit coverage: 4 new manifest_sync tests (deferred non-blocking, durable blocking, coalescing, shutdown-reclaim) + 4 spill-pacing tests

Drive-by: 5 latently broken test harnesses (unmasked by the gates)

These failures pre-date the branch and were invisible in CI:

  • cmd_flush_dbsize_debug_memory: still passed --persistence-dir, removed in June by 1d26618 — the server exits 2 before accepting; CI stayed green only because the suite self-skips when target/release/moon is absent. Now passes --dir and routes binary discovery through common::find_moon_binary().
  • memory_doctor_response: MOON_BIN-blind hardcoded finder → stale host Mach-O via OrbStack proxy → 30s spawn timeout. Same find_moon_binary() fix.
  • mem_watchdog, oom_bypass_closure, spsc_two_db: rooted server dirs on the repo volume; at <5% free the diskfull write pause shadowed every assertion with MOONERR diskfull. These suites test the RSS watchdog / OOM gate / COPY-MOVE routing — not the disk guard — so they now pass --disk-free-min-pct 0 per the established harness convention.

Follow-up candidate (not this PR): ~15 more test files still hardcode target/release/moon.

Summary by CodeRabbit

  • Performance
    • Moved manifest durability work off the shard event loop and coalesced redundant manifest commits to speed durable progress.
    • Added pacing after durable flushes (including when cold reads are active) and after spill flushes to improve read/write balance.
  • Bug Fixes
    • Fixed a BGSAVE startup race that could swallow snapshot triggers during slower shard initialization.
    • Improved spill-completion manifest persistence by batching it into fewer durable updates.
    • Ensured shutdown correctly flushes pending deferred manifest commits.
  • Tests
    • Updated integration harnesses for correct binary resolution and disabled disk-free guards with --disk-free-min-pct 0.
    • Added a deterministic BGSAVE startup race test and tightened crash-matrix synchronization coverage.

…c thread (task #59 lever 1)

Root cause of the residual task #59 tail (205ms cold-GET during spill flood,
G2 re-run): apply_completion_vec ran ONE DURABLE manifest commit (up to 2
fsyncs) PER SPILLED FILE on the shard event-loop thread. strace decomposition
(2026-07-16, tmp/t59-decompose.sh + tmp/t59-strace.sh): each shard-N thread
spent 1.0-2.1s of an 8s flood window blocked in fsync (163-167 calls, single
calls up to 1.0s) — every connection on the shard stalls behind each one.
PING p50 through a fresh connection measured 103ms under flood AND under
post-flood drain; raw device preads only p50=4ms — the loop, not the disk,
was the dominant stall.

Fix: split ShardManifest into loop-owned RAM state + ManifestIo (file handle,
dual-slot alternation, reopen bookkeeping). enable_deferred_sync() moves
ManifestIo onto a per-shard manifest-sync-{id} thread:
- commit() keeps EXACTLY its old blocking durability (send + ack) — the
  checkpoint protocol and the no-AOF durable batch spill path, where the
  manifest IS the durability record, are unchanged.
- commit_deferred() (new) ships a complete root snapshot and returns
  immediately; consecutive queued snapshots coalesce to the newest (each is
  a full root at a higher epoch). Used ONLY by the spill-completion apply
  path, which runs exclusively under --appendonly yes where AOF replay + the
  orphan sweep reconstruct anything a lost manifest commit recorded.
- apply_completion_vec: one deferred commit per drained batch (was: durable
  commit per file), manifest add_file + cold-index inserts stay RAM-only.
- Shutdown flushes pending commits and reclaims the handle inline
  (shutdown_deferred) before the event loop exits, both runtime arms.

Crash-safety invariant (overflow pages sync'd BEFORE the root that points at
them; dual-slot atomic root flip) is preserved verbatim — persist() is the
old commit() body running on a different thread.

TDD (mirrors cold_read's TEST_INJECT_DELAY_MS pattern):
- deferred_commit_does_not_block_caller_and_reaches_disk — 200ms injected
  sync delay, caller returns <100ms, entry durable after flush (fails by
  construction against the old synchronous path).
- durable_commit_blocks_until_persisted — commit() still waits and is
  immediately reopenable from a second handle.
- queued_deferred_commits_coalesce_to_fewer_persists — 20 queued commits
  coalesce, final superset snapshot lands.
- shutdown_reclaims_io_and_inline_commits_still_work.
Full lib suite: 4343 passed / 0 failed.

Part of task #59 (read-vs-spill fairness); lever 2 (spill pacing) follows.

author: Tin Dang <tindang.ht97@gmail.com>
…readers (task #59 lever 2)

Even with all fsyncs off the event loop (lever 1), a cold pread still waits
behind the spill writer's continuous multi-MB pwrite+fsync stream at the
device level: raw 64KB O_DIRECT reads measured p99 208ms during a G2-shape
SET flood vs 6ms idle (tmp/t59-decompose.sh, 2026-07-16).

Fix: after each durable batch flush, the spill thread consults
COLD_READS_INFLIGHT (a Relaxed atomic incremented by RAII guard in BOTH the
async cold-read pool path and the synchronous MGET/MULTI/Lua read path,
cancel-safe via Drop) and yields one 4ms quantum when a reader is waiting.

Guardrails (spill_pace_after_flush, pure decision fn):
- reads_inflight == 0 -> never pause (zero idle cost)
- request backlog >= REQUEST_QUEUE_CAP/2 -> never pause: a paced spill
  thread must not push eviction into try_send failure, which surfaces as
  -OOM to write clients; full-speed drain wins over read latency there
- shutdown drain path never paces

IO-priority (ioprio_set) deliberately NOT included: nix 0.31 has no wrapper,
a raw libc syscall would add a new unsafe block (requires explicit approval
per UNSAFE_POLICY), and it is only worth proposing if the pacing + lever-1
numbers still miss the target. Measure first.

Tests: 3 unit tests on the decision function (idle-free, backlog-cutoff,
quantum bounds) + RAII guard count/release test. Full lib suite 4347/0.

author: Tin Dang <tindang.ht97@gmail.com>
author: Tin Dang <tindang.ht97@gmail.com>
…ag + MOON_BIN-blind spawn

The 4 tests in cmd_flush_dbsize_debug_memory.rs have been silently broken
since 1d26618 removed the --persistence-dir CLI flag (June 2026): the
spawned server exits with status 2 (clap arg error) and the suite either
fails or self-skips when target/release/moon is absent — which is why CI
(no release binary) stayed green while any run with a built binary went red.

Two fixes:
- Pass --dir (the current flag) instead of the removed --persistence-dir.
- Route binary discovery through common::find_moon_binary() so MOON_BIN is
  honored; the previous hardcoded target/release/moon fallback is the
  documented stale-binary trap on shared checkouts (OrbStack VM exec's a
  host Mach-O via proxy and the server never accepts in-VM).

Verified on moon-dev: 4/4 green in 0.7s with MOON_BIN pinned to the
tokio release-fast binary.

Refs: unmasked while running the task #59 gate battery
author: Tin Dang <tindang.ht97@gmail.com>
…memory_doctor

Running the task #59 gate battery on a near-full dev volume (/Volumes/Games
at <1% free) unmasked four latently environment-fragile suites:

- mem_watchdog, oom_bypass_closure, spsc_two_db: root their server dirs on
  the repo volume, so the 5%-free diskfull write pause shadowed every
  assertion with MOONERR diskfull. These suites test the RSS watchdog, the
  -OOM eviction gate, and COPY/MOVE routing — not the disk guard — so they
  now pass --disk-free-min-pct 0, per the established harness convention
  (gotcha_diskfull_guard_gutted_crash_tests).
- memory_doctor_response: hardcoded target/release/moon finder ignored
  MOON_BIN — on a shared macOS/Linux checkout the VM exec'd a stale host
  Mach-O via OrbStack's proxy and spawn_listening timed out after 30s. Now
  routes through common::find_moon_binary().

All four suites green on moon-dev with MOON_BIN pinned: 16/16 in <8s
(previously 13 failures + one 30s timeout).

author: Tin Dang <tindang.ht97@gmail.com>
… battery

author: Tin Dang <tindang.ht97@gmail.com>
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d032b6ea-1274-4eef-9ac1-b2eddb579ef1

📥 Commits

Reviewing files that changed from the base of the PR and between 25f8aa6 and 737c08a.

📒 Files selected for processing (2)
  • CHANGELOG.md
  • src/shard/event_loop.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/shard/event_loop.rs
  • CHANGELOG.md

📝 Walkthrough

Walkthrough

The change moves manifest persistence to per-shard deferred sync threads, coalesces spill-completion commits, adds cold-read-aware spill pacing, fixes BGSAVE startup trigger handling, and updates integration test launch and crash synchronization.

Changes

Persistence and spill coordination

Layer / File(s) Summary
Manifest I/O backend refactor
src/persistence/manifest.rs
ShardManifest separates logical state from ManifestIo, preserves ordered persistence and compaction recovery, and adds instrumentation and test hooks.
Per-shard deferred manifest synchronization
src/persistence/manifest_sync.rs, src/persistence/mod.rs, src/shard/event_loop.rs, src/shard/persistence_tick.rs
Deferred and durable commits are queued, coalesced, acknowledged, and shut down through a per-shard worker; spill completions issue one deferred commit per batch.
Cold-read-aware spill pacing
src/storage/tiered/cold_read.rs, src/storage/tiered/cold_read_pool.rs, src/storage/tiered/spill_thread.rs, CHANGELOG.md
Cold-read activity is tracked with an RAII guard, and spill flushes pace processing based on reader pressure and backlog depth.

BGSAVE startup handling

Layer / File(s) Summary
Startup trigger handling and regression coverage
src/shard/event_loop.rs, tests/bgsave_startup_race.rs, CHANGELOG.md
Snapshot trigger cursors start at zero, shard completion accounting covers error paths, shutdown flushes deferred manifests, and an integration test verifies BGSAVE completion during shard startup.

Integration test harness corrections

Layer / File(s) Summary
Integration process launch updates
tests/cmd_flush_dbsize_debug_memory.rs, tests/memory_doctor_response.rs, tests/mem_watchdog.rs, tests/oom_bypass_closure.rs, tests/spsc_two_db.rs, CHANGELOG.md
Tests use shared Moon binary discovery, the supported --dir option, and --disk-free-min-pct 0.
Crash timing synchronization
tests/crash_matrix_cross_plane/scenarios.rs
The crash scenario waits for WAL synchronization for both the orders and DLQ queues before termination.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ShardEventLoop
  participant ManifestSyncAgent
  participant ManifestIo
  participant SpillThread
  participant ColdReadPool
  ShardEventLoop->>ManifestSyncAgent: enable deferred sync
  SpillThread->>ManifestSyncAgent: submit deferred manifest batch
  ManifestSyncAgent->>ManifestIo: persist coalesced root
  ColdReadPool->>SpillThread: expose in-flight read count
  SpillThread->>SpillThread: pace after flush when readers are waiting
  ShardEventLoop->>ManifestSyncAgent: shutdown and flush
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description includes a summary, but it omits the required Checklist, Performance Impact, and Notes sections from the template. Add the missing Checklist, Performance Impact, and Notes sections, and fill Performance Impact with results or 'None'.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main performance change: off-loop manifest fsync plus spill-write pacing.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/t59-offloop-manifest-fsync

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
src/persistence/manifest.rs (2)

301-302: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The shared test knob uses a poisoned standard mutex. Replace it with parking_lot::Mutex and remove every .unwrap().

  • src/persistence/manifest.rs#L301-L302: define the knob with parking_lot::Mutex.
  • src/persistence/manifest_sync.rs#L192-L193: acquire with .lock().
  • src/persistence/manifest_sync.rs#L222-L223: acquire with .lock().
  • src/persistence/manifest_sync.rs#L251-L252: acquire with .lock().
  • src/persistence/manifest_sync.rs#L290-L291: acquire with .lock().

As per coding guidelines, “Use parking_lot::RwLock and parking_lot::Mutex instead of std::sync locks; do not use poisoned-lock unwrap patterns.”

🤖 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/persistence/manifest.rs` around lines 301 - 302, The shared
TEST_SYNC_KNOB_LOCK should use parking_lot::Mutex instead of std::sync::Mutex,
and all lock acquisitions must remove poisoned-lock unwrap patterns. Update the
definition in src/persistence/manifest.rs#L301-L302, and replace each
corresponding acquisition with .lock() in
src/persistence/manifest_sync.rs#L192-L193, `#L222-L223`, `#L251-L252`, and
`#L290-L291`.

Source: Coding guidelines


898-1075: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Move ManifestIo into a dedicated module.

This file now exceeds 1,800 lines, and the newly extracted I/O implementation is already a cohesive module boundary.

As per coding guidelines, “No single Rust file should exceed 1500 lines.”

🤖 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/persistence/manifest.rs` around lines 898 - 1075, Move the complete
ManifestIo implementation, including persist and compact and its associated
helpers/state, from manifest.rs into a dedicated persistence I/O module. Update
module declarations, imports, visibility, and all call sites so ManifestIo
remains available through the existing API without changing behavior. Keep
manifest.rs below the 1,500-line guideline.

Source: Coding guidelines

🤖 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.

Inline comments:
In `@src/persistence/manifest_sync.rs`:
- Around line 67-82: The manifest-sync startup path currently drops ManifestIo
when thread creation fails, disabling durable inline operation. Update
manifest_sync::spawn to return the retained I/O alongside startup failure,
preserve IoBackend::Inline in manifest initialization when startup fails, and
update the event-loop startup handling to continue using inline durability
rather than disabling manifest persistence; apply these changes in
src/persistence/manifest_sync.rs:67-82, src/persistence/manifest.rs:541-548, and
src/shard/event_loop.rs:637-638.
- Around line 258-269: Update the test setup around TEST_INJECT_SYNC_DELAY_MS
and the burst loop to wait until TEST_PERSIST_COUNT advances from the captured
before value, confirming the first persist has entered the injected delay before
enqueueing the remaining requests. Keep the delay enabled during that
synchronization, then enqueue the rest of the burst and reset it afterward so
the coalescing assertion is deterministic.
- Around line 17-20: Update commit_deferred and its manifest-sync queueing to
use a bounded(1) wake channel with non-blocking sends, retaining only the latest
snapshot when the worker is behind. Remove the blocking flume::Sender::send
behavior for deferred commits while preserving the existing coalescing and
sync-worker processing semantics.

In `@src/storage/tiered/spill_thread.rs`:
- Around line 731-740: Update reader_inflight_guard_counts_and_releases to avoid
asserting exact values of the process-global COLD_READS_INFLIGHT counter; test
ColdReadInflightGuard with an injected or local counter instead, or serialize
all tests that access the global counter.

---

Nitpick comments:
In `@src/persistence/manifest.rs`:
- Around line 301-302: The shared TEST_SYNC_KNOB_LOCK should use
parking_lot::Mutex instead of std::sync::Mutex, and all lock acquisitions must
remove poisoned-lock unwrap patterns. Update the definition in
src/persistence/manifest.rs#L301-L302, and replace each corresponding
acquisition with .lock() in src/persistence/manifest_sync.rs#L192-L193,
`#L222-L223`, `#L251-L252`, and `#L290-L291`.
- Around line 898-1075: Move the complete ManifestIo implementation, including
persist and compact and its associated helpers/state, from manifest.rs into a
dedicated persistence I/O module. Update module declarations, imports,
visibility, and all call sites so ManifestIo remains available through the
existing API without changing behavior. Keep manifest.rs below the 1,500-line
guideline.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0923bc95-c734-4798-b131-fd9551ef1964

📥 Commits

Reviewing files that changed from the base of the PR and between e41aa67 and 5a5e081.

📒 Files selected for processing (14)
  • CHANGELOG.md
  • src/persistence/manifest.rs
  • src/persistence/manifest_sync.rs
  • src/persistence/mod.rs
  • src/shard/event_loop.rs
  • src/shard/persistence_tick.rs
  • src/storage/tiered/cold_read.rs
  • src/storage/tiered/cold_read_pool.rs
  • src/storage/tiered/spill_thread.rs
  • tests/cmd_flush_dbsize_debug_memory.rs
  • tests/mem_watchdog.rs
  • tests/memory_doctor_response.rs
  • tests/oom_bypass_closure.rs
  • tests/spsc_two_db.rs

Comment thread src/persistence/manifest_sync.rs Outdated
Comment thread src/persistence/manifest_sync.rs Outdated
Comment thread src/persistence/manifest_sync.rs
Comment thread src/storage/tiered/spill_thread.rs Outdated
… commits can never block

Review finding on PR #361, confirmed real: commit_deferred used a blocking
flume send into a bounded(64) request queue. The worker's try_recv drain
keeps up with any live burst, but commits arriving while the worker is
INSIDE a stalled persist (saturated-disk fsync, seconds are realistic)
pile up with no drainer — at 64 deep the shard thread blocks on send,
re-creating exactly the stall the agent exists to remove.

Replace the queue with a latest-snapshot slot (parking_lot::Mutex) plus a
bounded(1) wake-token channel:
- commit_deferred: replace slot + try_send token — never blocks. Full is
  fine (state was written before the send, the pending round observes it);
  Disconnected still fails loudly.
- commit_durable: same handoff, then blocks on its ack as before. Acks are
  pushed with the slot write and taken with it; firing after a newer
  snapshot persists remains correct (a newer root strictly supersedes).
- Coalescing is now by construction: the slot holds only the newest root.

Red/green: new test burst_of_deferred_commits_never_blocks_the_caller
primes the worker into a 400ms-stalled persist, then bursts 100 deferred
commits — the old agent blocks (FAILED, caller waits out the persist), the
new agent absorbs it in <200ms. All 5 manifest_sync unit tests, the 46
crash_matrix_cross_plane kill-9 tests, cold_orphan_sweep, and
manifest_gc_tombstones pass.

refs #361
author: Tin Dang <tindang.ht97@gmail.com>
…BGSAVE no longer hangs

The listener accepts clients as soon as the FASTEST shard's event loop is
up, so a BGSAVE (or auto-save) can broadcast its snapshot epoch while a
slower shard is still initializing. Each shard seeded its epoch cursor
from the watch channel's CURRENT value at loop start
(`last_snapshot_epoch = snapshot_trigger_rx.borrow()`), silently
swallowing such a pending trigger: that shard never snapshotted,
BGSAVE_SHARDS_REMAINING never reached zero, and rdb_bgsave_in_progress
stuck at 1 forever — every later BGSAVE refused as already-in-progress
until restart. Pre-existing (not introduced by this branch); surfaced as a
~15%/run crash_matrix_cross_plane flake under full-suite CPU contention,
where a stack sample of a stuck instance showed all four shard threads
idle in kevent (state-machine hang, not a blocked thread) and INFO showed
rdb_last_save_time:0 — the FIRST BGSAVE of the instance's life had hung.

Fix: start the cursor at 0. Epochs are per-process and start at 0, so 0
is always "nothing seen yet"; a trigger that raced startup is honored on
the shard's first tick.

Red/green: new test-only fault injection MOON_TEST_SLOW_SHARD_START_MS
delays every non-zero shard's loop entry, making the race deterministic —
tests/bgsave_startup_race.rs times out at 10s on the pre-fix cursor every
run and completes in ~1s post-fix. 18 full crash-matrix suite runs after
the fix: zero BGSAVE hangs (was the dominant failure mode).

refs #361
author: Tin Dang <tindang.ht97@gmail.com>
… kill-9

cross_plane_mq_isolated synced durability on a marker pushed to ORDERSQ
and then killed -9 — but the DLQ queue lives on a different shard with an
independent WAL stream that flushes on its own 1ms tick. Under full-suite
CPU contention that tick can slip past the kill, so the cell measured
cross-shard flush-timing luck instead of DLQ durability: autopsy of a
failing run's data dir showed the dlqtestq bytes (create + both pushes +
DLQ routing) absent from EVERY shard's WAL at kill time (2/12 suite runs;
0/30 isolated runs).

Wait for the DLQ queue's own bytes to appear in a WAL before crashing —
the product contract under test (DLQ routing recorded in the WAL survives
kill-9) is unchanged and now tested exactly. 15 suite runs after the fix:
zero mq failures.

refs #361
author: Tin Dang <tindang.ht97@gmail.com>
…istic tests

Three remaining review findings on PR #361, all verified real:

1. A failed manifest-sync thread spawn (thread limit, EAGAIN) destroyed
   the working inline backend: the ManifestIo moved into the dropped
   closure, leaving a manifest that could never commit again until
   restart. spawn() now hands the io to the thread over a channel and
   returns Result<Self, (ManifestIo, io::Error)> — on failure
   enable_deferred_sync keeps IoBackend::Inline and logs, degrading to
   on-loop fsyncs instead of no durability at all.

2. queued_deferred_commits_coalesce_to_fewer_persists raced its own delay
   knob: the injected persist stall was reset right after the burst, so
   scheduling could let the worker miss it and weaken the coalescing bound
   into flakiness. The test now primes the worker INSIDE a stalled persist
   before bursting and keeps the delay armed through the shutdown flush.

3. reader_inflight_guard_counts_and_releases asserted exact values on the
   process-global COLD_READS_INFLIGHT while sibling tests in the same
   binary exercise the cold-read path concurrently. The guard gains
   ColdReadInflightGuard::on(&'static AtomicUsize) and the test verifies
   the RAII mechanics on its own local counter.

refs #361
author: Tin Dang <tindang.ht97@gmail.com>
@pilotspacex-byte

Copy link
Copy Markdown
Contributor Author

Crash-matrix flake investigation (follow-up on this branch)

While validating the review-round fixes, crash_matrix_cross_plane showed a ~15-25%/run failure rate on this branch (merge-base: 14/14 clean). Full root-cause below — two real bugs found and fixed, neither in the new manifest-sync agent.

Mode A — BGSAVE stuck forever (dominant; FIXED in c4cd94c)

A stack sample of a stuck instance showed all four shard threads idle in kevent — not a blocked thread, a state-machine hang. INFO showed rdb_last_save_time:0: the first BGSAVE of the instance's life had hung. Root cause: each shard seeded its snapshot-epoch cursor from the trigger watch channel's current value at event-loop start. The listener answers clients as soon as the fastest shard is up, so a BGSAVE broadcast while a slower shard was still initializing was silently swallowed by that shard → BGSAVE_SHARDS_REMAINING never reached 0 → rdb_bgsave_in_progress:1 forever. Pre-existing (reachable in v0.8.0); this branch's timing shifts merely amplified the window. Reproduced deterministically with a delayed shard start (MOON_TEST_SLOW_SHARD_START_MS fault injection, new), pinned by tests/bgsave_startup_race.rs (red: 10s timeout every run; green: ~1s).

Mode C — MQ DLQ entries "lost" across kill-9 (FIXED in 0598d86, harness)

Autopsy of a failing run's data dir: the DLQ queue's bytes (create + pushes + routing) were absent from every shard's WAL at kill time — the cell synced durability on a marker pushed to a different shard's queue, then killed -9. Per-shard WAL streams flush independently on each shard's 1ms tick; under full-suite CPU contention the DLQ shard's tick slips past the kill. The cell now also waits for the DLQ queue's own bytes before crashing. Product contract unchanged and now tested exactly. (0/30 isolated runs failed; only suite-parallel runs raced.)

Mode B — rare ConnectionReset (~1/18, OPEN)

Server-side stderr shows no panic; different cells each time (kv legacy, mixedpassc legacy, graphdrop s1). Not attributable to this branch with current evidence (a 6% rate is compatible with the clean 14-run baseline). Tracking separately in #365.

Validation

18 full-suite runs with all fixes: 0 BGSAVE hangs, 0 mq failures (runs after the harness fix compiled in), 1 mode-B reset. Plus: 46/46 crash matrix (single run), manifest_sync unit tests 5/5, bgsave_startup_race red/green, fmt + clippy (default & tokio feature sets) + unwrap-ratchet clean.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
CHANGELOG.md (1)

55-63: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Clarify the five-suite scope.

This wording can imply that all five suites both switched to MOON_BIN resolution and added --disk-free-min-pct 0. The detailed description indicates these fixes were split across the suites; say “across five suites” to avoid overstating the per-suite changes.

🤖 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 `@CHANGELOG.md` around lines 55 - 63, Clarify the changelog entry’s opening to
say the fixes occurred across five integration suites, rather than implying
every suite received both changes. Preserve the existing breakdown identifying
which suites use MOON_BIN and which receive --disk-free-min-pct 0.
♻️ Duplicate comments (1)
src/persistence/manifest_sync.rs (1)

303-316: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Synchronize on persist entry instead of sleeping.

A loaded scheduler can delay the worker beyond 50 ms, so both tests may enqueue their bursts before the injected stall begins and falsely pass. Wait with a deadline until TEST_PERSIST_COUNT advances from its baseline.

This repeats the earlier synchronization finding, which remains applicable to the current fixed-sleep implementation.

Also applies to: 350-359

🤖 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/persistence/manifest_sync.rs` around lines 303 - 316, Replace the fixed
50 ms sleep in the deferred-persist test around TEST_INJECT_SYNC_DELAY_MS with
deadline-based polling that waits until TEST_PERSIST_COUNT advances from its
recorded baseline, then enqueue the burst. Apply the same synchronization change
to the corresponding block around the second test location, preserving the
existing stall and shutdown flow.
🧹 Nitpick comments (1)
tests/bgsave_startup_race.rs (1)

71-92: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Improve test robustness by using common::spawn_listening.

The manual process spawn and Conn::open loop can hang for 30 seconds if the child process fails to bind the port or crashes at startup (e.g., due to a port collision in parallel CI). Using the common::spawn_listening helper handles this cleanly by detecting child exits and retrying with a fresh port.

🛠️ Proposed refactor
-    let port = common::reserve_port();
-    let mut child = std::process::Command::new(find_moon_binary());
-    child
-        .args([
-            "--port",
-            &port.to_string(),
-            "--dir",
-            &dir.path().to_string_lossy(),
-            "--shards",
-            "4",
-            "--appendonly",
-            "yes",
-            "--disk-free-min-pct",
-            "0",
-        ])
-        // Shards 1-3 enter their event loops ~500ms late; shard 0 (and the
-        // listener) come up immediately, so the BGSAVE below lands inside
-        // the startup window.
-        .env("MOON_TEST_SLOW_SHARD_START_MS", "500")
-        .stdout(std::process::Stdio::null())
-        .stderr(std::process::Stdio::null());
-    let mut child = child.spawn().expect("spawn moon");
+    let (mut child, port) = common::spawn_listening(|port| {
+        let mut child = std::process::Command::new(find_moon_binary());
+        child
+            .args([
+                "--port",
+                &port.to_string(),
+                "--dir",
+                &dir.path().to_string_lossy(),
+                "--shards",
+                "4",
+                "--appendonly",
+                "yes",
+                "--disk-free-min-pct",
+                "0",
+            ])
+            // Shards 1-3 enter their event loops ~500ms late; shard 0 (and the
+            // listener) come up immediately, so the BGSAVE below lands inside
+            // the startup window.
+            .env("MOON_TEST_SLOW_SHARD_START_MS", "500")
+            .stdout(std::process::Stdio::null())
+            .stderr(std::process::Stdio::null());
+        child.spawn().expect("spawn moon")
+    });
🤖 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 `@tests/bgsave_startup_race.rs` around lines 71 - 92, Replace the manual
process construction and startup waiting in the BGSAVE race test with
common::spawn_listening, preserving the existing Moon arguments, environment
configuration, and child-process handle usage. Ensure the helper supplies the
selected listening port and handles startup failures by detecting child exits
and retrying with a fresh port.
🤖 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.

Inline comments:
In `@src/persistence/manifest_sync.rs`:
- Around line 153-156: Update ManifestSync::shutdown to join the worker thread
before waiting on gb_rx, then clear the stored shutdown sender in SyncShared
before calling gb_rx.recv(). Preserve the existing reclaimed ManifestIo return
behavior while ensuring a panicked worker cannot leave the receiver blocked by
an unconsumed sender.

In `@tests/crash_matrix_cross_plane/scenarios.rs`:
- Around line 478-485: Update the synchronization after the DLQ operation to
wait for a unique payload from the later DLQ-routing write, rather than
dlqtestq.as_bytes(), which also occurs in queue creation. Use the DLQ
operation’s distinctive bytes or the owning shard’s WAL position with
harness::wait_for_wal_v3_bytes_any_shard, while preserving the crash timing and
durability check.

---

Outside diff comments:
In `@CHANGELOG.md`:
- Around line 55-63: Clarify the changelog entry’s opening to say the fixes
occurred across five integration suites, rather than implying every suite
received both changes. Preserve the existing breakdown identifying which suites
use MOON_BIN and which receive --disk-free-min-pct 0.

---

Duplicate comments:
In `@src/persistence/manifest_sync.rs`:
- Around line 303-316: Replace the fixed 50 ms sleep in the deferred-persist
test around TEST_INJECT_SYNC_DELAY_MS with deadline-based polling that waits
until TEST_PERSIST_COUNT advances from its recorded baseline, then enqueue the
burst. Apply the same synchronization change to the corresponding block around
the second test location, preserving the existing stall and shutdown flow.

---

Nitpick comments:
In `@tests/bgsave_startup_race.rs`:
- Around line 71-92: Replace the manual process construction and startup waiting
in the BGSAVE race test with common::spawn_listening, preserving the existing
Moon arguments, environment configuration, and child-process handle usage.
Ensure the helper supplies the selected listening port and handles startup
failures by detecting child exits and retrying with a fresh port.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 20901d03-ca24-4ecb-bdc2-3b3a1c3b17aa

📥 Commits

Reviewing files that changed from the base of the PR and between 5a5e081 and 25f8aa6.

📒 Files selected for processing (8)
  • CHANGELOG.md
  • src/persistence/manifest.rs
  • src/persistence/manifest_sync.rs
  • src/shard/event_loop.rs
  • src/storage/tiered/cold_read_pool.rs
  • src/storage/tiered/spill_thread.rs
  • tests/bgsave_startup_race.rs
  • tests/crash_matrix_cross_plane/scenarios.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/storage/tiered/cold_read_pool.rs
  • src/persistence/manifest.rs
  • src/storage/tiered/spill_thread.rs

Comment on lines +153 to +156
pub(crate) fn shutdown(mut self) -> Option<ManifestIo> {
let (gb_tx, gb_rx) = flume::bounded::<ManifestIo>(1);
self.shared.lock().shutdown = Some(gb_tx);
let _ = self.notify();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Join the worker before waiting for reclaimed I/O.

If the worker panics, shutdown stores gb_tx in SyncShared and then blocks forever on gb_rx.recv() because that sender remains alive. Join first, then remove any unconsumed shutdown sender before receiving.

Proposed fix
     pub(crate) fn shutdown(mut self) -> Option<ManifestIo> {
         let (gb_tx, gb_rx) = flume::bounded::<ManifestIo>(1);
         self.shared.lock().shutdown = Some(gb_tx);
         let _ = self.notify();
-        let io = gb_rx.recv().ok();
         if let Some(j) = self.join.take() {
             let _ = j.join();
         }
-        io
+        // Drop the sender if the worker exited before consuming it.
+        self.shared.lock().shutdown.take();
+        gb_rx.recv().ok()
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub(crate) fn shutdown(mut self) -> Option<ManifestIo> {
let (gb_tx, gb_rx) = flume::bounded::<ManifestIo>(1);
self.shared.lock().shutdown = Some(gb_tx);
let _ = self.notify();
pub(crate) fn shutdown(mut self) -> Option<ManifestIo> {
let (gb_tx, gb_rx) = flume::bounded::<ManifestIo>(1);
self.shared.lock().shutdown = Some(gb_tx);
let _ = self.notify();
if let Some(j) = self.join.take() {
let _ = j.join();
}
// Drop the sender if the worker exited before consuming it.
self.shared.lock().shutdown.take();
gb_rx.recv().ok()
}
🤖 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/persistence/manifest_sync.rs` around lines 153 - 156, Update
ManifestSync::shutdown to join the worker thread before waiting on gb_rx, then
clear the stored shutdown sender in SyncShared before calling gb_rx.recv().
Preserve the existing reclaimed ManifestIo return behavior while ensuring a
panicked worker cannot leave the receiver blocked by an unconsumed sender.

Comment on lines +478 to +485
// The marker proves ORDERSQ's shard flushed its WAL stream — it says
// nothing about the shard that owns the DLQ queue: per-shard WAL streams
// flush independently on each shard's own 1ms tick, and under CPU
// starvation (full-suite parallelism) that tick can slip past this kill.
// Sync on the DLQ queue's own bytes before crashing, or this cell
// measures cross-shard flush-timing luck instead of DLQ durability
// (observed: dlqtestq absent from EVERY WAL at kill time, 2/12 runs).
harness::wait_for_wal_v3_bytes_any_shard(&dir, cfg.shards, dlqtestq.as_bytes());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Wait for a unique DLQ write. dlqtestq.as_bytes() already appears in the queue-creation WAL record, so this byte search can succeed before the later DLQ-routing records are flushed. Use a payload that only appears in the DLQ operation, or wait on the owning shard’s WAL position instead.

🤖 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 `@tests/crash_matrix_cross_plane/scenarios.rs` around lines 478 - 485, Update
the synchronization after the DLQ operation to wait for a unique payload from
the later DLQ-routing write, rather than dlqtestq.as_bytes(), which also occurs
in queue creation. Use the DLQ operation’s distinctive bytes or the owning
shard’s WAL position with harness::wait_for_wal_v3_bytes_any_shard, while
preserving the crash timing and durability check.

tests/bgsave_startup_race.rs failed in CI (runtime-tokio): the tokio shard
loop's snapshot-finalize path called finalize_snapshot_success/error but —
unlike the monoio arm — never called bgsave_shard_done, so
BGSAVE_SHARDS_REMAINING stayed at num_shards and rdb_bgsave_in_progress
stuck at 1 after EVERY sharded BGSAVE under runtime-tokio. Pre-existing
and CI-invisible until now: the crash suites that await BGSAVE are
`#[ignore]`d (run only in the local VM battery, which uses monoio), so no
CI test had ever waited for a tokio BGSAVE to complete.

Mirror the monoio arm: bgsave_shard_done(true/false) after finalize. Safe
for auto-save-triggered snapshots — the counter ignores calls at 0.

Red: CI Check job, 3/3 retries timing out at the 10s deadline (and the
same locally with --features runtime-tokio,jemalloc). Green: 0.99s local
tokio run; monoio leg unaffected (1.35s).

refs #361
author: Tin Dang <tindang.ht97@gmail.com>
@pilotspacex-byte
pilotspacex-byte merged commit 84a1be5 into main Jul 17, 2026
8 checks passed
@TinDang97
TinDang97 deleted the fix/t59-offloop-manifest-fsync branch July 17, 2026 16:11
pilotspacex-byte added a commit that referenced this pull request Jul 19, 2026
…eset (#393)

Patch release folding the post-v0.8.0 perf/correctness train (#361#392) plus a single-shard tuning preset (broadened --profile standalone + conf/moon-standalone.conf). O3 contention governor makes --io-busy-poll-us deploy-safe on any host. Adversarial review caught + fixed a conf-file arena-cap false-claim before merge. Gate: crash-matrix nightly + ITERS=20 soak green on RC.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants