fix(storage): truthful used_memory under disk-offload — RSS→logical ledger, stub-only recovery, replay demote (task #56)#349
Conversation
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
|
Warning Review limit reached
Next review available in: 40 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (11)
📝 WalkthroughWalkthroughDisk-offload memory reporting now uses a logical ledger instead of RSS. Recovery invalidates stale cold shadows and demotes redundant replayed hot copies across replay paths. Monitoring documentation, changelog entries, unit tests, and ignored end-to-end crash-recovery tests cover the updated behavior. ChangesDisk-offload correctness
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant AOFReplay
participant Recovery
participant Database
AOFReplay->>Recovery: complete replay
Recovery->>Database: demote_replayed_cold_shadows()
Database->>Database: remove redundant hot copies
Database-->>Recovery: return demoted count
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
4b9b003 to
ae45e2f
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (2)
src/persistence/recovery.rs (1)
703-726: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueIterate over all databases for consistency.
While disk offload currently targets
databases[0], iterating over alldatabasesmatches the loop structure inmain.rsand ensures future-proofing if disk offload is expanded to multiple databases.♻️ Proposed refactor
- if let Some(db0) = databases.first_mut() { - let demoted = db0.demote_replayed_cold_shadows(); - if demoted > 0 { - info!( - "Shard {}: demoted {} AOF-replay hot shadow(s) back to cold-only \ - stubs (Phase 4b fallback path, used_memory truthful after restart, task `#56`)", - shard_id, demoted - ); - } - } + let mut total_demoted = 0; + for db in databases.iter_mut() { + total_demoted += db.demote_replayed_cold_shadows(); + } + if total_demoted > 0 { + info!( + "Shard {}: demoted {} AOF-replay hot shadow(s) back to cold-only \ + stubs (Phase 4b fallback path, used_memory truthful after restart, task `#56`)", + shard_id, total_demoted + ); + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/persistence/recovery.rs` around lines 703 - 726, Update the Phase 4b fallback reconciliation to iterate over every database in databases instead of only databases.first_mut(). Call demote_replayed_cold_shadows on each database and log each positive demotion count with the corresponding shard context, preserving the existing behavior and message for each database.src/storage/db.rs (1)
1290-1345: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy liftFile exceeds the 1500-line cap; this PR adds further to an already-oversized file.
demote_replayed_cold_shadowsand its doc comment (plus the two new tests later in the file) are correct, but they land in asrc/storage/db.rsthat's already several times past the documented 1500-line limit. Consider splitting cold-tier-specificDatabasemethods (promotion/demotion/cold accessors) and their tests into a dedicated submodule.As per coding guidelines, "No single Rust file should exceed 1500 lines. Split command-group files into directory modules when approaching the limit; split read and write implementations above 1000 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/db.rs` around lines 1290 - 1345, Extract cold-tier-specific Database methods, including demote_replayed_cold_shadows, promotion/demotion logic, and cold accessors, into a dedicated storage submodule while preserving their existing visibility and behavior. Move the associated cold-tier tests with the implementation, and keep src/storage/db.rs under the 1500-line limit without changing unrelated Database functionality.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/persistence/recovery.rs`:
- Around line 703-726: Update the Phase 4b fallback reconciliation to iterate
over every database in databases instead of only databases.first_mut(). Call
demote_replayed_cold_shadows on each database and log each positive demotion
count with the corresponding shard context, preserving the existing behavior and
message for each database.
In `@src/storage/db.rs`:
- Around line 1290-1345: Extract cold-tier-specific Database methods, including
demote_replayed_cold_shadows, promotion/demotion logic, and cold accessors, into
a dedicated storage submodule while preserving their existing visibility and
behavior. Move the associated cold-tier tests with the implementation, and keep
src/storage/db.rs under the 1500-line limit without changing unrelated Database
functionality.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f8d41d92-cccc-4b9a-8027-a9fcd757650c
📒 Files selected for processing (11)
CHANGELOG.mddocs/guides/monitoring.mdsrc/admin/metrics_setup.rssrc/command/connection.rssrc/command/server_admin.rssrc/main.rssrc/persistence/recovery.rssrc/storage/db.rstests/cold_shadow_overwrite_resurrection.rstests/cold_shadow_single_shard_tokio.rstests/used_memory_offload_truthful.rs
…task #56) The G2 acceptance run (4 shards, --maxmemory 256MB, 2.6GB dataset, disk-offload enabled) reported INFO used_memory at 406-762MB against the 256MB cap, and worse after a kill-9 restart. A 2-shard/8MB-cap/40k-key macOS repro (tests/used_memory_offload_truthful.rs) instrumented at load, steady state, and post-restart isolated three independent bugs: 1. used_memory was literally process RSS (src/command/connection.rs), not the logical ledger --maxmemory eviction actually gates on. RSS carries allocator overhead, mmap'd cold-read page-cache, the Lua script cache, and the replication backlog -- none of which eviction charges against the cap. INFO now reports the same KV(+ColdIndex)+vector+text+graph used-term ShardDatabases::recompute_elastic_budget already gates on (admin::metrics_setup::logical_used_memory_bytes); used_memory_rss / used_memory_peak still expose the true OS footprint, a new moon_used_memory_bytes Prometheus gauge mirrors the ledger, and MEMORY DOCTOR gained a "gated vs RSS vs outside-the-cap" section. 2. Restart double-loaded every spilled String key into hot RAM (src/persistence/recovery.rs): v3 recovery Phase 3 re-inserted each previously-spilled String entry directly into the hot DashTable (#79-04, predating cold read-through) AND rebuilt a ColdIndex stub for the same key (#80-02) -- nobody removed the first loop when the second, correct mechanism landed. Every value type now recovers as a cold-index-only stub; the first GET lazily promotes and only then charges used_memory, via the same path ordinary cold read-through uses. 3. AOF replay re-hydrated already-cold keys back into hot RAM on every restart (src/main.rs, src/storage/db.rs): fixing #2 exposed this second, independent path -- only surfaces with --appendonly yes AND disk-offload both active. An evicted-and-spilled key gets no AOF DEL record (record_reason_del never fires for a spilled entry), so AOF replay blindly reapplies the key's original SET into hot RAM with zero cold-tier awareness, transiently re-inflating used_memory to ~6x steady state after every restart (self-healing over several eviction ticks, slower the larger the offloaded dataset -- the "got WORSE after restart" symptom). Database::demote_replayed_cold_shadows reconciles this right after AOF replay finishes, before the server accepts connections: any key still present in the (already crash-consistent) ColdIndex at that point is provably redundant with what replay just wrote hot for it, so the redundant hot copy is dropped. Red/green: tests/used_memory_offload_truthful.rs drives all three mechanisms end-to-end (spill past maxmemory, confirm real disk spill, SIGKILL, restart) and asserts used_memory stays within 1.75x maxmemory at steady state AND immediately post-restart. Before (pre-fix baseline binary): steady_max_used=39,632,896 (4.7x the 8MB cap; used == rss, confirming the RSS-as-used_memory bug) -- test fails at the steady-state assertion. After (all three fixes): steady_max_used=8,388,410 (~1.0x cap), post_restart_max_used=8,388,410 (identical to steady state, no post-restart transient at all) -- test passes both assertions, reproducibly across repeated runs. Scope: confined to accounting/reporting (charging, uncharging, INFO/ metrics surfaces, restart re-charge paths) per the task boundary -- spill file format/batching is owned by a separate concurrent effort and was not touched. Gates run on macOS (VM occupied by a concurrent crash-matrix run): - 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 --lib (default features): 4331 passed, 0 failed, 1 ignored - cargo test --lib --no-default-features --features runtime-tokio,jemalloc storage::db::: 35 passed - cargo test --release --test used_memory_offload_truthful -- --ignored: pass (repeated) - cargo test --release --test crash_matrix_per_shard_aof: 3 passed - cargo test --release --test crash_recovery_cold_del_resurrection: 2 passed - cargo test --release --test crash_recovery_disk_offload_no_aof: 1 passed - cargo test --release --test crash_recovery_orphan_sweep_readiness: 1 passed - cargo test --release --test memory_doctor_response: 1 passed - cargo test --test aof_multidb_kill9: 4 passed VM-scale validation still needed: re-run the original G2 acceptance scenario (4 shards, 2.6GB dataset, --maxmemory 256MB) on moon-dev once free, to confirm used_memory stays truthful at that scale and that the post-restart AOF-replay-demotion pass completes promptly on a much larger cold index / AOF incr log than this repro's 40k keys. author: Tin Dang <tindang.ht97@gmail.com>
…_memory offload fix
Adversarial review of fix/t56-used-memory-offload flagged three gaps before
this branch could ship: a critical stale-data resurrection bug, a recovery
path where the new demotion logic was never invoked, and an undocumented
used_memory parity gap. All three are fixed here.
- CRITICAL: Database::set() (src/storage/db.rs) now clears a key's
ColdIndex shadow the instant a second write to that key is observed
(InsertOrUpdate::Updated). Without this, a key spilled at v1 and then
live-overwritten to v2 (no re-eviction before a crash) would have its
hot v2 wrongly dropped by demote_replayed_cold_shadows on restart,
resurrecting stale v1. Provably safe because AOF replay always starts
from an empty DashTable, so a key's first replayed write is always
Inserted (left alone) and any later write during that replay can only
be Updated if the AOF recorded a write after the one that got spilled.
- HIGH: recover_shard_v3_pitr's Phase 4b appendonly.aof fallback
(src/persistence/recovery.rs) is the ONLY KV replay path under
runtime-tokio + --shards 1 (no AofManifest exists there), and it never
called demote_replayed_cold_shadows. It now does, closing the gap for
that runtime/shard combination.
- MEDIUM: logical_used_memory_bytes() (src/admin/metrics_setup.rs) and
the moon_used_memory_bytes gauge now include the Lua script cache and
replication backlog, matching real Redis's used_memory semantics
(allocator-attributed, not eviction-reclaimable). Both terms were
already tracked as separate moon_memory_bytes{kind=...} gauges, so this
costs no new instrumentation. The actual --maxmemory eviction gate
(ShardDatabases::recompute_elastic_budget) is unchanged and narrower.
MEMORY DOCTOR (src/command/server_admin.rs) and
docs/guides/monitoring.md now show three distinct figures: elastic
budget, used_memory (reported), and RSS.
New tests (all RED before their respective fix, GREEN after):
- storage::db::tests::test_second_write_invalidates_cold_shadow (unit)
- tests/cold_shadow_overwrite_resurrection.rs
(overwritten_cold_key_returns_new_value_after_crash)
- tests/cold_shadow_single_shard_tokio.rs
(single_shard_overwritten_cold_key_returns_new_value_after_crash, run
against a runtime-tokio+jemalloc binary; uses SETEX per the
monoio-write-gate/inline-SET dispatch gotcha)
Verified: cargo fmt --check, cargo clippy --tests -- -D warnings (default
features), cargo clippy --no-default-features --features
runtime-tokio,jemalloc --tests -- -D warnings, cargo test --lib (4332
passed), plus the full existing disk-offload/crash-recovery integration
suite (used_memory_offload_truthful, crash_recovery_cold_del_resurrection,
crash_matrix_per_shard_aof, memory_doctor_response, aof_multidb_kill9) —
all green with no regressions.
author: Tin Dang
ae45e2f to
fc56c7b
Compare
…evidence (#356) Re-ran the G2 acceptance benchmark (first run 2026-07-13 @ 983952a) on main @ 4dcfd53 with the complete v0.8 storage batch merged (#347 t49 audit, #350 spill batching, #352 crash-matrix CI, #349 used_memory truth, #353 vec-merge backoff). moon-dev VM, real-disk btrfs --dir, appendonly=yes, 4 shards, 256MB cap, 260K x 10KB (2.6GB = 10x), runner verified idle for all timed phases. Results vs baseline: - Spill files for the full 2.6GB: ~236,000 -> 840 (~280x, PR #350). - used_memory: 406-762MB (RSS masquerading as the ledger) -> 1.00x cap at steady state; post-restart transient (AOF replay re-heats keys) drains to 255MB (< cap) within 5s and holds — demote pass logged "demoted 83,788 AOF-replay hot shadow(s)" (task #56, PR #349). - Cold GET during active spill flood: worst 1,910ms -> 205ms (9.3x). Task #59 (per-shard read-vs-spill-writer fairness) remains open. - kill-9 restart-to-PONG: 16.9s, decomposed as ~2s/shard cold-index rebuild (179K entries) + ~9.4s AOF incr replay (340K cmds / 3.3GB, no rewrite had fired). Criterion "readiness not O(spilled-keys)" holds — the cost is AOF-sized, the standard Redis-style bound; file count is out of the boot path. - Integrity: 497/500 at first read (3 read-side artifacts during the post-replay drain), 500/500 on rediff after settle. Zero acked loss. New finding filed as #355: DBSIZE counts only resident keys under disk-offload (24,275 reported vs ~164K logical) — pre-existing semantics gap, made visible by this investigation, non-blocking. Also updates PRODUCTION-CONTRACT.md: CRASH-02 row (matrix 37->46 cells, all green ungated, scheduled CI via PR #352), MEM-10X-01 row (this re-run's evidence), and a 2026-07-16 audit-trail entry. author: Tin Dang <tindang.ht97@gmail.com> Co-authored-by: Tin Dang <tindang.ht97@gmail.com>
…ne + 10× RAM datasets (#360) Version 0.7.1 -> 0.8.0 (Cargo.toml + Cargo.lock), CHANGELOG [0.8.0] roll-up, RELEASES.md ledger entry, ROADMAP v0.8.0 section flipped to SHIPPED (all 6 items ticked; exit-criterion wording corrected 42 -> 46 cells). Release converts the storage kernel built during the v0.7.0 cycle into a verifiable public claim: - CRASH-02: 46-cell cross-plane kill-9 matrix green ungated, in scheduled CI (nightly full + Saturday ITERS=20 soak, PR #352) - MEM-10X-01: G2 re-run on real disk (docs/perf/2026-07-16-g2-10x-ram-rerun.md, PR #356) — spill 236K -> 840 files (#350), used_memory 1.00x cap with <=5s post-restart drain (#349), cold-GET tail 1910 -> 205ms - Plus: vector auto-merge CPU-livelock fix (#353), CI recall-canary offload (#354), task #49 audit-close (#347) Disclosed follow-ups: task #59 read-vs-spill fairness (205ms tail, goal <10ms), issue #355 DBSIZE resident-only under offload, restart time bounded by AOF-rewrite cadence. Tag gate: crash-matrix nightly full matrix + soak ITERS=20 both dispatched on RC 82752af. author: Tin Dang <tindang.ht97@gmail.com> Co-authored-by: Tin Dang <tindang.ht97@gmail.com>
Pin vendor/moon at e41aa671 — the merge commit of pilotspace/moon PR #351 on top of tag v0.8.0. This is deliberately AHEAD of the tag: the tag alone lacks the DashTable double-NeedsSplit recovery-panic fix (P0, deterministic on our production shard-0 checkpoint under hash skew), which merged immediately after the release cut. v0.8.0 "One Storage Kernel GA" highlights relevant to Lunaris: - kill-9-lossless durability on every plane (KV/vector/FTS/graph/MQ), crash-matrix CI (#352) - GraphUnion merge-recall abort-loop fixed via exponential backoff (#353) — the failure we observed live on 6381 since 2026-07-14 - truthful used_memory under disk-offload (#349), batched spill segments (#350), TLS pemfile -> pki-types Validation (task moon-v080-bump, gates G1-G9): - G7 upgrade-replay PASS: v0.7.1-written data dir replays intact on the v0.8.0 binary across all planes (KV/FT/graph/MQ/temporal) - G8 dashtable A/B PASS: stock binary panics rc=101 on the reconstructed production checkpoint; v0.8.0+fix binary boots it in ~10s with dbsize 219,661 / 376 indexes / writes OK - moondb SDK byte-identical (0.2.1); Cargo.lock moon 0.7.1 -> 0.8.0 - scripts/test-recovery.py gains LUNARIS_TEST_MOON_PORT override (OrbStack ai-proxy Redis wildcard-binds *:6380 and shadows the harness probe port) - docs/durability.md §2.8 + book §3.5 record the GA wave and the pin-ahead-of-tag rationale Live 6381 flip to this binary stays human-gated; until #353 is verified draining the merge backlog, the --max-unflushed-immutable-segments 4096 override stays in the LaunchAgent plist. refs: pilotspace/moon#349 #350 #351 #352 #353, task moon-v080-bump author: Tin Dang
Summary
Task #56 (v0.8 item 2): make
used_memorytruthful under disk-offload. The G2 acceptance run reported 406–762MB against a 256MB cap, worse after kill-9 restart. Three independent bugs, diagnosed via an instrumented 2-shard/8MB/40k-key repro:used_memorywas process RSS, not the logical ledger--maxmemoryeviction gates on. It now reports the same kv(+ColdIndex)+vector+text+graph used-term the elastic budget gates on, plus Lua script cache and replication backlog for Redis parity;used_memory_rss/used_memory_peakunchanged; newmoon_used_memory_bytesPrometheus gauge;MEMORY DOCTORexplains gated-vs-RSS-vs-outside-the-cap.recovery.rsPhase 3): a pre-ColdIndex loop re-inserted every spilled String into the hot DashTable while the newer mechanism rebuilt the cold stub for the same key. All types now recover cold-stub-only with lazy promotion on first read.Database::demote_replayed_cold_shadowsreconciles post-replay, pre-serving.Adversarial review trail (DO-NOT-SHIP → SHIP)
An independent adversarial review found and the fix round closed:
Database::set()clears the shadow onInsertOrUpdate::Updated; sound at replay because replay starts from an empty table — anUpdatedproves a later AOF record exists. Re-review verified coverage across SETRANGE/bit-ops/INCR/GETSET/collection-writes/RESTORE/COPY/Lua/MULTI.recover_shard_v3_pitrPhase 4b.Verification
tests/used_memory_offload_truthful.rs(12 tests): spill → SIGKILL → restart;used_memory ≤ 1.75× maxmemoryat steady state AND post-restarttests/cold_shadow_overwrite_resurrection.rs: overwrite cold key → kill-9 → GET returns the NEW value (RED on pre-fix HEAD)tests/cold_shadow_single_shard_tokio.rs: same, against a real tokio binarycargo test --lib4332 passed, crash-matrix + cold-DEL regression suites greendocs/guides/monitoring.md— what counts against--maxmemoryand what doesn'tSummary by CodeRabbit
Bug Fixes
used_memoryreporting during disk offload to reflect logical memory usage rather than process RSS.MEMORY DOCTORwith clearer memory breakdowns.Documentation
Tests