Skip to content

fix(storage): policy-aware eviction fail-close for disk-offload without a durability backstop#273

Merged
pilotspacex-byte merged 3 commits into
mainfrom
fix/disk-offload-no-durability-warn
Jul 10, 2026
Merged

fix(storage): policy-aware eviction fail-close for disk-offload without a durability backstop#273
pilotspacex-byte merged 3 commits into
mainfrom
fix/disk-offload-no-durability-warn

Conversation

@pilotspacex-byte

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

Copy link
Copy Markdown
Contributor

Summary

Fixes a default-config regression surfaced by the 2026-07-10 GCP disk-offload A/B: with --disk-offload enable (the default) + --appendonly no + no --save, an evicting maxmemory-policy (allkeys-lru/lfu/random, volatile-*) would OOM-reject writes at the cap instead of evicting — i.e. the classic "Redis as an LRU cache" recipe was silently broken by default.

Root cause: the inline write-path eviction gate (try_evict_if_needed_async_spill_*) has no ShardManifest and no AOF backstop under appendonly=no, so it took a policy-blind fail-close branch (added in #270 for crash-safety) that OOMs regardless of policy. #270's durable-spill replacement (evict_batch_durable_no_aof) only runs from the persistence tick, which needs persistence_dir (appendonly=yes || save), so appendonly=no fell through to the OOM.

Fix (2 commits)

  1. fix(config) — startup tracing::warn! when --disk-offload is enabled without a durability backstop (disk_offload_spill_inert() predicate), documenting that evicting policies drop and noeviction OOMs at the cap.
  2. fix(storage) — the fail-close is now policy-aware, matching Redis semantics:
    • total_memory <= budget → no-op
    • NoEviction → still OOMs (correct; noeviction always rejects)
    • any evicting policy → reclaims the budget by dropping victims (evict_one_with_spill(..., spill: None), the same plain-drop helper the disk-offload-disabled path already uses). volatile-* stays scoped to TTL-bearing keys and OOMs rather than drop a persistent key.

No behavior change to the appendonly=yes durable-spill path or the disk-offload-disabled path — the new branch only replaces the old blanket-OOM in the no-manifest/no-AOF case.

Verification

Unit (red/green, both runtimes):

  • async_spill_no_aof_backstop_no_manifest_allkeys_lru_evicts_pure_cache (RED pre-fix: OOM panic → GREEN)
  • async_spill_no_aof_backstop_no_manifest_noeviction_still_rejects
  • async_spill_no_aof_backstop_no_manifest_volatile_lru_scoped_to_ttl_keys
  • storage::eviction 32 passed; config 125 passed; clippy -D warnings + fmt clean on default and runtime-tokio,jemalloc.

GCP e2e (x86 c3, --disk-offload enable, 256 MiB cap, 500k unique 512B SETs, OOM-counting client — no abort-on-error):

scenario pre-fix (main) fix
appendonly=no, allkeys-lru ok=489282 oom=10718 (cache broken) ok=500000 oom=0 (evicts, bounded)
appendonly=no, noeviction rejects at cap oom=107388 rejects at cap ✓
appendonly=yes, allkeys-lru oom=0 oom=0 (unchanged) ✓

Binaries verified distinct (md5 + fix-string presence) before the A/B.

Follow-up (not this PR)

A two-threshold functional offload-without-AOF (spill under appendonly=no with a crash-safe backstop) is deferred to v0.7 if it becomes a real requirement; today the correct default-config behavior is evict-or-OOM per policy, which this PR delivers.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed memory-limit enforcement for tiered disk offload when no durability backstop (AOF and snapshot) is available, ensuring eligible victims are dropped until under the budget.
    • noeviction now reliably returns an OOM error at the limit, while other eviction policies reclaim memory by dropping keys as appropriate.
    • TTL-based eviction continues to preserve persistent data when no eligible temporary keys remain.
  • Documentation
    • Updated Tiered memory offload tuning guidance to explain when cold-spill is inert vs. active, and how to avoid the non-spill configuration.
    • Documented the new startup warning for disk offload without durability.

…ackstop

GCP benchmark (2026-07-10) found that `--disk-offload enable` (the
default) combined with `--appendonly no` and no `--save` silently
degrades: the durable-spill eviction path added by PR #270
(`evict_batch_durable_no_aof`) only runs from the tick-driven
memory-pressure cascade (`shard::persistence_tick::handle_memory_pressure`),
which requires a `ShardManifest` threaded through `persistence_dir` —
and `main.rs` only constructs `persistence_dir` when
`appendonly == "yes" || save.is_some()`. The inline per-connection
write-path eviction gate has no manifest access and, correctly, bails
rather than risk an unrecoverable crash-loss window. The net effect:
`--maxmemory` silently degrades from "spill cold data to disk" to a
hard reject-at-cap (writes OOM-rejected), and cold data is never
spilled.

This trade-off (correctness over availability) is intentional and
unchanged by this commit — the eviction/spill logic is untouched. What
was missing was visibility: an operator following the documented
`--disk-offload enable` default plus `--appendonly no` would hit OOM
rejections with no indication why disk-offload wasn't relieving
pressure.

Adds `ServerConfig::disk_offload_spill_inert` (testable predicate) and
`ServerConfig::warn_disk_offload_without_durability` (single
`tracing::warn!` at startup), mirroring the existing
`warn_deprecated_cold_tier_flags` pattern exactly. Wired into
`main.rs` right after that call site. Four unit tests cover the true
case and the three ways to make it false (`--appendonly yes`,
`--save` set, `--disk-offload disable`). Documents the requirement in
`docs/guides/tuning.md`'s "Tiered memory offload" section and adds a
CHANGELOG entry.

author: Tin Dang <tindang.ht97@gmail.com>
…licies without a spill backstop, OOM only for noeviction

The previous commit added a startup warning for `--disk-offload enable`
without a durability backstop (`--appendonly no` + no `--save`), but left
the underlying behavior as an unconditional OOM reject whenever the
`manifest is None` branch of
`try_evict_if_needed_async_spill_with_total_budget` was reached — the inline
per-connection write-path eviction gate under that config combination. That
meant `--maxmemory-policy allkeys-lru --appendonly no` (the documented "Pure
cache (no durability)" recipe, and disk-offload's own default of `enable`)
OOM'd at the cap instead of evicting: a classic LRU cache broken out of the
box by a config default.

Durable spill (`evict_batch_durable_no_aof`) is genuinely impossible from
this call site without a `ShardManifest` — that part is correct and
unchanged. What was wrong is treating "can't spill durably" as "can't evict
at all," for every policy. Redis semantics only require `noeviction` to
reject; every other policy (`allkeys-*`/`volatile-*`) is expected to make
room by dropping keys.

Fix (`src/storage/eviction.rs`, the `manifest is None` branch): now
policy-aware, mirroring the loop shape of the `manifest is Some` branch
right below it but calling the existing plain-drop helper
(`evict_one_with_spill(db, config, &policy, None)` — the exact function the
disk-offload-disabled write path already uses, no new victim-selection
logic) instead of `evict_batch_durable_no_aof`:
  1. `total_memory <= budget` → `Ok(())`, nothing to do.
  2. `policy == NoEviction` → `Err(oom_error())`, unchanged.
  3. Any evicting policy → loop dropping victims (no spill, no tiering)
     until the budget is satisfied or no eligible victim remains (then
     OOM, e.g. `volatile-*` with no TTL'd keys left).

Also reworded the startup warning added in the previous commit: it
previously implied `--maxmemory` unconditionally becomes a hard reject
cap, which is now only true for `noeviction`. It now says evicting
policies fall back to dropping keys with no tiering, and only
`noeviction` rejects with OOM.

Three new tests in `src/storage/eviction.rs::tests` (RED confirmed against
the pre-fix code before reapplying the fix):
- `async_spill_no_aof_backstop_no_manifest_allkeys_lru_evicts_pure_cache`
  — the pure-cache regression test itself: allkeys-lru over budget now
  evicts (Ok, memory reclaimed) instead of OOM.
- `async_spill_no_aof_backstop_no_manifest_noeviction_still_rejects` —
  confirms noeviction is unchanged (OOM, value retained).
- `async_spill_no_aof_backstop_no_manifest_volatile_lru_scoped_to_ttl_keys`
  — volatile-lru only drops keys carrying a TTL; a persistent key survives,
  and once no volatile candidate remains the cap correctly OOMs rather than
  dropping the persistent key.

The `--appendonly yes` async-spill path and the `manifest is Some` durable
batch-spill path are untouched; their existing tests
(`async_spill_no_aof_backstop_durably_spills_before_drop`,
`async_spill_no_aof_backstop_spill_failure_retains_hot_value`, and the
elastic-budget/per-shard-budget suite) still pass unmodified.

Updated `docs/guides/tuning.md`'s disk-offload durability-backstop note to
reflect that the "Pure cache" recipe works correctly, and expanded the
CHANGELOG [Unreleased] entry to cover the behavior fix.

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 10, 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: 555fc748-4c32-4869-a427-50dcbac0fa23

📥 Commits

Reviewing files that changed from the base of the PR and between 6683efe and c89bac4.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • docs/guides/tuning.md
  • src/config.rs
✅ Files skipped from review due to trivial changes (2)
  • docs/guides/tuning.md
  • CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/config.rs

📝 Walkthrough

Walkthrough

Disk-offload now detects configurations without AOF or RDB durability, warns at startup, documents inert cold-spill behavior, and falls back to policy-based in-memory eviction when enforcing memory limits without a spill manifest.

Changes

Disk-offload durability fallback

Layer / File(s) Summary
Durability detection and startup warning
src/config.rs, src/main.rs, docs/guides/tuning.md, CHANGELOG.md
Adds configuration predicates and startup warnings for disk-offload without AOF or RDB durability, with matching documentation and changelog entries.
In-memory eviction fallback
src/storage/eviction.rs
Evicting policies now drop eligible victims without spill files when no durability backstop exists, while noeviction and exhausted volatile-lru cases still return OOM. Regression tests cover these policies.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant main
  participant ServerConfig
  participant try_evict_if_needed_async_spill_with_total_budget
  participant evict_one_with_spill
  main->>ServerConfig: warn_disk_offload_without_durability()
  ServerConfig-->>main: Emit warning when spill is inert
  main->>try_evict_if_needed_async_spill_with_total_budget: Enforce total memory budget
  try_evict_if_needed_async_spill_with_total_budget->>evict_one_with_spill: Drop eligible victim with spill=None
  evict_one_with_spill-->>try_evict_if_needed_async_spill_with_total_budget: Reclaim memory
Loading

Possibly related PRs

  • pilotspace/moon#170: Related changes to the async-spill eviction path and budget-driven eviction behavior.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: policy-aware eviction for disk-offload without a durability backstop.
Description check ✅ Passed The description is mostly complete, covering summary, verification, performance impact, and follow-up, though it doesn't use the exact template headings.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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/disk-offload-no-durability-warn

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: 8

🤖 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 45-53: Update the changelog entry describing disk-offload behavior
to replace “only noeviction still OOMs” with wording that also includes volatile
eviction policies once their TTL-bearing victims are exhausted, matching the
regression test and tuning guide.

In `@docs/guides/tuning.md`:
- Around line 220-225: Update the “Pure cache (no durability)” section to
qualify the statement that only noeviction rejects writes: volatile-* policies
can also return OOM when no TTL-bearing keys remain. Reference the eviction
behavior verified by the regression test in eviction.rs, and revise the wording
to state that volatile policies evict only TTL-bearing victims while retaining
persistent keys.

In `@src/config.rs`:
- Around line 934-958: Split the oversized Config implementation and its related
tests from src/config.rs into focused Rust modules before adding further
configuration APIs. Preserve public interfaces and behavior, including
disk_offload_spill_inert and its associated tests, and update module
declarations/imports so all existing callers and tests continue to compile.
Ensure no resulting Rust file exceeds 1500 lines.
- Around line 947-955: Update the documentation near the spill/maxmemory policy
explanation to state that writes can also return OOM under volatile-* policies
when no TTL-bearing eviction victim remains, as implemented in
try_evict_if_needed_async_spill_with_total_budget. Replace the overly broad
claim that only noeviction rejects writes with OOM while preserving the
distinction that spill remains inert.
- Around line 956-958: The disk offload warning logic in
disk_offload_spill_inert must use parsed active save rules rather than
save.is_none(), so an empty --save value is treated as having no RDB schedule.
Update the condition using the existing save-rule parsing representation and add
a test covering --save "".

In `@src/main.rs`:
- Around line 143-147: Split the oversized startup implementation in src/main.rs
into focused Rust modules before adding further logic, keeping main startup
behavior intact. Move related functions, types, and helpers together and update
module declarations/imports so callers still resolve correctly; ensure no Rust
source file exceeds the 1500-line limit.
- Around line 143-147: Update the startup comment above
warn_disk_offload_without_durability to describe the current behavior: eviction
policies drop eligible victims, while OOM occurs only with noeviction or when no
eligible victim exists; retain the note that disk offload remains inert without
a durability backstop and behavior is unchanged.

In `@src/storage/eviction.rs`:
- Around line 618-640: Split eviction.rs so every Rust file remains under the
1500-line limit: move the large test module into a separate test file/module,
and separate read/write eviction implementations if necessary. Preserve existing
module visibility, imports, and test coverage, with production symbols such as
evict_one_with_spill and related eviction APIs remaining correctly accessible.
🪄 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: 1d67839e-99a7-4aba-a97d-f6c25ba38cc9

📥 Commits

Reviewing files that changed from the base of the PR and between 0db0990 and 6683efe.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • docs/guides/tuning.md
  • src/config.rs
  • src/main.rs
  • src/storage/eviction.rs

Comment thread CHANGELOG.md Outdated
Comment thread docs/guides/tuning.md Outdated
Comment thread src/config.rs
Comment on lines +934 to +958
/// True when `--disk-offload enable` is configured but neither AOF
/// (`--appendonly yes`) nor RDB (`--save`) durability is on (GCP
/// benchmark finding, 2026-07-10).
///
/// The durable-spill eviction path (`evict_batch_durable_no_aof`) needs
/// a `ShardManifest`, which today is only threaded through the
/// tick-driven memory-pressure cascade
/// (`shard::persistence_tick::handle_memory_pressure`) — itself gated on
/// `persistence_dir`, which `main.rs` only constructs when
/// `appendonly == "yes" || save.is_some()` (see `main.rs`'s
/// `persistence_dir` binding). The inline per-connection write-path
/// eviction gate has no manifest access and, under this combination,
/// cannot durably spill — cold data is NOT tiered to disk regardless of
/// policy. What happens to the *write* itself is now policy-aware
/// (`src/storage/eviction.rs`, the `manifest is None` branch of
/// `try_evict_if_needed_async_spill_with_total_budget`): an evicting
/// policy (`allkeys-*`/`volatile-*`) still honors `--maxmemory` by
/// DROPPING victims outright (Redis cache semantics, no tiering, no
/// crash-durability claim needed since nothing needs to survive a
/// restart); only `noeviction` rejects writes with OOM at the cap. This
/// predicate stays orthogonal to `maxmemory_policy` — spill IS still
/// inert either way — and exists only to make that degradation loud.
pub fn disk_offload_spill_inert(&self) -> bool {
self.disk_offload_enabled() && self.appendonly != "yes" && self.save.is_none()
}

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 | 🟠 Major | 🏗️ Heavy lift

Split src/config.rs before extending it further.

The final file reaches Line 2808, so this change adds more configuration surface to a Rust file already well beyond the 1500-line limit. Move related implementation/tests into modules before continuing to grow this file.

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/config.rs` around lines 934 - 958, Split the oversized Config
implementation and its related tests from src/config.rs into focused Rust
modules before adding further configuration APIs. Preserve public interfaces and
behavior, including disk_offload_spill_inert and its associated tests, and
update module declarations/imports so all existing callers and tests continue to
compile. Ensure no resulting Rust file exceeds 1500 lines.

Source: Coding guidelines

Comment thread src/config.rs
Comment thread src/config.rs
Comment on lines +956 to +958
pub fn disk_offload_spill_inert(&self) -> bool {
self.disk_offload_enabled() && self.appendonly != "yes" && self.save.is_none()
}

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 | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

ast-grep outline src/config.rs --items all --type function
rg -n -C6 -- '--save|parse_save_rules|save\.is_some|save\.is_none' \
  src/config.rs src/main.rs

Repository: pilotspace/moon

Length of output: 15227


🏁 Script executed:

#!/bin/bash
set -eu

# Inspect the save field definition and relevant parsing logic.
ast-grep outline src/config.rs --match 'struct ServerConfig' --view expanded
rg -n -C4 'save:|--save|parse_save_rules|save\.is_some|save\.is_none|disable form|empty.*save|snapshot rule' src/config.rs src/main.rs src/persistence -g '!target'

# Inspect auto-save rule parsing implementation.
ast-grep outline src/persistence/auto_save.rs --view expanded
sed -n '1,260p' src/persistence/auto_save.rs

Repository: pilotspace/moon

Length of output: 28129


🏁 Script executed:

#!/bin/bash
set -eu

# Show the CLI field annotations and any normalization of save.
sed -n '130,170p' src/config.rs
sed -n '1230,1335p' src/config.rs

# Search for explicit tests or docs about disabling save snapshots.
rg -n -C3 'save.*""|--save ""|empty string|disable.*save|save is none|save rules empty|parse_save_rules\(&input\)' src tests .

Repository: pilotspace/moon

Length of output: 46382


Use active save rules here

--save "" is still Some(""), so save.is_none() misses the disable form and suppresses this warning even when no RDB schedule is active. Check parsed save rules instead, and add coverage for --save "".

🤖 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/config.rs` around lines 956 - 958, The disk offload warning logic in
disk_offload_spill_inert must use parsed active save rules rather than
save.is_none(), so an empty --save value is treated as having no RDB schedule.
Update the condition using the existing save-rule parsing representation and add
a test covering --save "".

Comment thread src/main.rs
Comment on lines +143 to +147
// GCP benchmark finding (2026-07-10): --disk-offload enable without a
// durability backstop (--appendonly yes or --save) leaves the cold-spill
// tier silently inert — --maxmemory becomes a hard reject-at-cap instead
// of spilling. Warn once at startup; behavior is intentionally unchanged.
config.warn_disk_offload_without_durability();

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 | 🟠 Major | 🏗️ Heavy lift

Split src/main.rs before adding more startup logic.

The final file reaches Line 1862, exceeding the repository’s 1500-line Rust-file limit.

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/main.rs` around lines 143 - 147, Split the oversized startup
implementation in src/main.rs into focused Rust modules before adding further
logic, keeping main startup behavior intact. Move related functions, types, and
helpers together and update module declarations/imports so callers still resolve
correctly; ensure no Rust source file exceeds the 1500-line limit.

Source: Coding guidelines


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

Update the startup comment to match the new eviction behavior.

This comment still says --maxmemory becomes a hard reject, but evicting policies now drop eligible victims; OOM remains for noeviction and policies with no eligible victim.

Suggested wording
-    // tier silently inert — --maxmemory becomes a hard reject-at-cap instead
-    // of spilling. Warn once at startup; behavior is intentionally unchanged.
+    // tier silently inert — evicting policies drop eligible victims instead
+    // of spilling, while noeviction rejects writes at the cap. Warn once.
📝 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
// GCP benchmark finding (2026-07-10): --disk-offload enable without a
// durability backstop (--appendonly yes or --save) leaves the cold-spill
// tier silently inert — --maxmemory becomes a hard reject-at-cap instead
// of spilling. Warn once at startup; behavior is intentionally unchanged.
config.warn_disk_offload_without_durability();
// GCP benchmark finding (2026-07-10): --disk-offload enable without a
// durability backstop (--appendonly yes or --save) leaves the cold-spill
// tier silently inert — evicting policies drop eligible victims instead
// of spilling, while noeviction rejects writes at the cap. Warn once.
config.warn_disk_offload_without_durability();
🤖 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/main.rs` around lines 143 - 147, Update the startup comment above
warn_disk_offload_without_durability to describe the current behavior: eviction
policies drop eligible victims, while OOM occurs only with noeviction or when no
eligible victim exists; retain the note that disk offload remains inert without
a durability backstop and behavior is unchanged.

Comment thread src/storage/eviction.rs
Comment on lines +618 to +640
// durable spill (evict_batch_durable_no_aof, below) is impossible
// here. That does NOT mean the cap goes unenforced, though: an
// evicting policy (AllKeys*/Volatile*) must still honor Redis
// semantics and reclaim the budget by DROPPING victims with no
// tiering (`evict_one_with_spill` with `spill: None`, the exact
// plain-drop helper the disk-offload-disabled write path already
// uses) -- only `noeviction` rejects with OOM. A durable spill
// still happens whenever a manifest IS reachable (the
// tick-driven memory-pressure cascade, handled below) or once
// `--appendonly yes` / `--save` gives this call site a backstop.
let mut current_total = total_memory;
while current_total > budget {
if policy == EvictionPolicy::NoEviction {
return Err(oom_error());
}
let before = db.estimated_memory();
if !evict_one_with_spill(db, config, &policy, None) {
return Err(oom_error());
}
let after = db.estimated_memory();
current_total = current_total.saturating_sub(before.saturating_sub(after));
}
return Ok(());

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 | 🟠 Major | 🏗️ Heavy lift

Split src/storage/eviction.rs before adding more eviction logic.

The final file reaches Line 2187, exceeding the repository’s 1500-line Rust-file limit. Separate production eviction logic from the large test module and split read/write implementations as needed.

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/storage/eviction.rs` around lines 618 - 640, Split eviction.rs so every
Rust file remains under the 1500-line limit: move the large test module into a
separate test file/module, and separate read/write eviction implementations if
necessary. Preserve existing module visibility, imports, and test coverage, with
production symbols such as evict_one_with_spill and related eviction APIs
remaining correctly accessible.

Source: Coding guidelines

…remains

Address CodeRabbit review on #273: the "only noeviction OOMs" phrasing
understated the fail-close. An evicting policy also returns OOM when it has
no eligible victim to drop — the canonical case is a volatile-* policy once
its TTL-bearing keys are exhausted (persistent keys are retained, the write
is rejected), exactly as the volatile-lru regression test verifies.

Corrected in the three user-facing spots: CHANGELOG [Unreleased], the
disk_offload_spill_inert doc comment + startup warn string (src/config.rs),
and the "Pure cache" recipe note in docs/guides/tuning.md. No behavior
change — wording only.

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

Copy link
Copy Markdown
Contributor Author

Addressed CodeRabbit review in c89bac4:

  • Fixed (accuracy): the "only `noeviction` OOMs" wording understated the fail-close — an evicting policy also OOMs when it has no eligible victim (e.g. `volatile-*` once its TTL keys are exhausted, as the volatile-lru regression test verifies). Corrected in CHANGELOG, src/config.rs doc comment + warn string, and docs/guides/tuning.md.
  • Deferred (out of scope): the file-split suggestions on config.rs / main.rs / eviction.rs (>1500-line limit) are pre-existing — none of these files crossed the limit in this 2-commit fix. Splitting them is a separate refactor already tracked as a follow-up (alongside the known rdb.rs split).

@pilotspacex-byte
pilotspacex-byte merged commit 3aa9fee into main Jul 10, 2026
10 checks passed
@pilotspacex-byte
pilotspacex-byte deleted the fix/disk-offload-no-durability-warn branch July 10, 2026 13:55
pilotspacex-byte added a commit that referenced this pull request Jul 13, 2026
… of plain-dropping them (#312)

Root-causes the intermittent tests/cold_collection_visibility.rs CI flake
(stable GHOST: EXISTS == 1 but LRANGE/ZRANGE/HGETALL/SMEMBERS permanently
empty; reproduced on unmodified main, only on shared/slow Linux runners).

Two compounding defects in storage::eviction::evict_one_with_spill (the
synchronous spill path shared by the tick, the memory-pressure cascade's
sync-spill fallback, and db_quota):

1. Collection victims (Hash/List/Set/ZSet/Stream) were gated behind a
   stale `is_string` check predating kv_spill::spill_to_datafile's full
   collection support (it already serializes any RedisValueRef via
   kv_serde) — a collection victim under a live SpillContext silently
   plain-dropped, indistinguishable from spill: None.
2. Even string victims never registered a ColdIndex entry after a
   successful sync spill (spill_to_datafile was always called with
   cold_index: None) — the durable .mpf file existed and was manifest-
   registered, but nothing could ever read it back.

Separately, shard::timers::run_eviction (the periodic 100ms tick,
independent of the memory-pressure cascade) never received a SpillContext
at all — every victim it picked was plain-dropped even with
--disk-offload enable and a durability backstop (--appendonly yes)
configured. shard::persistence_tick::run_eviction_tick now builds a real
SpillContext (ShardManifest + shard data dir + next_file_id) whenever
disk-offload is enabled and a manifest is present, threading it through;
falls back to the pre-existing fail-close plain-drop (PR #273
policy-aware discipline — noeviction still OOMs, an evicting policy still
frees RAM) when no durability backstop exists, matching the documented
"spill is inert without one" rule.

Deterministic regression tests (no server process, no timing race):
  - storage::eviction::tests::sync_spill_non_string_victim_is_durably_spilled_not_plain_dropped
  - shard::timers::tests::test_run_eviction_spills_collection_victim_when_spill_context_given

tests/cold_collection_visibility.rs is unmodified (its GHOST assertions
are the correctness gate this fix satisfies) and green 10/10 locally on
both runtimes (monoio release-fast + tokio jemalloc release-fast).

Gates: cargo fmt --check clean; cargo clippy -- -D warnings clean (default
+ --no-default-features --features runtime-tokio,jemalloc); storage::eviction,
shard::timers, shard::persistence_tick, storage::db_quota, storage::tiered
lib test modules green.

footer: closes task #45
author: Tin Dang

Co-authored-by: Tin Dang <tindang.ht97@gmail.com>
TinDang97 added a commit that referenced this pull request Jul 14, 2026
…ound-truth mismatch (task #44)

crash_recovery_disk_offload_no_aof was the last red suite cited by
PRODUCTION-CONTRACT.md row CRASH-01. Investigated whether it was harness
rot (like task #31's WAL v2 probe rot) or a real durability defect (like
task #60's replay no-op) with on-disk evidence before touching anything.

Root cause: harness rot, not a data-loss regression.

The test forced LRU eviction with a 16,000-key pipelined SET burst and
asserted post-crash recovery >= 65% of PROBE_COUNT, assuming most evicted
probes land durably in a heap-*.mpf file. That assumption predates
disk_offload_spill_inert() / PR #273 (policy-aware eviction fail-close):
under --appendonly no, the per-connection write-path eviction gate
(run_write_eviction_gate, src/server/conn/handler_monoio/mod.rs) -- the
path every ordinary SET/HSET past maxmemory actually takes -- has no
ShardManifest handle and PLAIN-DROPS victims. This is documented Redis
"pure cache, no durability" semantics (see docs/PRODUCTION-CONTRACT.md's
CRASH-02 note on task #57, which made this drop-not-OOM behavior the
intended fix, not a bug). Only the periodic memory-pressure tick
(shard/persistence_tick.rs) durably spills under --appendonly no, and a
busy connection's synchronous per-write eviction always wins the race
before that tick ever observes a sustained over-budget shard. Confirmed
via direct manifest/heap-*.mpf inspection: the old pipelined-SET burst
durably spilled 0-1/200 probes, making the 65% floor permanently
unreachable regardless of settle time or contention tuning.

Fix (test-only, no src/ change):

1. write_filler now sends the filler as ONE MSET instead of 16,000
   pipelined SETs. MSET's handler applies all pairs with no per-key
   eviction check, and the write-path gate that does run checks memory
   once, pre-MSET -- so a shard can end up far over budget with no
   further write pending to re-trigger the plain-drop path. The periodic
   tick is then the only thing left to reclaim it, durably spilling the
   LRU-oldest keys (the probes) via the manifest.

2. The fixed RECOVERY_FLOOR is replaced by count_durable_probe_entries,
   which reads each shard's manifest + Active KvLeaf DataFiles directly
   (the same way recover_shard_v3_pitr does at boot) right before the
   kill to establish ground truth, and asserts recovery returns AT LEAST
   that many probes -- the actual #22 regression signal, decoupled from
   eviction throughput.

Verified the rewritten test still red-flags a reintroduced #22 regression:
manually reverted the `persistence_dir.is_some() || disk_offload_base.is_some()`
gate in main.rs, confirmed RED (ground truth 20, post 0), reverted back.
Green 12x+ consecutive on macOS (monoio default + tokio runtime); no
regression in crash_recovery_cold_del_resurrection or the
crash_matrix_cross_plane no-durability-contract spot check
(cross_plane_spot_offload_no_s1_no_durability_contract_liveness), which
already independently documents the same drop-not-spill contract.

fmt clean, clippy clean (default + tokio+jemalloc matrix).

Updates CRASH-01's audit caveat in docs/PRODUCTION-CONTRACT.md and adds a
CHANGELOG [Unreleased] entry.

author: Tin Dang
pilotspacex-byte added a commit that referenced this pull request Jul 14, 2026
…ound-truth mismatch (task #44) (#328)

crash_recovery_disk_offload_no_aof was the last red suite cited by
PRODUCTION-CONTRACT.md row CRASH-01. Investigated whether it was harness
rot (like task #31's WAL v2 probe rot) or a real durability defect (like
task #60's replay no-op) with on-disk evidence before touching anything.

Root cause: harness rot, not a data-loss regression.

The test forced LRU eviction with a 16,000-key pipelined SET burst and
asserted post-crash recovery >= 65% of PROBE_COUNT, assuming most evicted
probes land durably in a heap-*.mpf file. That assumption predates
disk_offload_spill_inert() / PR #273 (policy-aware eviction fail-close):
under --appendonly no, the per-connection write-path eviction gate
(run_write_eviction_gate, src/server/conn/handler_monoio/mod.rs) -- the
path every ordinary SET/HSET past maxmemory actually takes -- has no
ShardManifest handle and PLAIN-DROPS victims. This is documented Redis
"pure cache, no durability" semantics (see docs/PRODUCTION-CONTRACT.md's
CRASH-02 note on task #57, which made this drop-not-OOM behavior the
intended fix, not a bug). Only the periodic memory-pressure tick
(shard/persistence_tick.rs) durably spills under --appendonly no, and a
busy connection's synchronous per-write eviction always wins the race
before that tick ever observes a sustained over-budget shard. Confirmed
via direct manifest/heap-*.mpf inspection: the old pipelined-SET burst
durably spilled 0-1/200 probes, making the 65% floor permanently
unreachable regardless of settle time or contention tuning.

Fix (test-only, no src/ change):

1. write_filler now sends the filler as ONE MSET instead of 16,000
   pipelined SETs. MSET's handler applies all pairs with no per-key
   eviction check, and the write-path gate that does run checks memory
   once, pre-MSET -- so a shard can end up far over budget with no
   further write pending to re-trigger the plain-drop path. The periodic
   tick is then the only thing left to reclaim it, durably spilling the
   LRU-oldest keys (the probes) via the manifest.

2. The fixed RECOVERY_FLOOR is replaced by count_durable_probe_entries,
   which reads each shard's manifest + Active KvLeaf DataFiles directly
   (the same way recover_shard_v3_pitr does at boot) right before the
   kill to establish ground truth, and asserts recovery returns AT LEAST
   that many probes -- the actual #22 regression signal, decoupled from
   eviction throughput.

Verified the rewritten test still red-flags a reintroduced #22 regression:
manually reverted the `persistence_dir.is_some() || disk_offload_base.is_some()`
gate in main.rs, confirmed RED (ground truth 20, post 0), reverted back.
Green 12x+ consecutive on macOS (monoio default + tokio runtime); no
regression in crash_recovery_cold_del_resurrection or the
crash_matrix_cross_plane no-durability-contract spot check
(cross_plane_spot_offload_no_s1_no_durability_contract_liveness), which
already independently documents the same drop-not-spill contract.

fmt clean, clippy clean (default + tokio+jemalloc matrix).

Updates CRASH-01's audit caveat in docs/PRODUCTION-CONTRACT.md and adds a
CHANGELOG [Unreleased] entry.

author: Tin Dang

Co-authored-by: Tin Dang <tindang.ht97@gmail.com>
pilotspacex-byte added a commit that referenced this pull request Jul 16, 2026
…les, ~129× fewer heap files (v0.8 item 3) (#350)

* perf(storage): coalesce oversized entries into shared spill batch files

v0.8 roadmap item 3 / task #57 follow-up: disk-offload spill files must
batch effectively so file count scales as ~keys/batch, not ~keys. The G2
acceptance run (260K x 10KB keys, 256MB cap) produced ~236K heap-*.mpf
files even though the format nominally supports batching (<=256 keys per
file). Root cause: SpillThread::flush_buffer already coalesced up to 256
requests per flush, but partitioned them by value size before writing —
every entry over INLINE_MAX_VALUE_BYTES (3500B, e.g. all of G2's 10KB
values) was routed to its own dedicated single-entry file via
spill_single_entry, defeating the batch entirely.

build_kv_spill_batch / write_kv_spill_batch (src/storage/tiered/kv_spill.rs)
are rewritten to pack inline AND oversized entries into ONE shared batch
file per flush: each oversized entry gets a dedicated leaf page (holding
an overflow pointer) plus its overflow-page chain placed inline in the
same page stream, via a new BatchSlot enum (Leaf | Overflow) replacing the
old two-Vec (leaves, overflow) BatchPages layout. The atomic
temp-file+sync_all+rename+fsync_directory write sequence is unchanged, so
the payload-before-reference durability ordering (a key is only marked
cold after its containing segment is durable) is preserved. flush_buffer
(src/storage/tiered/spill_thread.rs) no longer partitions the buffer by
size — it builds one SpillEntry list and calls build_kv_spill_batch once
per flush, producing one SpillCompletion (or falling back to per-entry
salvage only on a build/write error).

Fixing this uncovered a latent bug in build_overflow_chain
(src/persistence/kv_page.rs): its prev/next link computation silently
assumed every chain's start_page_id == 1 (true of every caller before this
change, since a single-entry file always put its one leaf at page 0) — a
chain-local i+1/i+2 formula that produced dangling/wrong links the moment
a caller (this new batching code) passed a variable start_page_id > 1 for
the second, third, etc. oversized entry sharing a file. Links are now
computed as file-absolute (start_page_id + i), matching what
read_overflow_chain already expected. Caught by the new
test_rebuild_from_manifest_oversized_batch_roundtrip test before it could
ship.

Empirical measurement (macOS, matching G2's shape): RED (pre-fix) 3742
files / ~34,908 evicted 10KB keys; GREEN (post-fix) 29 files for the same
eviction load — a ~129x reduction, spill_batches_flushed now matches file
count 1:1 as designed.

New tests:
- kv_spill.rs::tests: test_build_kv_spill_batch_oversized_entries_share_one_file,
  test_build_kv_spill_batch_mixed_inline_and_oversized,
  test_rebuild_from_manifest_oversized_batch_roundtrip
- tests/crash_recovery_spill_batch_kill9.rs: end-to-end kill-9-mid-spill
  regression guard. Drives a real server through a sustained
  eviction+spill burst of 4000 x 8000-byte (overflow-chain) values under
  --appendonly yes --appendfsync always, SIGKILLs it with no settle
  window (maximizing the chance of a torn trailing batch — either an
  unrenamed heap-*.tmp or a just-renamed file whose manifest commit raced
  the kill), then restarts and asserts zero acknowledged-write loss, byte-
  exact content, a batched (<= keys/64) heap-file count before the kill,
  and no lingering .tmp files after the post-restart orphan sweep
  settles. Verified green on both the monoio (default) and tokio
  (--features runtime-tokio,jemalloc) runtimes.

Note: an earlier version of this test restarted round 2 with the same
small --maxmemory as round 1 and saw non-deterministic scattered loss
(68-533 keys across repeated runs) on BOTH this fix and the pre-fix
binary (A/B'd by hand via git stash) — root-caused to AOF replay
re-inserting the full dataset hot with no eviction gate, then racing the
verification reads' cold-promotions against the periodic post-restart
eviction tick. That is a distinct, pre-existing gap orthogonal to spill
batching (adjacent to the in-flight used_memory-accounting fix in a
sibling worktree) and out of scope here; round 2 now restarts with a
generous --maxmemory to remove that confound and isolate the contract
actually in scope.

Preserved invariants (verified via the full existing kv_spill/spill_thread
test suite plus the crash test above): next_spill_file_id_seed uniqueness
across orphans, crash-orphan sweep classification, FLUSH/DEL tombstone +
cold-plane replay (PR #257), and eviction fail-close semantics (#273) are
all unaffected — none of that logic was touched.

Gates: cargo fmt --check, cargo clippy -- -D warnings (default features
and --no-default-features --features runtime-tokio,jemalloc, both
including --tests), full cargo test --lib (4333 passed), targeted
kv_spill/spill_thread/kv_page unit tests, the new crash_recovery_spill_batch_kill9
integration test (3x stable on monoio, 1x green on tokio), and the
pre-existing crash_recovery_disk_offload_no_aof regression test — all
green.

Not in scope (deferred, flagged for follow-up): the synchronous
per-key SpillContext eviction path (timers::run_eviction, used when the
async memory-pressure cascade is not active) still spills one file per
victim. The empirical repro matching G2's shape (--appendonly yes)
confirmed the async batching path above is dominant and fully fixes the
reported regression; the sync path is lower-traffic and was left alone
given the scoped time budget.

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

* fix(storage): close adversarial-review gaps on spill-segment-batching

Adversarial review of perf/spill-segment-batching (SHIP-WITH-FIXES) flagged
three gaps in the spill-file-batching rewrite before merge:

1. Test gap: the existing kill-9 test only proves AOF-replay-derived
   recovery (every acked key ends up hot via DispatchReplayEngine, never
   touching cold_read_through), so it could not tell "the leaf-offset +
   overflow-chain-with-start_page_id>1 read path works" apart from "AOF
   replay papered over a broken cold layout". Added
   spill_batch_shared_file_survives_cold_read_after_kill9
   (tests/crash_recovery_spill_batch_kill9.rs): pre-seeds a 3-entry shared
   batch file (1 inline + 2 oversized, ordered so the second oversized
   entry's overflow chain starts at file-absolute page_idx > 1) directly via
   build_kv_spill_batch/write_kv_spill_batch + ShardManifest, boots a real
   --appendonly no server on top of it, kills it without ever touching the
   keys, restarts, and GETs all three -- the only way any of these bytes
   reach the client is a genuine ColdIndex::rebuild_from_manifest +
   cold_read_through read.

   Getting this test to actually exercise that path (rather than a hot-RAM
   hit) required pulling in src/persistence/recovery.rs's fix from the
   concurrent task #56 effort (fix/t56-used-memory-offload, commit
   689c52e): v3 recovery Phase 3 used to re-insert every previously-spilled
   String entry directly into the hot DashTable in ADDITION to rebuilding
   the ColdIndex stub for the same key -- for an entry_flags::OVERFLOW
   entry this hot-preload loop ignored the flag and inserted the raw 4-byte
   page-pointer as if it were the literal value, silently corrupting every
   oversized cold key on every restart (a pre-existing bug, independent of
   this batching fix -- it affected the old single-entry-per-file layout
   too). That recovery.rs hunk is a clean, self-contained cherry-pick
   (verified: 689c52e's parent has an IDENTICAL recovery.rs to this
   branch's pre-fix state, and the hunk does not touch any of task #56's
   other, out-of-scope-here, used_memory ledger/RSS/AOF-replay-demotion
   changes) and is a hard prerequisite for this test's premise -- there is
   no way to route around it from this side.

   A companion library-level unit test,
   test_rebuild_from_manifest_mixed_inline_and_oversized_roundtrip
   (kv_spill.rs), independently proves the exact same read path correct via
   direct calls to the production functions, without depending on server
   boot sequencing.

2. Memory-pressure characteristic: build_kv_spill_batch materialized an
   entire flush's pages in RAM before write_kv_spill_batch touched disk --
   fine for the old ~50KB/256-small-entries case, but this fix lets
   oversized entries share that same unconditional batch, so 256
   consistently-large (e.g. near-maxmemory-sized) values could resident
   hundreds of MB at once, exactly when spills fire under memory pressure.
   flush_buffer (spill_thread.rs) now splits each FLUSH_ENTRY_CAP-sized
   buffer into sub-batches capped at 4 MiB of cumulative value_bytes (chosen
   against the OLD per-entry path's peak -- one value's pages at a time, no
   cross-entry amplification -- while leaving the G2 acceptance shape,
   256 x ~10KB = ~2.5 MiB, as a single file, unchanged), each becoming its
   own file via that sub-batch's own already-pre-assigned file_id. New unit
   test: oversized_flush_splits_into_byte_capped_sub_batches.

3. Hygiene: fixed the stale FLUSH_ENTRY_CAP comment (no longer claims to
   bound in-RAM size by itself -- superseded by BATCH_BYTES_CAP) and the
   stale entry_flags::OVERFLOW doc (corrected from a fictitious 12-byte
   file_id:u64+page_id:u32 layout to the actual 4-byte file-absolute
   start_page_idx -- no file_id is stored, the chain always lives in the
   same physical file as its leaf stub). Added a comment at
   write_kv_spill_batch explaining why it hand-rolls temp+fsync+rename
   instead of calling persistence::atomic::atomic_write_durable: that
   helper takes one pre-materialized &[u8], and concatenating batch.pages
   into a single buffer first would undo BATCH_BYTES_CAP's whole point.

Gates (macOS, this worktree):
- cargo fmt --check: clean
- cargo clippy --tests -- -D warnings (default features): clean
- cargo clippy --no-default-features --features runtime-tokio,jemalloc --tests -- -D warnings: clean
- cargo test --release --lib (default features): 4335 passed, 0 failed, 1 ignored
- cargo test --no-default-features --features runtime-tokio,jemalloc --release --lib: 3518 passed, 0 failed, 1 ignored
- cargo test --release --lib storage::tiered::kv_spill::: 16 passed
- cargo test --release --lib persistence::kv_page::: 23 passed
- cargo test --release --lib persistence::recovery::: 15 passed
- cargo test --release --lib storage::tiered::spill_thread::: 13 passed (incl. new byte-cap-split test)
- cargo test --release --test crash_recovery_spill_batch_kill9 -- --ignored (monoio, default features): 2 passed
  (spill_batch_shared_file_survives_cold_read_after_kill9 re-run 3x clean before this final pass)
- cargo test --no-default-features --features runtime-tokio,jemalloc --release --test crash_recovery_spill_batch_kill9 -- --ignored (tokio, MOON_NO_URING=1): 2 passed

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

---------

Co-authored-by: Tin Dang <tindang.ht97@gmail.com>
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