Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed — `--disk-offload` without a durability backstop broke the default LRU cache recipe

- **GCP benchmark finding (2026-07-10)**: with `--disk-offload enable` (the
default) but `--appendonly no` and no `--save`, the durable-spill eviction
path added to fix the crash window above (`evict_batch_durable_no_aof`)
never runs, because it needs a `ShardManifest` that 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()`. The inline write-path eviction
gate has no manifest access, so durable spill is impossible from that call
site — but the pre-fix `manifest is None` branch OOM'd **unconditionally**
regardless of `--maxmemory-policy`, silently breaking the documented
"Pure cache (no durability)" recipe
(`--appendonly no --maxmemory <bytes> --maxmemory-policy allkeys-lru`): a
classic LRU cache rejected writes at the cap instead of evicting.
- **Behavior fix** (`src/storage/eviction.rs`,
`try_evict_if_needed_async_spill_with_total_budget`'s `manifest is None`
branch): made the fail-close policy-aware, matching Redis semantics. When
`total_memory <= budget` nothing happens; when `policy == NoEviction` it
still OOMs (correct — noeviction always rejects); otherwise (any evicting
policy — `allkeys-*`/`volatile-*`) it now reclaims the budget by
**dropping** victims via `evict_one_with_spill(.., spill: None)` (the same
plain-drop helper the disk-offload-disabled write path already uses — no
new victim-selection logic), looping until the budget is satisfied or no
eligible victim remains (then OOM). No durable spill is attempted here —
that still only happens via the tick's `evict_batch_durable_no_aof` when a
manifest is reachable. Three new tests in `src/storage/eviction.rs`:
`async_spill_no_aof_backstop_no_manifest_allkeys_lru_evicts_pure_cache`
(the regression test — fails with OOM on pre-fix code, passes after),
`async_spill_no_aof_backstop_no_manifest_noeviction_still_rejects`
(unchanged noeviction OOM behavior), and
`async_spill_no_aof_backstop_no_manifest_volatile_lru_scoped_to_ttl_keys`
(`volatile-*` only evicts keys with a TTL, never a persistent key). The
`--appendonly yes` async-spill path and the manifest-reachable durable
batch-spill path are untouched.
- **Startup warning**: `ServerConfig::disk_offload_spill_inert` (new
predicate, `src/config.rs`) plus
`ServerConfig::warn_disk_offload_without_durability` (mirrors
`warn_deprecated_cold_tier_flags`) emit a single `tracing::warn!` at
startup when `--disk-offload enable` + no durability backstop is
detected, called from `main.rs` right after the existing cold-tier-flags
warning: disk-offload's cold-spill tiering is still inert in this
combination (evicting policies drop eligible victims instead of tiering;
`noeviction` — and any evicting policy with no eligible victim left, e.g.
`volatile-*` once its TTL-bearing keys are gone — returns OOM). Documented
in `docs/guides/tuning.md`'s "Tiered
memory offload" section.

### Fixed — test flakiness: oom_bypass_closure readiness on loaded CI runners

- `tests/oom_bypass_closure.rs`: the readiness path used a fixed 30s connect
Expand Down
23 changes: 23 additions & 0 deletions docs/guides/tuning.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,29 @@ resident forever:
backing both the KV cold-read cache and vector/graph page I/O; it starts
empty and grows lazily, so setting it high does not pre-commit RAM.

> **Requires a durability backstop.** The KV cold-spill path above needs a
> `ShardManifest`, which is only threaded through the tick-driven
> memory-pressure cascade — itself gated on `--appendonly yes` or `--save`
> being configured. With `--disk-offload enable` (the default) but
> `--appendonly no` and no `--save`, the inline write-path eviction gate has
> no manifest access and cannot durably spill: cold data is **never spilled
> to disk** in this combination, regardless of `--maxmemory-policy`. The
> **"Pure cache (no durability)" recipe above still works correctly** —
> `allkeys-lru` (and the other evicting policies) fall back to Redis-style
> cache eviction: victims are DROPPED outright (no tiering, no durability
> claim needed since nothing is meant to survive a restart) to keep
> `--maxmemory` honored. `noeviction` rejects writes with OOM once the budget
> is hit, and an evicting policy also returns OOM when no eligible victim
> remains (e.g. no TTL-bearing key is left under a `volatile-*` policy) — same
> as with disk-offload off. This
> spill-inertness is intentional (correctness over availability for the
> *tiering* feature specifically) — Moon warns about it once at startup
> (`ServerConfig::warn_disk_offload_without_durability`). Enable
> `--appendonly yes` or configure `--save` to activate disk-offload spill
> (durable tiering instead of dropping), or pass `--disk-offload disable` if
> you only want in-memory `--maxmemory-policy` eviction with no spill code
> path involved at all.

## Vector/FTS/graph idle-unload

Immutable vector segments (`ImmutableSegment`: full in-memory HNSW graph +
Expand Down
75 changes: 75 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -931,6 +931,54 @@ impl ServerConfig {
}
}

/// 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); `noeviction` — and any evicting policy once no eligible
/// victim remains (e.g. `volatile-*` with no TTL keys left) — 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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
pub fn disk_offload_spill_inert(&self) -> bool {
self.disk_offload_enabled() && self.appendonly != "yes" && self.save.is_none()
}
Comment on lines +934 to +960

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 on lines +958 to +960

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


/// Warn once at startup when [`disk_offload_spill_inert`] holds — see
/// that method's docs for the full mechanism. Behavior is unchanged;
/// this only surfaces the pre-existing silent degradation.
///
/// [`disk_offload_spill_inert`]: Self::disk_offload_spill_inert
pub fn warn_disk_offload_without_durability(&self) {
if self.disk_offload_spill_inert() {
tracing::warn!(
"--disk-offload is enabled but persistence is off (appendonly=no and no \
--save). The disk-offload cold-spill tier requires a durability backstop \
to function: without one, cold data is NOT spilled to disk. Evicting \
policies (allkeys-*/volatile-*) fall back to DROPPING eligible \
victims with no tiering; noeviction — and any evicting policy once \
no eligible victim remains (e.g. volatile-* with no TTL keys) — \
rejects writes with OOM at the cap. Enable \
--appendonly yes or --save to activate durable spill."
);
}
}

/// Validate `--databases` fits the `u8` db_index tag used by vector/text
/// index scoping (WS5a round 2). Returns an error message (not a
/// `Frame`/`anyhow::Error` — kept plain so both `main.rs`'s early-boot
Expand Down Expand Up @@ -2369,6 +2417,33 @@ mod tests {
assert_eq!(config.pagecache_size, None);
}

#[test]
fn disk_offload_spill_inert_true_without_durability_backstop() {
// disk-offload defaults to enabled; explicitly turning off both AOF
// and RDB durability with no backstop leaves cold-spill inert.
let config = ServerConfig::parse_from(["moon", "--appendonly", "no"]);
assert!(config.disk_offload_spill_inert());
}

#[test]
fn disk_offload_spill_inert_false_when_appendonly_yes() {
let config = ServerConfig::parse_from(["moon", "--appendonly", "yes"]);
assert!(!config.disk_offload_spill_inert());
}

#[test]
fn disk_offload_spill_inert_false_when_save_configured() {
let config = ServerConfig::parse_from(["moon", "--appendonly", "no", "--save", "3600 1"]);
assert!(!config.disk_offload_spill_inert());
}

#[test]
fn disk_offload_spill_inert_false_when_disk_offload_disabled() {
let config =
ServerConfig::parse_from(["moon", "--appendonly", "no", "--disk-offload", "disable"]);
assert!(!config.disk_offload_spill_inert());
}

#[test]
fn test_parse_size() {
assert_eq!(ServerConfig::parse_size("256mb"), Some(268_435_456));
Expand Down
6 changes: 6 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,12 @@ fn main() -> anyhow::Result<()> {
// once if an operator still sets any of them away from their default.
config.warn_deprecated_cold_tier_flags();

// 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();
Comment on lines +143 to +147

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.


// ── --profile tuning presets (WS4, v0.6.0) ──────────────────────────────
// Fill-only: a preset only sets flags the operator left at their default
// (see `ServerConfig::apply_profile`); anything explicitly passed on the
Expand Down
Loading
Loading