Skip to content

fix(persistence): async-park cold GET off shard event loop — no stall, no resurrection (task #59) - #323

Merged
pilotspacex-byte merged 1 commit into
mainfrom
worktree-agent-a6e37c265c0d5ef0e
Jul 14, 2026
Merged

fix(persistence): async-park cold GET off shard event loop — no stall, no resurrection (task #59)#323
pilotspacex-byte merged 1 commit into
mainfrom
worktree-agent-a6e37c265c0d5ef0e

Conversation

@pilotspacex-byte

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

Copy link
Copy Markdown
Contributor

Task #59 — Cold GET no longer stalls behind spill/AOF write backlog

Problem (G2 criterion-2 tail spike, real disk): Database::get() on a spilled key did a blocking open+pread inline on the single-threaded shard event loop. Under active spill/AOF write backlog that pread stalled up to ~1.9s, blocking every connection on that shard (even resident-key GETs and PINGs queued behind it — per-shard blast radius, empirically confirmed).

Fix

  • Off-loop cold read (src/storage/tiered/cold_read_pool.rs): a small worker pool performs the blocking open+pread; the requesting monoio task parks on read_cold_entry_async (flume async recv, no timeout) while sibling connections on the same shard keep running. Mirrors the vector engine's SegmentReloadPool precedent.
  • GET-only pre-warm hook (handler_monoio): peek hot/cold status synchronously → if cold, .await the off-loop read → apply outcome → fall through to the unchanged dispatch_read. MGET/HGET/MULTI/Lua paths stay synchronous.

Correctness: no nil-on-timeout, no resurrection

Two races closed (both found in review before merge):

  1. First attempt rejected — a 100ms recv_timeout returned Miss under the very backlog it targets → GET wrongly returned nil for an existing key. Replaced with an unbounded async park.
  2. DEL/FLUSHDB/expiry-during-await resurrection — the .await is a shard yield point; a concurrent DEL of the cold key during the ~1.9s window would leave promote_cold_outcome applying a stale Hit, permanently resurrecting a deleted key. Fixed by revalidating — synchronously, no .await between check and mutate — that the cold index still maps the key to the exact same ColdLocation the outcome was read from; mismatch/absence discards the outcome and the normal dispatch answers from current state. promote_cold_if_present (the sync MULTI/Lua path) routes through the same pipeline so the invariant lives in one place.

Tests

  • test_del_during_cold_read_await_does_not_resurrect_key — reproduces the exact TOCTOU (stale Hit resolves after DEL) and asserts no resurrection at hot / cold-index / subsequent-GET layers.
  • test_async_cold_read_returns_correct_value_never_times_out_to_wrong_answer, test_slow_cold_read_does_not_starve_sibling_task (sibling non-starvation).
  • storage::tiered::cold_read 13 tests, storage::db 34, storage::tiered::cold_index 13 — all green on default (monoio) + runtime-tokio,jemalloc.

fmt clean; clippy -D warnings clean both matrices.

Summary by CodeRabbit

  • Bug Fixes

    • Cold-tier GET requests no longer block other connections during storage reads by resolving cold data asynchronously off the event loop.
    • Existing cold entries are no longer incorrectly treated as misses during slow reads.
    • Deleted/cleared keys cannot reappear after an in-progress cold read.
    • Genuine missing keys still return the expected fast “not found” response.
    • Inline GET for cold keys now declines inlining instead of risking blocking behavior.
  • Tests

    • Added coverage for delayed cold reads, non-blocking behavior, correct inline vs non-inline handling, expiry reclaim, and concurrent deletion scenarios.

@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 13, 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: c4a168ca-3b68-435a-ab4a-427785f1f227

📥 Commits

Reviewing files that changed from the base of the PR and between 1c66bdb and ad19fd4.

📒 Files selected for processing (9)
  • CHANGELOG.md
  • src/server/conn/blocking.rs
  • src/server/conn/handler_monoio/mod.rs
  • src/server/conn/tests.rs
  • src/storage/db.rs
  • src/storage/tiered/cold_index.rs
  • src/storage/tiered/cold_read.rs
  • src/storage/tiered/cold_read_pool.rs
  • src/storage/tiered/mod.rs
🚧 Files skipped from review as they are similar to previous changes (9)
  • src/storage/tiered/mod.rs
  • src/storage/tiered/cold_index.rs
  • src/server/conn/handler_monoio/mod.rs
  • src/storage/tiered/cold_read.rs
  • CHANGELOG.md
  • src/storage/db.rs
  • src/server/conn/blocking.rs
  • src/server/conn/tests.rs
  • src/storage/tiered/cold_read_pool.rs

📝 Walkthrough

Walkthrough

Cold-tier GETs now use off-shard asynchronous reads without timeout-based misses, revalidate cold-index mappings before promotion, and bypass the blocking inline path for real cold entries while preserving genuine miss responses.

Changes

Cold-tier GET flow

Layer / File(s) Summary
Off-shard cold-read worker pool
src/storage/tiered/cold_read_pool.rs, src/storage/tiered/mod.rs
Blocking cold reads run on dedicated workers through an async API that preserves actual outcomes and falls back synchronously on worker errors; tests cover timing, misses, and executor yielding.
Cold-result promotion and revalidation
src/storage/tiered/cold_index.rs, src/storage/tiered/cold_read.rs, src/storage/db.rs
Cold outcomes are promoted only when the index still maps the key to the read location; expired entries are reclaimed and misses remain misses.
GET routing and inline fallback
src/server/conn/blocking.rs, src/server/conn/handler_monoio/mod.rs, src/server/conn/tests.rs, CHANGELOG.md
Indexed cold GETs leave inline dispatch for asynchronous handling, while genuine misses still return an inline null response; the behavior is documented and tested.

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

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant MonoioHandler
  participant ColdReadPool
  participant Database
  participant ColdIndex
  Client->>MonoioHandler: GET cold key
  MonoioHandler->>ColdReadPool: await read_cold_entry_async
  ColdReadPool-->>MonoioHandler: ColdReadOutcome
  MonoioHandler->>Database: promote_cold_outcome
  Database->>ColdIndex: revalidate ColdLocation
  ColdIndex-->>Database: current mapping
  Database-->>MonoioHandler: promoted or rejected outcome
  MonoioHandler-->>Client: GET response
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the change well, but it does not follow the required template sections for Summary, Checklist, Performance Impact, and Notes. Rewrite the PR description using the repository template and add the missing Summary, Checklist, Performance Impact, and Notes sections.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: moving cold GET off the shard event loop and preventing stall/resurrection issues.
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 worktree-agent-a6e37c265c0d5ef0e

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.

@TinDang97
TinDang97 force-pushed the worktree-agent-a6e37c265c0d5ef0e branch from 5774447 to 1c66bdb Compare July 13, 2026 17:15

@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: 1

🧹 Nitpick comments (1)
src/storage/tiered/cold_read_pool.rs (1)

39-82: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Unbounded job queue can grow without bound under sustained disk backlog.

job_sender() uses flume::unbounded for the job queue, so submission never applies backpressure. Under the exact scenario this feature targets — sustained spill/AOF disk backlog with continuous cold GET traffic outpacing the 2 (or configured) worker threads — queued ColdReadJobs (each holding a PathBuf clone + reply sender) can accumulate without limit, trading a shard-thread stall for unbounded worker-pool memory growth.

Since the design already commits to "no timeout, ever," a bounded queue (e.g. flume::bounded(N)) combined with send_async().await on submission would apply natural backpressure to callers — slowing new cold GETs under backlog without ever manufacturing a wrong answer — instead of growing memory unboundedly.

♻️ Proposed bounded-queue approach
-        let (tx, rx) = flume::unbounded::<ColdReadJob>();
+        let (tx, rx) = flume::bounded::<ColdReadJob>(1024); // tune capacity

And in read_cold_entry_async, submit via the async sender instead of the sync one so a full queue suspends the caller (yielding to siblings) rather than growing memory:

-    if job_sender().send(job).is_err() {
+    if job_sender().send_async(job).await.is_err() {
         return read_cold_entry(shard_dir, location, now_ms, None);
     }
🤖 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/storage/tiered/cold_read_pool.rs` around lines 39 - 82, Replace the
unbounded queue created in job_sender with a bounded flume queue sized by an
appropriate pool-queue capacity, and update read_cold_entry_async to submit
ColdReadJob via the sender’s async send operation. Preserve the existing
send-failure fallback and ensure a full queue suspends the caller rather than
allocating additional jobs.
🤖 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/storage/tiered/cold_read.rs`:
- Around line 465-469: In the test guard acquisition near the cold-read
expiry-reclaim path, replace TEST_DELAY_LOCK.lock().unwrap() with the existing
poisoned-lock recovery pattern using into_inner(), matching
test_inline_get_declines_for_cold_key_instead_of_blocking in conn tests. Also
update the preceding comment to describe the current synchronous
promote_cold_if_present behavior without referencing the removed off-thread pool
or timeout design.

---

Nitpick comments:
In `@src/storage/tiered/cold_read_pool.rs`:
- Around line 39-82: Replace the unbounded queue created in job_sender with a
bounded flume queue sized by an appropriate pool-queue capacity, and update
read_cold_entry_async to submit ColdReadJob via the sender’s async send
operation. Preserve the existing send-failure fallback and ensure a full queue
suspends the caller rather than allocating additional jobs.
🪄 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: d343e428-a790-4eb3-9a50-88ec8d760e6b

📥 Commits

Reviewing files that changed from the base of the PR and between 0039795 and 1c66bdb.

📒 Files selected for processing (9)
  • CHANGELOG.md
  • src/server/conn/blocking.rs
  • src/server/conn/handler_monoio/mod.rs
  • src/server/conn/tests.rs
  • src/storage/db.rs
  • src/storage/tiered/cold_index.rs
  • src/storage/tiered/cold_read.rs
  • src/storage/tiered/cold_read_pool.rs
  • src/storage/tiered/mod.rs

Comment thread src/storage/tiered/cold_read.rs
…t (task #59)

`Database::get()`'s cold-tier fallback (`promote_cold_if_present` ->
`cold_read::read_cold_entry`) does a blocking `std::fs::File::open` + pread
inline on the single-threaded shard event loop. Under spill/AOF write
backlog on the same disk, that pread can block for up to ~1.9s, stalling
every connection on the shard for the duration -- confirmed root cause of
task #59.

An earlier revision of this fix bounded the wait with a timeout and fell
back to `ColdReadOutcome::Miss` on expiry, applied uniformly to all call
sites. Review caught that this was a correctness regression: on timeout,
`promote_cold_if_present` returned false without promoting, so a GET on an
EXISTING spilled key could return nil under disk backlog -- strictly worse
than a slow-but-correct answer. It also only partially fixed the latency:
the shard thread still blocked on `recv_timeout`, just for a shorter,
still-nonzero window. That mechanism has been fully removed; there is no
timeout anywhere in this change now.

Corrected design: `Database`/dispatch are plain synchronous functions with
no `.await`-capable call site of their own (MULTI/EXEC bodies run inside a
sync closure; Lua `redis.call` cannot suspend `mlua`'s C-call reentry at
all). But the connection handler that calls them
(`handle_connection_sharded_monoio`, `src/server/conn/handler_monoio/mod.rs`)
is already `async fn` and already `.await`s other things per command (AOF
group-commit sends). That's the one seam where a real non-blocking wait is
possible: for GET specifically, peek (cheap, in-memory) whether the key
needs a disk read; if so, `.await` the ACTUAL result via a new off-thread
worker pool (`storage::tiered::cold_read_pool::read_cold_entry_async`, 2
threads by default, `MOON_COLD_READ_POOL_THREADS` override, using flume's
`recv_async()` for the same cross-thread-wake pattern CLAUDE.md already
documents for monoio) -- no timeout, ever. The awaiting connection task
yields, so sibling connections on the same shard thread keep running while
the real pread completes; the resolved outcome (never a placeholder) is
applied via the new `Database::promote_cold_outcome` before the unmodified
synchronous `dispatch_read` answers from now-hot RAM.

MULTI/EXEC bodies, Lua `redis.call`, and every non-GET command (MGET/HGET/
LRANGE/etc.) are left on the ORIGINAL synchronous blocking read, completely
unchanged from before this task -- `Database::promote_cold_if_present` and
`Database::get_cold_value` call `cold_read::cold_read_through_outcome`/
`cold_read::cold_read_through` directly again, exactly as pre-task-59.
Slower under backlog for those paths, same as always, but never wrong. Only
the default `runtime-monoio` connection handler got the GET pre-warm hook in
this pass; the `runtime-tokio` handler (`handler_sharded/mod.rs`) is
unchanged, tracked as follow-up. See tmp/task59-design.md for the full
call-site audit, the corrected design rationale, and the explicit list of
what remains out of scope.

Testing: `storage::tiered::cold_read_pool` has a deterministic RED/GREEN
suite using a test-only injected-latency hook
(`cold_read::TEST_INJECT_DELAY_MS`):
- `test_direct_cold_read_blocks_caller_for_full_delay` -- RED, still true
  for the unchanged synchronous paths.
- `test_async_cold_read_returns_correct_value_never_times_out_to_wrong_answer`
  -- the corrected property: a 400ms injected delay makes the async call
  take >= 400ms (never truncates) AND return the real on-disk value.
- `test_async_cold_read_correct_on_fast_path`,
  `test_async_cold_read_genuine_miss_stays_miss` -- no regressions.
- `test_slow_cold_read_does_not_starve_sibling_task` -- on a
  `#[tokio::test(flavor = "current_thread")]` single-threaded executor, a
  task awaiting a 300ms-injected-delay cold read is spawned alongside an
  independent sibling task; the sibling completes in << 100ms (proving the
  await point genuinely yields) while the slow reader still resolves to the
  correct value.

Round 2 review caught a second race: a `DEL`/`FLUSHDB` on the SAME key
running on the SAME shard thread while GET's `.await` is suspended is not
covered by the `contains_key` guard (a cold-only key has no hot entry to
catch it), so the stale `Hit` outcome that resolves afterward would
resurrect the deleted key into hot RAM permanently -- a durable correctness
violation, and the likely case (not a corner one) given the window is the
full read duration under exactly the backlog this fix targets. Fixed:
`ColdLocation` now derives `PartialEq`/`Eq`; `promote_cold_outcome` takes
the `expected_location` the outcome was read from and revalidates,
synchronously with no intervening `.await`, that the cold index still maps
the key to that SAME location before promoting a `Hit`/`Expired` outcome --
otherwise the outcome is discarded and the caller's normal dispatch answers
from current state (nil for a deleted key). New regression test
`test_del_during_cold_read_await_does_not_resurrect_key` reproduces the
exact race deterministically (poll the cold-read future just enough to
submit the job, run `DEL` before the injected-delay job resolves, then
apply the resulting stale outcome) and asserts the key stays absent both
immediately and on a subsequent GET.

Round 3 review caught that rounds 1-2, though internally correct, landed
the fix on a branch plain `GET key` never reaches. `blocking.rs`'s
synchronous inline fast path (`try_inline_dispatch`) handles GET
UNCONDITIONALLY (unlike SET, GET inlining is not gated by disk-offload --
see the comment at handler_monoio ~691-693) and, before this change, its
`GetOutcome::Miss` branch called `cold_read::read_cold_entry_at` directly
-- a synchronous blocking pread right there on the shard thread. That IS
the ~1.9s inline stall this task targets, and it's what redis-benchmark GET
and the task's own repro actually exercise; the async pre-warm hook from
rounds 1-2 is only reached for commands the inline loop declines, which
plain GET never did. Fixed: the inline GET path now distinguishes
"genuinely absent from both tiers" (`cold_lookup_location` -> `None`,
still answered inline with a fast `$-1`) from "a real cold entry exists"
(`Some`) -- the latter now declines to inline (returns the pre-existing
"not inlined" sentinel `0`, command bytes left unconsumed, exactly like
every other early-return in that function) instead of doing the blocking
read, so it falls through to the already-correct async pre-warm hook. No
new fall-through machinery needed -- reused the existing decline path
`try_inline_dispatch_loop`/`handler_monoio` already handle correctly for
other cases (malformed command, write-inlining disabled, cross-shard key).
New test `test_inline_get_declines_for_cold_key_instead_of_blocking` in
`src/server/conn/tests.rs` injects a 1000ms cold-read delay and asserts
`try_inline_dispatch` returns in well under 200ms with sentinel `0` and the
input bytes completely unconsumed -- proving it never touches the slow
path, not merely "returns before the delay elapses". Companion test
`test_inline_get_genuine_miss_still_answers_inline` pins the unaffected
genuine-miss case.

Verified on the Linux VM (moon-dev), prior to the round-3 fix, under real
disk-offload pressure (allkeys-lru + --disk-offload, maxmemory 300KB
forcing eviction+spill of ~130 of 400 written keys): a key SET then spilled
returns its EXACT known value on GET (not nil). That run predates the
round-3 fix and, per round-3's own finding, was unknowingly exercising the
OLD blocking inline path rather than the async-park mechanism (the disk
just happened to be fast enough with no real write backlog present at that
moment for the concurrent PING to still look fast) -- so it remains valid
evidence of correctness under real disk-offload, but is NOT evidence the
async-park mechanism was engaged. A live wall-clock before/after A/B on the
NOW-corrected inline-decline path remains a recommended follow-up before
merge. fmt/clippy clean on both default (monoio) and
runtime-tokio,jemalloc feature matrices; `storage::tiered::cold_read` (13
tests), `storage::db` (34 tests), `storage::tiered::cold_index` (13 tests),
and `server::conn::tests` (14 tests, inline dispatch) suites all green,
re-run for flakes.

Follow-up (not in this change): extend the async-park pattern to
MGET/HGET/LRANGE/GETRANGE/STRLEN and to the runtime-tokio connection
handler (`handler_sharded/mod.rs`, which has its own inline+dispatch_read
paths with the same GET-inlining shape); a live wall-clock before/after A/B
of the production ~1.9s stall under real (non-injected) concurrent-write
backlog against the corrected inline-decline path.

author: Tin Dang
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