Skip to content

fix(storage): in-flight spills are a storage plane, not a bookkeeping note (#459) - #465

Merged
TinDang97 merged 3 commits into
mainfrom
fix/459-inflight-spill-visibility
Aug 10, 2026
Merged

fix(storage): in-flight spills are a storage plane, not a bookkeeping note (#459)#465
TinDang97 merged 3 commits into
mainfrom
fix/459-inflight-spill-visibility

Conversation

@TinDang97

@TinDang97 TinDang97 commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes #459.

Filed as a possible durability bug from a flaky dbsize_offload_logical
assertion (live 373, recovered 400). That symptom turned out to be the
mildest of three faults from one root cause.

Root cause

evict_one_async_spill freed the hot entry the instant the SpillRequest was
queued, and cold_index only learned the key when the completion landed. In
between, the key was in no plane the database consultsspill_inflight
held only a request id and was read solely by the completion path.

The eviction code named the window and accepted it:

// Accept a brief read-miss until the completion applies — the key is
// safe: it is in the SpillRequest [...] AOF incr log is the durability
// backstop for the pre-flush window.

The reasoning was half right. The tentative cold_index entry was correctly
rejected (the slot is unknown at evict time, so it would serve a different
key's value), and the AOF genuinely does backstop durability. What nothing
covered is visibility — a read-miss is not a latency artifact, it is a
wrong answer — and nothing considered a write landing inside the window.

Measured

origin/main @4c9bd2c5, 400 × 4 KiB keys against a 512 KiB cap, 1 shard,
--disk-offload enable (the failing test's exact config):

before after
DBSIZE after all SETs acked 124, climbing to 400 unaided 400 immediately
GET / EXISTS on live keys 276 nil; returned 250 ms later with no write 0 nil
DEL acked, then reversed 277/400 answered :0, all 277 read back 400 acked, DBSIZE 0

The third is the severe one and it is not transient: the completion path
inserted into cold_index unconditionally, so the resurrections reached
the manifest and survived restart. A client that deleted data got it back.

Blast radius is --disk-offload enable, which is opt-in — but for those users
it is a P0.

Fix

spill_inflight becomes the third storage plane it always was, carrying the
payload. That payload is the same refcounted Bytes the queued request
already pins
, so this is a refcount rather than a copy and adds no peak
memory.

  • reads promote from it with no disk read, on all four paths that can
    reach a cold key: promote_cold_if_present (collections, Lua, MULTI), the
    monoio async GET pre-warm, the inline GET fast path (which was framing
    $-1 inline), and get_cold_value for the &self shared-read
    *_readonly handlers;
  • EXISTS / DEL count it; DBSIZE and logical_len count it; KEYS and
    RANDOMKEY enumerate it;
  • DEL, an overwriting SET, and a promoting read each retire the record,
    which withdraws the completion's authorization to publish
    . That is what
    makes a delete inside the window final, and it also stops a stale payload
    landing as a cold shadow behind a newer hot value;
  • unpublished completions are counted as spill_completion_superseded.

Non-spilling servers pay one is_empty() load on the affected paths.

Two traps worth knowing about for review

  • Ordering is load-bearing. db.remove() clears the in-flight record
    (that is what makes DEL final), so the mark must follow it — marking
    first erases itself. Both run synchronously on the shard thread with no
    .await between, so no reader sees the gap.
  • get_readonly calls get_cold_value directly, not via
    cold_read_only. Patching cold_read_only looked correct and changed
    nothing; monoio was fully green while tokio still failed with EXISTS=1 and
    GET=nil. All CI test jobs build tokio, so both legs were run.

Verification

Red-first. tests/spill_inflight_visibility.rs — one wire-level test per
fault, each failing on the pre-fix binary for the right reason (277
resurrections / retry-liars / DBSIZE 124→400) and green after. The assertion
shape throughout is retry-and-compare: a key that answers nil and then
returns a value with no write in between cannot be explained by legitimate
allkeys-lru eviction, which never brings a key back. Plus five unit tests on
the plane's own semantics.

  • full suite 5110/5110 on monoio (the shipped runtime) and 5110/5110 on
    tokio
    (CI parity), both exit 0
  • cargo clippy --all-targets clean on default features and on
    runtime-tokio,jemalloc; cargo fmt --check clean
  • the two #[ignore]d offload crash suites pass, including
    overwritten_cold_key_returns_new_value_after_crash, which exercises the
    set() change across a kill
  • tests/dbsize_offload_logical.rs — the suite that filed the issue — passes
  • three independent Python repro harnesses all report zero on the fixed binary

Two things deliberately not done

The bench is inconclusive, not clean. The change adds one is_empty()
load to set() and to the inline GET path. A 9-rep interleaved A/B on the
moon-dev VM (alternating leg order, Redis re-measured every rep as a
control) gave mixed-sign deltas — get p1 −0.29%, get p16 +2.35%,
set p1 +5.31%, set p16 −1.52% — every one inside its own per-cell noise
floor of 7–29%. That resolution cannot see an effect this small, so it is not
evidence of "no regression". It is not recorded in BENCHMARK.md: §2 is
the GCloud section and OrbStack numbers are development-only per CLAUDE.md.
A GCE-grade number can be taken on moon-bench-arm / moon-bench-x86 if
reviewers want one.

SCAN's ordered cursor still skips in-flight keys. Merging an unordered
plane into an ascending hash48 walk that mutates under the cursor is real
work and was not attempted. This stays within SCAN's contract (only keys
present for the whole iteration are guaranteed); KEYS / RANDOMKEY, whose
contracts are point-in-time, do include them. Documented at the call site
rather than left silent.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed transient missing-key errors while values are being moved to disk.
    • Ensured reads, existence checks, deletions, overwrites, and database-size counts remain accurate during disk offload.
    • Prevented deleted or replaced values from reappearing after background offload completes.
    • Improved cold-read performance by serving pending offloads directly from memory.
  • Monitoring
    • Added INFO visibility for superseded disk-offload completions.
  • Known Limitation
    • Key scans may not include values currently in the in-flight offload state.

… note (#459)

A key whose disk-offload spill was queued but not yet completed existed in no
plane the database consults. `evict_one_async_spill` freed the hot entry the
moment the `SpillRequest` was sent, and `cold_index` only learned about the key
when the completion landed; `spill_inflight` held nothing but a request id and
was read solely by the completion path.

The eviction code documented the window and accepted it — "accept a brief
read-miss ... AOF incr log is the durability backstop". The AOF does backstop
durability. It does not backstop visibility, and nothing considered a write
arriving inside the window.

Measured on origin/main @4c9bd2c5, 400 x 4KiB keys against a 512KiB cap
(the config of the failing test in #459):

  * DBSIZE answered 124 for 400 acked keys, then climbed to 400 on its own
    with no further writes — this is the `live 373 / recovered 400` that
    filed the issue;
  * GET and EXISTS denied live keys: `GET k0` -> nil, `EXISTS k0` -> 0, then
    250ms later `GET k0` -> 4096 bytes, no intervening write;
  * 277 of 400 DELs answered :0 and were then reversed. The completion path
    inserted into `cold_index` unconditionally, so the resurrections reached
    the manifest and survived restart. A client that deleted data got it back.

`spill_inflight` now carries the payload and is treated as the third plane it
always was. The payload is the same refcounted `Bytes` the queued request
already pins, so this is a refcount rather than a copy and adds no peak memory.

  * reads promote from it with no disk read, on all three dispatch paths —
    `promote_cold_if_present` (collections, Lua, MULTI), the monoio async GET
    pre-warm, and the inline GET fast path, which was framing `$-1` inline for
    keys EXISTS reported as present;
  * EXISTS / DEL count it; DBSIZE and `logical_len` count it; KEYS and
    RANDOMKEY enumerate it;
  * DEL, an overwriting SET, and a promoting read each retire the record,
    which withdraws the completion's authorization to publish. That is what
    makes a delete inside the window final, and it also stops a stale payload
    being published as a cold shadow behind a newer hot value;
  * unpublished completions are counted as `spill_completion_superseded`.

Servers that are not spilling pay one `is_empty()` load on the affected paths.

Known remaining gap, documented at the call site: SCAN's ordered cursor does
not merge the unordered in-flight plane, so it may skip a key for the
milliseconds its spill is queued. That is within SCAN's contract (only keys
present for the whole iteration are guaranteed); KEYS, whose contract is
point-in-time, does include them.

Tests: `tests/spill_inflight_visibility.rs` — one wire-level test per fault,
each red on the pre-fix binary for the right reason (277 resurrections,
retry-liars, DBSIZE 124->400) and green after. Retry-and-compare is the
assertion shape throughout: a key that answers nil and then returns a value
with no write in between cannot be explained by legitimate allkeys-lru
eviction, which never brings a key back. Plus five unit tests on the plane's
own semantics in `src/storage/db/mod.rs`.

Verified: full suite 5110/5110 (monoio, the shipped runtime), clippy clean,
fmt clean; the three independent Python repro harnesses that found the faults
all report zero on the fixed binary; `tests/dbsize_offload_logical.rs` — the
suite that filed #459 — passes.

Fixes #459
author: Tin Dang
@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@TinDang97, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 53 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fe6e477d-613e-4c05-a7f5-dfc01cec2a09

📥 Commits

Reviewing files that changed from the base of the PR and between 3c19dd9 and 36c2977.

📒 Files selected for processing (1)
  • tests/spill_inflight_visibility.rs
📝 Walkthrough

Walkthrough

The disk-offload path now retains pending spill payloads in memory. Reads and keyspace operations include these entries, while deletion, overwrite, and promotion invalidate stale spill completions before cold-index publication.

Changes

In-flight spill visibility

Layer / File(s) Summary
Pending spill capture
src/storage/db/mod.rs, src/storage/eviction.rs
Pending spills retain serialized values, value types, request IDs, and TTL metadata after hot-tier removal.
Visibility and keyspace operations
src/storage/db/..., src/server/conn/...
Reads can promote or return pending values. Existence checks, deletion, logical sizing, and point-in-time key listings account for live in-flight entries. Cursor-based cold scans continue to omit them.
Completion authorization and metrics
src/shard/persistence_tick.rs, src/storage/tiered/spill_thread.rs, src/command/connection.rs, CHANGELOG.md
Cold-index publication requires a current pending record. Superseded completions are skipped, counted, exposed in INFO, and documented.
Disk-offload visibility validation
tests/spill_inflight_visibility.rs, src/storage/db/mod.rs
Tests cover pending-value readability, expiration, transient read misses, delete resurrection, overwrite invalidation, promotion cancellation, and DBSIZE changes during spill processing.

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

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant GETHandler
  participant Database
  participant SpillThread
  participant ColdIndex
  Client->>GETHandler: GET key
  GETHandler->>Database: promote_inflight_if_present
  Database-->>GETHandler: restore value to hot tier
  GETHandler-->>Client: return value
  SpillThread->>Database: validate pending spill record
  Database-->>SpillThread: authorize or reject completion
  SpillThread->>ColdIndex: publish authorized spill
Loading

Possibly related PRs

  • pilotspace/moon#362: Both changes update logical key counting for disk-offload state.
  • pilotspace/moon#403: Both changes modify the storage database modules used for in-flight spill visibility.
  • pilotspace/moon#259: Both changes modify cold-tier spill handling and expiration behavior.

Suggested reviewers: pilotspacex-byte

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary fix: treating in-flight spills as a visible storage plane.
Description check ✅ Passed The description provides a detailed summary, verification results, performance discussion, and reviewer notes, despite using different section headings.
Linked Issues check ✅ Passed The PR fixes the linked issue's spill-path resurrection and visibility defects and adds focused regression coverage for issue #459.
Out of Scope Changes check ✅ Passed The changelog, metric, tests, and implementation changes support the linked spill-visibility and lifecycle objectives without unrelated scope.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/459-inflight-spill-visibility

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/storage/eviction.rs (1)

951-959: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Optional: build the key Bytes once and clone the handle.

Line 916 already allocates Bytes::copy_from_slice(key.as_bytes()) for SpillRequest.key. Line 952 allocates a second copy of the same bytes. A Bytes clone is a refcount bump, so one allocation serves both.

♻️ Proposed refactor
         let file_id = *next_file_id;
         *next_file_id += 1;
 
+        // One allocation of the key; the mark below clones the handle.
+        let key_bytes = Bytes::copy_from_slice(key.as_bytes());
+
         // Second handle on the SAME payload the request carries (refcount,
         // not a copy) so the in-flight plane below can answer reads from RAM
         // while the request is queued.
         let pending_bytes = value_bytes.clone();
 
         let req = SpillRequest {
-            key: Bytes::copy_from_slice(key.as_bytes()),
+            key: key_bytes.clone(),
             db_index,
         db.spill_inflight_mark(
-            Bytes::copy_from_slice(key.as_bytes()),
+            key_bytes,
             crate::storage::db::PendingSpill {
🤖 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/eviction.rs` around lines 951 - 959, Reuse the existing Bytes
allocation from SpillRequest.key in the spill_inflight_mark call instead of
creating a second Bytes::copy_from_slice from key.as_bytes(). Clone the stored
Bytes handle when passing it to spill_inflight_mark, while preserving the
existing PendingSpill fields.
src/storage/db/kv_ops.rs (1)

513-523: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Rename remove_cold_only, or the in-flight retirement stays hidden behind the name.

remove_cold_only now retires the in-flight record in addition to the cold-index entry. The doc comment states this, but the name says the opposite. The in-flight retirement is load-bearing: it is what withdraws the completion's authorization to publish, so a reader auditing "what makes DEL final" must not skip this function because its name reads as cold-index-only.

A name such as retire_non_hot_planes or remove_cold_and_inflight makes the second effect visible at every call site (remove and remove_counting_cold).

♻️ Proposed rename
-    /// Drops the cold copy AND any in-flight spill record.
+    /// Drops the cold copy AND any in-flight spill record.
     ///
     /// Retiring the in-flight record is the load-bearing half (`#459`): it is
     /// the completion's authorization to insert into `cold_index`, so
     /// without this a DEL issued during the spill window was undone when the
     /// spill landed — and committed to the manifest, so it survived restart.
     #[inline]
-    fn remove_cold_only(&mut self, key: &[u8]) -> bool {
+    fn remove_cold_and_inflight(&mut self, key: &[u8]) -> bool {
         self.spill_inflight_forget(key);
         self.cold_index.as_mut().is_some_and(|ci| ci.remove(key))
     }

Update the two call sites in remove (line 484) and remove_counting_cold (line 505).

🤖 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/db/kv_ops.rs` around lines 513 - 523, Rename remove_cold_only to
a name that explicitly reflects both cold-index removal and in-flight spill
retirement, such as remove_cold_and_inflight, and update both call sites in
remove and remove_counting_cold. Keep the existing behavior unchanged.
🤖 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 `@CHANGELOG.md`:
- Around line 9-41: Move the new spill-inflight changelog entry from the first
### Fixed section to the top of the existing ### Fixed section under
[Unreleased]. Leave only one ### Fixed heading, preserving the entry as the
first fixed item and the conventional section order with ### Added and ###
Security.

In `@src/storage/db/mod.rs`:
- Around line 213-230: Update spill_inflight_mark, spill_inflight_clear, and
spill_inflight_forget to charge the retained pending payload when it is marked
and credit it on every retirement path, including completion, DEL, overwriting
SET, and promoting reads. Ensure key and value memory use the existing
accounting helpers so used_memory remains truthful while the spill is in flight;
verify all call sites and avoid leaving any retirement path unbalanced.

In `@src/storage/tiered/spill_thread.rs`:
- Around line 183-191: Wire spill_completion_superseded_total() into the
existing INFO/metrics section that reports other spill fields, using
spill_completion_superseded as the output key. If the chosen INFO key differs,
update the corresponding CHANGELOG entry to match the final field name.

In `@tests/spill_inflight_visibility.rs`:
- Around line 286-290: Replace the fixed-sleep drain helper with a poll that
repeatedly reads DBSIZE through the provided mutable client and waits for the
value to stabilize across consecutive reads, bounded by the existing
readiness_deadline CI-aware timeout. Update drain and both call sites to accept
and pass &mut c, while preserving the strict exact-count assertions and
load_keys behavior.

---

Nitpick comments:
In `@src/storage/db/kv_ops.rs`:
- Around line 513-523: Rename remove_cold_only to a name that explicitly
reflects both cold-index removal and in-flight spill retirement, such as
remove_cold_and_inflight, and update both call sites in remove and
remove_counting_cold. Keep the existing behavior unchanged.

In `@src/storage/eviction.rs`:
- Around line 951-959: Reuse the existing Bytes allocation from SpillRequest.key
in the spill_inflight_mark call instead of creating a second
Bytes::copy_from_slice from key.as_bytes(). Clone the stored Bytes handle when
passing it to spill_inflight_mark, while preserving the existing PendingSpill
fields.
🪄 Autofix

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 Plus

Run ID: 7d4819e0-833e-4140-9e70-19af361b2b3c

📥 Commits

Reviewing files that changed from the base of the PR and between 4c9bd2c and 417a465.

📒 Files selected for processing (10)
  • CHANGELOG.md
  • src/server/conn/blocking.rs
  • src/server/conn/handler_monoio/mod.rs
  • src/shard/persistence_tick.rs
  • src/storage/db/accessors.rs
  • src/storage/db/kv_ops.rs
  • src/storage/db/mod.rs
  • src/storage/eviction.rs
  • src/storage/tiered/spill_thread.rs
  • tests/spill_inflight_visibility.rs

Comment thread CHANGELOG.md Outdated
Comment thread src/storage/db/mod.rs
Comment on lines +213 to +230
/// Costs nothing to hold: `value_bytes` is the very same refcounted
/// `Bytes` the queued `SpillRequest` already pins for the whole window,
/// so this is one refcount, not a copy.
///
/// Empty for every server not actively spilling, which is what makes the
/// `is_empty()` fast bail on the hot paths below honest rather than a
/// hopeful guess. Size is bounded by the spill channel's capacity:
/// `evict_one_async_spill` marks only after a successful `try_send`, and
/// a full channel makes it bail.
///
/// Retention note: a record is retired by its completion, by DEL, by an
/// overwriting SET, or by a promoting read. A LOST completion (see
/// `spill_completion_dropped_total` — the rare shutdown-with-full-channel
/// edge) therefore now strands a payload rather than the bare u64 it used
/// to, so the value stays charged to RAM until the key is next written or
/// deleted. Reads stay correct throughout; the cost is that the eviction
/// did not actually reclaim that key's memory.
spill_inflight: std::collections::HashMap<bytes::Bytes, PendingSpill>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

The pending payload is resident RAM that used_memory no longer counts.

evict_one_async_spill calls db.remove(key) before spill_inflight_mark. remove_hot credits back entry_overhead(key, &entry), so the ledger drops by the full entry cost. spill_inflight_mark then retains value_bytes (and the key Bytes) without charging anything back.

For the length of the window the eviction loop believes it reclaimed memory it did not reclaim. evict_to_budget re-reads estimated_memory() after each victim, so within one tick it can select additional victims to close a gap that the in-flight plane is still holding. The retained bytes are bounded by the spill channel capacity, so this is bounded over-eviction rather than unbounded growth — but the doc block here states the cost only for the LOST-completion case, which understates it.

Two options:

  • Charge the pending payload in spill_inflight_mark and credit it in every retirement path (spill_inflight_clear, spill_inflight_forget). This keeps used_memory truthful and stops the over-eviction, at the cost of the eviction loop making less apparent progress per victim.
  • Keep the current accounting and state the trade-off here, so a future reader does not treat used_memory as exact during a spill burst.

Run the following script to confirm no other call site charges the pending payload:

#!/bin/bash
# Description: Check whether any path charges/credits memory for the in-flight spill payload.
set -euo pipefail

# All mark/clear/forget call sites, with context, to see adjacent memory accounting.
rg -n -C 6 'spill_inflight_(mark|clear|forget)\s*\(' --type=rust

# Memory accounting helpers, to confirm none of them is keyed to spill_inflight.
rg -n -C 3 '\b(charge_memory|credit_memory|adjust_memory|used_memory)\b' --type=rust -g '!**/tests/**'
🤖 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/db/mod.rs` around lines 213 - 230, Update spill_inflight_mark,
spill_inflight_clear, and spill_inflight_forget to charge the retained pending
payload when it is marked and credit it on every retirement path, including
completion, DEL, overwriting SET, and promoting reads. Ensure key and value
memory use the existing accounting helpers so used_memory remains truthful while
the spill is in flight; verify all call sites and avoid leaving any retirement
path unbalanced.

Comment on lines +183 to +191
/// `maxmemory` is too small for the working set. Exposed as
/// `spill_completion_superseded` in INFO.
static SPILL_COMPLETION_SUPERSEDED: AtomicU64 = AtomicU64::new(0);

/// Cumulative superseded (unpublished) spill completions. For INFO / metrics.
#[inline]
pub fn spill_completion_superseded_total() -> u64 {
SPILL_COMPLETION_SUPERSEDED.load(Ordering::Relaxed)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Confirm the superseded-spill counter is read by an INFO/metrics emitter.
set -euo pipefail

# Callers of the new getter.
rg -n -C 5 'spill_completion_superseded_total' --type=rust

# The INFO field name itself.
rg -n -C 3 'spill_completion_superseded' --type=rust

# Compare with the established sibling counter to see the expected wiring shape.
rg -n -C 5 'spill_failed_reinserted_total' --type=rust

Repository: pilotspace/moon

Length of output: 153


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repo files around storage/tiered and info/metrics =="
git ls-files | rg '(^src/.+\.rs$|CHANGELOG|README|.*info|.*metric|.*stat|.*stats|.*tiered|spill_thread)' | sed -n '1,200p'

echo
echo "== occurrences =="
rg -n 'spill_completion_superseded_total|spill_completion_superseded|spill_failed_reinserted_total|spill_failed_reinserted' --type=rust || true

Repository: pilotspace/moon

Length of output: 5597


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== git status =="
git status --short

echo
echo "== changed files =="
git diff --name-only HEAD~1..HEAD 2>/dev/null || git diff --name-only --cached || true

echo
echo "== search all tracked text for superseded counter =="
rg -n 'spill_completion_superseded' . || true

Repository: pilotspace/moon

Length of output: 1015


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== spill_thread relevant lines =="
sed -n '160,215p' src/storage/tiered/spill_thread.rs

echo
echo "== persistence_tick call site =="
sed -n '700,755p' src/shard/persistence_tick.rs

echo
echo "== sibling counter definitions/usages =="
rg -n 'spill_failed_reinserted_total|spill_failed_reinserted|record_spill_failed_reinserted' --type=rust || true

echo
echo "== INFO field candidates around storage/tiered/spill/stat/reclamation =="
rg -n 'spill_|reinsert|tiered|superseded|failed_reinserted|Storage\s*:\s*{|pub struct.*Info|Section\.Info|info_reclamation|server_admin|info' src/command src/admin src/server -g '*.rs' | sed -n '1,240p'

Repository: pilotspace/moon

Length of output: 5044


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== info command file outline =="
ast-grep outline src/command/info_reclamation.rs --view compact || true

echo
echo "== command info_reclamation relevant sizes and contents =="
wc -l src/command/info_reclamation.rs
sed -n '1,260p' src/command/info_reclamation.rs

echo
echo "== all tracked references to spill_thread getters =="
rg -n -C 3 -e 'spill_completion_superseded|spill_failed_reinserted|record_spill_completion_superseded|record_spill_failed_reinserted' . || true

Repository: pilotspace/moon

Length of output: 19649


Wire spill_completion_superseded into INFO fields.

record_spill_completion_superseded() is called from src/shard/persistence_tick.rs:731, but src/storage/tiered/spill_thread.rs:189's getter is not read by any INFO/metrics caller. Add spill_completion_superseded_total() to an INFO section alongside existing spill fields, and update the field name in CHANGELOG.md:35 if the final output key differs.

🤖 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/spill_thread.rs` around lines 183 - 191, Wire
spill_completion_superseded_total() into the existing INFO/metrics section that
reports other spill fields, using spill_completion_superseded as the output key.
If the chosen INFO key differs, update the corresponding CHANGELOG entry to
match the final field name.

Comment thread tests/spill_inflight_visibility.rs Outdated
#459)

Addresses the PR #465 review.

- INFO: `spill_completion_superseded` is now actually emitted next to its
  sibling `spill_failed_reinserted`. The counter and getter existed but were
  never wired into the INFO persistence section, so the CHANGELOG claimed a
  field that did not exist. Verified on a live server.

- CHANGELOG: `[Unreleased]` had gained a second `### Fixed` heading. Keep a
  Changelog allows one section per change type per release, and a parser that
  indexes by heading silently drops one block. Folded into the existing
  section.

- tests: replace the fixed 5s drain with a poll for a STABLE DBSIZE, and fail
  a refused SET with an explicit "this run never reached the in-flight window"
  message. Both failure modes were environmental (slow disk backs the spill
  queue up, `try_send` fails, eviction surfaces OOM) and would have been read
  as the defect returning. Re-confirmed the amended tests still fail on the
  pre-fix binary with identical diagnostics — 277 resurrections, retry-liars,
  DBSIZE 124->400 — so they still discriminate. Also ~3x faster (12.9s ->
  3.9s) since the poll returns as soon as the queue is quiet.

- docs: record the memory-accounting gap the review surfaced. The pending
  payload is resident RAM `used_memory` does not count: `db.remove()` credits
  back the whole entry cost while the queued request still holds a full
  `Bytes::copy_from_slice` of the value. Verified PRE-EXISTING on main, which
  makes the same copy and the same credit-back; this PR adds only the key
  `Bytes` plus a small struct per in-flight key, and moves the end of the
  retention window from "channel drained" to "completion applied". Charging
  the bytes needs `evict_to_budget` taught to stop while pending bytes are
  large, or it evicts in a runaway loop chasing memory that cannot drop yet —
  a design change, tracked in #466 rather than smuggled into a visibility fix.

Verified: fmt clean; clippy clean on default features AND on
runtime-tokio,jemalloc; the three wire-level tests green on monoio and tokio,
and still red on the pre-fix binary.

author: Tin Dang
@TinDang97

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

Second commit (3c19dd9) addresses all four findings from the first pass:

  • spill_completion_superseded is now actually wired into INFO (the counter existed but was never emitted — the CHANGELOG claimed a field that did not exist)
  • the duplicate ### Fixed heading under [Unreleased] is folded into one section
  • the fixed 5s drain is replaced by a poll for a stable DBSIZE, and a refused SET now fails with an explicit "this run never reached the in-flight window" message; the amended tests were re-confirmed still red on the pre-fix binary with identical diagnostics
  • the memory-accounting finding is documented rather than silently absorbed: verified pre-existing on origin/main (which makes the same Bytes::copy_from_slice and the same credit-back), and tracked in Spilled-but-not-yet-written values are resident RAM that used_memory does not count #466 with why charging the bytes needs evict_to_budget taught to stop, or it evicts in a runaway loop.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== PR metadata and commits =='
gh pr view 465 --repo pilotspace/moon --json number,title,headRefOid,baseRefName,commits,files \
  --jq '{number,title,headRefOid,baseRefName,commits:[.commits[]|{oid,messageHeadline}],files:[.files[]|{path,additions,deletions}]}'

echo '== Follow-up commit diff =='
git show --format=fuller --find-renames --find-copies 3c19dd9b -- \
  CHANGELOG.md \
  src/server/conn \
  src/storage/tiered/spill_thread.rs \
  tests/spill_inflight_visibility.rs

echo '== Issue `#466` =='
gh issue view 466 --repo pilotspace/moon --json number,title,state,body,url,assignees \
  --jq '{number,title,state,body,url,assignees:[.assignees[].login]}'

echo '== Full PR changed-file patch summary =='
git diff --stat origin/main...HEAD
git diff --check origin/main...HEAD

Length of output: 16633


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== Module outlines =='
for file in \
  src/command/connection.rs \
  src/shard/persistence_tick.rs \
  src/storage/db/mod.rs \
  src/storage/db/kv_ops.rs \
  src/storage/db/accessors.rs \
  src/storage/eviction.rs \
  src/storage/tiered/spill_thread.rs \
  src/server/conn/blocking.rs \
  src/server/conn/handler_monoio/mod.rs \
  tests/spill_inflight_visibility.rs
do
  echo "--- $file ---"
  ast-grep outline "$file" --items all || true
done

echo '== Spill lifecycle symbols and INFO emission =='
rg -n -C 5 \
  -e 'spill_inflight_(mark|forget|alive|entry|value|keys|is_empty)' \
  -e 'PendingSpill' \
  -e 'spill_completion_superseded|spill_failed_reinserted' \
  -e 'SpillCompletion|SpillRequest' \
  -e 'promote_inflight_if_present' \
  src tests

echo '== Changed source patch, excluding already-inspected changelog/test follow-up =='
git diff --find-renames origin/main...HEAD -- \
  src/command/connection.rs \
  src/server/conn/blocking.rs \
  src/server/conn/handler_monoio/mod.rs \
  src/shard/persistence_tick.rs \
  src/storage/db/accessors.rs \
  src/storage/db/kv_ops.rs \
  src/storage/db/mod.rs \
  src/storage/eviction.rs \
  src/storage/tiered/spill_thread.rs

Length of output: 174732


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== Database counting and key enumeration =='
sed -n '598,750p' src/storage/db/kv_ops.rs

echo '== In-flight methods =='
sed -n '300,420p' src/storage/db/mod.rs

echo '== Completion handling =='
sed -n '613,750p' src/shard/persistence_tick.rs

echo '== DBSIZE dispatch call sites =='
rg -n -C 4 -P '\blogical_len\s*\(|\bDBSIZE\b' src --glob '*.rs'

echo '== In-flight payload reads and expiry call sites =='
rg -n -C 4 \
  -e 'spill_inflight_(entry|value|alive)\s*\(' \
  -e '\bcheck_expired\s*\(' \
  -e '\bdrop_if_expired\s*\(' \
  src --glob '*.rs'

echo '== Existing logical-length expiry tests =='
rg -n -C 8 -P 'fn\s+(test_len_and_expires_count|.*logical_len.*|.*expiry.*)' \
  src/storage/db/mod.rs src/storage/db/kv_ops.rs

Length of output: 77023


@TinDang97 I reviewed commit 3c19dd9b and the complete spill lifecycle.

The four follow-up changes are correct:

  • INFO now emits spill_completion_superseded with the matching metric value.
  • [Unreleased] now contains one ### Fixed section.
  • The integration test uses a bounded stable-DBSIZE poll. It also reports spill-queue backpressure as an environment failure.
  • Issue #466 documents the pre-existing memory-accounting gap and the required eviction-loop change.

I also traced request retirement and completion publication. DEL, overwrite, and read promotion remove the in-flight authorization before a completion can publish a cold-index entry.

I found no new actionable findings.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@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

🤖 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 `@tests/spill_inflight_visibility.rs`:
- Around line 305-326: Update the test helper drain around drain to use an
explicit spill-completion idle signal, such as a completion watermark or
dedicated queue-idle observation, instead of treating stable DBSIZE samples as
completion. Ensure the deletion-finality checks run only after all pending
SpillCompletions have been applied.
🪄 Autofix

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 Plus

Run ID: 7b665634-e44c-445c-b49e-7104fb437d83

📥 Commits

Reviewing files that changed from the base of the PR and between 417a465 and 3c19dd9.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • src/command/connection.rs
  • src/storage/db/mod.rs
  • tests/spill_inflight_visibility.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • CHANGELOG.md
  • src/storage/db/mod.rs

Comment thread tests/spill_inflight_visibility.rs
CodeRabbit re-review of #465 caught a soundness bug in the test's own drain
condition, and it fails in the direction that matters.

`DBSIZE` alone cannot tell "the spill pipeline is quiet" from "the pipeline
has not started applying completions yet". In the deletion-finality test that
distinction is the whole point: after the DEL sweep every key is either
deleted or in-flight-invisible, so DBSIZE reads 0 IMMEDIATELY while
completions are still queued. A stability gate can therefore return before any
completion is applied, and a completion that republishes a deleted key lands
after the assertions have already run — a FALSE PASS hiding the exact P0 this
file exists to catch.

It caught the bug anyway only because completions land inside the poll's ~1s
floor. That is luck, not a guarantee, and it would have decayed silently on a
slower or busier machine.

- `drain` now gates on server-reported spill PROGRESS as well as DBSIZE:
  `spill_batches_flushed` + `spill_completion_superseded` from INFO must ALSO
  hold still, for six consecutive samples. Either counter moves while anything
  is left in the pipeline.

- the deletion test gains a vacuity guard. "Nothing came back" only means
  something if the completions actually ARRIVED and were refused, so the test
  now asserts `spill_completion_superseded > 0` — the count of completions the
  event loop applied and declined to publish because DEL had retired their
  in-flight record, which is the mechanism under test. Without it, a run where
  nothing spilled, or where the queue drained before the DELs, passes while
  proving nothing. `info_counter` returns None for an absent field rather than
  defaulting to 0, so a missing counter cannot satisfy the guard.

Verified: still RED on the pre-fix binary with identical diagnostics — 277
resurrections, retry-liars, DBSIZE 124->400 — and the deletion test still
fails on the resurrection assertion, not on the new guard, so the ordering
reports the defect rather than the guard. Green on monoio and tokio. fmt and
clippy clean.

author: Tin Dang
@TinDang97
TinDang97 merged commit eb2575b into main Aug 10, 2026
9 checks passed
TinDang97 added a commit that referenced this pull request Aug 10, 2026
Merging main (which now carries #465) back into this branch re-created the
duplicate `### Fixed` under `[Unreleased]`: this branch predates the
restructure that #465 applied, so it re-added its own heading above the
existing one.

Two `### Fixed` sections in one release is the exact defect flagged on #465 —
Keep a Changelog allows one section per change type, and a parser that indexes
by heading reads one block and silently drops the other. Merging as-is would
have regressed main straight back into it, and no CI check looks at changelog
structure.

Folded into the single section, preserving main's `Added -> Security -> Fixed`
order and both entries.

author: Tin Dang
TinDang97 added a commit that referenced this pull request Aug 10, 2026
…alling context (#463)

* fix(protocol): RESP3 reply types match Redis, and stop depending on calling context

A live differential sweep against redis-server 8.6.1 found Moon's RESP3
conversion table wrong in both directions and, structurally, unable to be
right: it keyed only on the command NAME, while WITHSCORES, WITHVALUES and a
<count> argument are what actually decide the reply shape.

Under-conversion, client-breaking:
  - ZRANGE/ZREVRANGE/ZRANGEBYSCORE/ZDIFF/ZUNION/ZINTER/ZRANDMEMBER ... WITHSCORES
    arrived as a flat array of bulk strings instead of pair-wrapped
    [member, Double]. An unmodified redis-py raises
    "ValueError: not enough values to unpack" on this, so every RESP3
    application reading sorted-set scores was broken outright.
  - HRANDFIELD WITHVALUES / ZRANDMEMBER WITHSCORES answered a Map where Redis
    answers an array of pairs.
  - ZMSCORE, GEOPOS, SPOP <count>, ZPOPMIN/ZPOPMAX and XINFO STREAM were not
    converted at all.

Over-conversion, equally wrong:
  - SISMEMBER, HEXISTS, EXPIRE, PEXPIRE, PERSIST, SETNX and MSETNX returned
    Boolean where Redis returns Integer.
  - INCRBYFLOAT/HINCRBYFLOAT returned a lossy Double (,10.6) where Redis
    returns the exact Bulk "10.59999999999999964".

Emptiness must not change the reply TYPE either: HGETALL and CONFIG GET on a
miss answered *0 where Redis answers %0, so a client dispatching on the type
byte broke on exactly the path it hits most.

The conversion is now decided by (command, args) at one policy choke point
(Resp3Shape in src/protocol/resp3.rs) instead of by 11 call sites across three
handlers. The cross-shard reply loop no longer has the command's args by the
time its batch returns, so the shape is classified at ENQUEUE time and a 1-byte
Copy tag travels in RemoteMeta -- no per-command allocation on the shard hot
path. execute_transaction_sharded now takes the connection's protocol version
and converts each inner reply with its own command, so a command answers the
same shape standalone, inside MULTI/EXEC and inside a pipeline; previously
SMEMBERS was a Set outside a transaction and a flat Array inside one. CONFIG
GET (Map) and CLIENT INFO (Verbatim) are fixed at their intercepts, which
short-circuit the dispatch exit entirely -- the reason CONFIG could never be
fixed before.

RESP2 is byte-identical, pinned by a test written before the fix.

Verification (all against a LIVE redis-server 8.6.1, never against Moon's own
expectations):
  - scripts/test-client-compat.sh --strict: PASS 98 -> 157, WAIVED 54 -> 25,
    FAIL 0, exit 0, with all 13 now-stale waivers deleted.
  - tests/resp3_type_fidelity.rs: 13 new tests asserting the wire type byte
    directly, including shape equality across standalone/MULTI/pipeline and
    across a 4-shard server.
  - Raw-socket byte-diff of the miss path: BYTE-IDENTICAL to redis 8.6.1.
  - redis-py acceptance: zrange withscores went from raising to [['a', 1.0]].
  - clippy -D warnings clean on default and runtime-tokio,jemalloc; fmt clean.

The empty-reply defect was found by an adversarial re-read AFTER every bar was
green: all 152 harness cases populated their key first, so the entire miss path
was undiffed. Five miss-path cases were added and verified discriminating by
running them against the pre-fix binary, where all five fail.

Refs: .add/tasks/resp3-type-fidelity, milestone v0-9-client-compat
author: Tin Dang

* chore(add): record resp3-type-fidelity verify evidence + pre-existing-failure A/B

Advances the task to phase=verify and fills the GATE RECORD with the measured
evidence. Documents the same-load A/B that cleared this change of the two
full-suite residual failures: a pre-change server built from main fails the
dbsize_offload_logical guard MORE often (7/8) than this branch (5/8), with an
identical assertion, line and key counts on both legs.

The gate OUTCOME is deliberately left open — §6's 'a person reviewed and
approved the change' is not the AI's box to tick, and this change carries a
disclosure (a unit test was deleted during build) that warrants a human read.

author: Tin Dang

* chore(add): gate resp3-type-fidelity PASS — tokio CI parity green, follow-ups filed

Records the human-approved gate (Tin Dang, 2026-08-10) and the two verification
runs added after the first evidence pass:

- tokio CI-parity suite (the feature set every CI test job builds): 195 binaries,
  4352 passed, 0 failed, exit 0. It also passed both tests that failed the monoio
  run, independently corroborating the contention diagnosis.
- Client-compat harness against the DEPLOYED :6381 flag set on a throwaway server
  with an empty data dir: PASS 157 / FAIL 0. Live :6381 was deliberately not used
  as the target -- differ.py FLUSHALLs before every one of its 182 entries and that
  instance holds ~764k actively-growing keys.

Residual findings filed rather than folded in: #459 #460 #461 #462.

author: Tin Dang

* test(client-compat): re-point the divergence fixture — SISMEMBER is fixed now

The harness self-test test_a_diverging_entry_exits_one_and_names_the_divergence
asserts that a diverging entry exits 1 and names TYPE. It borrowed a REAL
divergence as its fixture: SISMEMBER under RESP3, where Moon over-converted
Integer to Boolean. This PR fixes that over-conversion, so the fixture stopped
diverging and the test began failing with '0 != 1' — it failed as a reward for
fixing something, which is backwards.

Re-pointed at COMMAND COUNT (Redis :274 vs Moon *0), an open TYPE divergence
owned by client-identity-introspection, so the reply-type line of work cannot
silently retire it again.

This is the THIRD fixture for this test (GET-inside-MULTI -> SISMEMBER ->
COMMAND COUNT); the comment now records the rotation so the next person sees
the pattern instead of rediscovering it. No permanent-by-construction TYPE
divergence exists to use instead: Moon's proprietary commands return an Error
on both servers, and redis 8.6.1 implements 'hotkeys' as well. The durable fix
is a test-only injection hook that fabricates a divergence rather than
borrowing one — tracked in #461.

Caught by CI, not locally: the client-compat job runs this Python suite in
addition to the harness itself, and I had only run the harness.

author: Tin Dang

* docs(bench): BENCHMARK.md 2.11 — RESP3 type-fidelity no-regression gate on GCE

Records the performance check for PR #463 on both arches. The change adds a
shape classification at enqueue and a conversion at the reply exit, both gated
on proto >= 3; redis-benchmark speaks RESP2, so this measures the cost of the
GATE, which is what could regress existing workloads.

Result: no regression detectable on either arch. x86 median delta +0.00% with
0/18 rows outside their noise floor; ARM +0.72% with 1/18 (a marginal GAIN, not
claimed -- Redis moved in the same direction on that row).

Method note worth keeping: the first attempt was a single full bench-compare
matrix, and it was DISCARDED. It showed apparent Moon regressions of -16.3%,
-15.5% and -11.4% on x86 -- but Redis, unchanged code benchmarked in both legs,
drifted up to -27.0% between the same legs. When the control moves further than
the subject, no per-row number means anything. Replaced with an interleaved
5-repetition A/B (main -> branch alternating) so drift hits both legs equally,
with Redis re-measured every repetition.

The record states the supportable claim (any effect is below a 3.6-6.0% noise
floor) rather than the unsupportable one (there is no regression).

author: Tin Dang

* docs(changelog): fold the duplicate Fixed heading after the main merge

Merging main (which now carries #465) back into this branch re-created the
duplicate `### Fixed` under `[Unreleased]`: this branch predates the
restructure that #465 applied, so it re-added its own heading above the
existing one.

Two `### Fixed` sections in one release is the exact defect flagged on #465 —
Keep a Changelog allows one section per change type, and a parser that indexes
by heading reads one block and silently drops the other. Merging as-is would
have regressed main straight back into it, and no CI check looks at changelog
structure.

Folded into the single section, preserving main's `Added -> Security -> Fixed`
order and both entries.

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.

dbsize_offload_logical: load-fragile, and restart can resurrect evicted keys (recovered > live)

1 participant