Skip to content

refactor(storage): unify RDB and spill value codecs into one module (W1)#396

Merged
pilotspacex-byte merged 1 commit into
mainfrom
refactor/w1-value-codec
Jul 20, 2026
Merged

refactor(storage): unify RDB and spill value codecs into one module (W1)#396
pilotspacex-byte merged 1 commit into
mainfrom
refactor/w1-value-codec

Conversation

@pilotspacex-byte

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

Copy link
Copy Markdown
Contributor

Summary

First PR of the storage-layer unification wave (W1 of W1–W6, plan reviewed 2026-07-20).

The value-body wire format existed as two hand-maintained copiesrdb::write_entry's value section and kv_serde::serialize_collection (~150 lines each, "format identical to rdb.rs" kept true by discipline only). This PR makes src/storage/value_codec.rs the single implementation; both call sites delegate.

  • encode_value_body / decode_value_body + HashTtlTrailer::{Absent,Required,Lenient} modeling the three container semantics (RDB v1 / RDB v2 / unversioned spill blobs with legacy pre-trailer forgiveness).
  • value_type_of exists once — kv_spill re-exports it; the two inline copies in eviction.rs now delegate.
  • DoS hardening: spill decode was missed by the RDB count-validation fix — corrupt .mpf length fields went straight into Vec::with_capacity. Every count is now validated before allocation on both planes.
  • Fail-closed scores (red-first test): an unparseable sorted-set listpack score refuses to encode (ValueCodecError::CorruptScore) instead of silently persisting 0.0 on both planes.

Format compatibility

Byte-identical by construction, pinned by golden byte-spec tests that rebuild expected bytes by hand per type (tests pin the FORMAT, not "whatever the encoder does"), plus legacy pre-trailer lenient-decode and oversized-count rejection tests. Existing RDB files and spill pages load unchanged.

Testing

  • macOS lib suite: 4419 passed
  • Linux VM (moon-dev, release-fast) lib suite: 4441 passed
  • clippy -D warnings on default AND runtime-tokio,jemalloc feature sets; fmt clean
  • 12 new codec tests (golden spec ×6, trailer modes, DoS rejection, canonical compact-variant encoding, fail-closed score)

Follow-up (W2): the three spill call sites still unwrap_or_default() on encode failure — W2 turns that into abort-eviction fail-closed as part of the single-spill-pipeline unification.

Summary by CodeRabbit

  • Changed

    • Unified value body serialization/deserialization shared across RDB snapshots and disk-offloaded spill blobs, keeping the same on-disk and wire formats.
    • Improved legacy hash TTL/trailer handling during data loading for better compatibility.
  • Bug Fixes

    • Hardened decoding to validate element counts before allocating memory, mitigating allocation-based denial-of-service on both RDB and spill paths.
    • Corrupt sorted-set listpack scores now fail closed (no longer written as 0.0), preventing silent data corruption.

@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 20, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 26518dfa-f73d-48d5-a2d8-84e689d9f896

📥 Commits

Reviewing files that changed from the base of the PR and between 81599ed and 3f4fe04.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • src/persistence/rdb.rs
  • src/storage/eviction.rs
  • src/storage/mod.rs
  • src/storage/tiered/kv_serde.rs
  • src/storage/tiered/kv_spill.rs
  • src/storage/value_codec.rs

📝 Walkthrough

Walkthrough

Changes

Shared value serialization

Layer / File(s) Summary
Codec contract and implementation
src/storage/mod.rs, src/storage/value_codec.rs
Adds the shared value-body wire-format codec, canonical type mapping, hash trailer modes, count validation, corruption errors, canonical encoding, and compatibility tests.
RDB codec integration
src/persistence/rdb.rs
Routes RDB value type selection and non-string encoding/decoding through the shared codec, including version-dependent hash trailer handling and corruption error conversion.
Spill codec integration
src/storage/tiered/kv_serde.rs, src/storage/tiered/kv_spill.rs, src/storage/eviction.rs, CHANGELOG.md
Routes spill serialization and type mapping through the shared codec, preserves lenient legacy hash decoding, tests corrupt sorted-set handling, and documents the changes.

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

Sequence Diagram(s)

sequenceDiagram
  participant RDBWriter
  participant ValueCodec
  participant SpillSerializer
  participant SpillDecoder
  RDBWriter->>ValueCodec: encode_value_body
  ValueCodec-->>RDBWriter: encoded value body
  SpillSerializer->>ValueCodec: encode_value_body
  ValueCodec-->>SpillSerializer: encoded spill body
  SpillDecoder->>ValueCodec: decode_value_body with lenient trailer mode
  ValueCodec-->>SpillDecoder: decoded RedisValue
Loading

Possibly related PRs

  • pilotspace/moon#270: Updates overlapping durable no-AOF eviction and async spill paths in src/storage/eviction.rs.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: unifying the RDB and spill value codecs into one storage module.
Description check ✅ Passed The description covers the summary, compatibility details, testing results, and follow-up notes, though it doesn't fully match the template sections.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/w1-value-codec

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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/persistence/rdb.rs`:
- Around line 1092-1118: The boot-time RDB loading flow still uses
read_entry_zero_copy() instead of the shared value_codec decoder, leaving the
hand-written path inconsistent with read_entry(). Update read_entry_zero_copy()
and its load() call path to use value_codec::decode_value_body() with the
appropriate ValueType and HashTtlTrailer handling, preserving string decoding
and existing error conversion.

In `@src/storage/eviction.rs`:
- Around line 892-898: The eviction and spill paths must fail closed when
serialize_collection returns None. In src/storage/eviction.rs:892-898, update
evict_batch_durable_no_aof to skip and retain the victim while logging the
serialization failure; in src/storage/eviction.rs:1022-1028, make
evict_one_async_spill return without db.remove, matching the try_send failure
path; in src/storage/tiered/kv_spill.rs:146-152, propagate or skip None in
spill_to_datafile instead of writing an empty body.
🪄 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: 2bcaf6de-3790-42bc-acb9-7c76bf68845e

📥 Commits

Reviewing files that changed from the base of the PR and between 39e4c5d and 81599ed.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • src/persistence/rdb.rs
  • src/storage/eviction.rs
  • src/storage/mod.rs
  • src/storage/tiered/kv_serde.rs
  • src/storage/tiered/kv_spill.rs
  • src/storage/value_codec.rs

Comment thread src/persistence/rdb.rs
Comment thread src/storage/eviction.rs
Comment on lines 892 to 898
let (value_type, value_bytes): (ValueType, &[u8]) = match val_ref {
RedisValueRef::String(s) => (ValueType::String, s),
ref other => {
let vt = match other {
RedisValueRef::Hash(_)
| RedisValueRef::HashListpack(_)
| RedisValueRef::HashWithTtl { .. } => ValueType::Hash,
RedisValueRef::List(_) | RedisValueRef::ListListpack(_) => ValueType::List,
RedisValueRef::Set(_)
| RedisValueRef::SetListpack(_)
| RedisValueRef::SetIntset(_) => ValueType::Set,
RedisValueRef::SortedSet { .. }
| RedisValueRef::SortedSetBPTree { .. }
| RedisValueRef::SortedSetListpack(_) => ValueType::ZSet,
RedisValueRef::Stream(_) => ValueType::Stream,
RedisValueRef::String(_) => unreachable!(),
};
let vt = kv_spill::value_type_of(other);
collection_buf = kv_serde::serialize_collection(other).unwrap_or_default();
(vt, collection_buf.as_slice())
}

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

serialize_collection(...).unwrap_or_default() defeats the new fail-closed codec and can silently drop data. With the codec change, serialize_collection now returns None for an unencodable value (e.g. ValueCodecError::CorruptScore) instead of writing a 0.0 body. unwrap_or_default() converts that None into an empty Vec<u8>, so the corrupt value is spilled as an empty body. In the two eviction paths the hot entry is removed after a "successful" spill, turning a rare in-memory corruption into total data loss on read-back — strictly worse than the prior 0.0-score behavior this PR sought to fix. (PR objectives note these are slated for W2; flagging as the shared blocker.)

  • src/storage/eviction.rs#L892-L898: in evict_batch_durable_no_aof, do not stage/evict the victim when serialize_collection returns None; skip the key (retain hot) and log, mirroring the existing durable-failure retention comments.
  • src/storage/eviction.rs#L1022-L1028: in evict_one_async_spill, bail out (return without db.remove) when serialization returns None, same as the try_send failure path.
  • src/storage/tiered/kv_spill.rs#L146-L152: in spill_to_datafile, propagate/skip on None instead of spilling an empty body into the cold index.
📍 Affects 2 files
  • src/storage/eviction.rs#L892-L898 (this comment)
  • src/storage/eviction.rs#L1022-L1028
  • src/storage/tiered/kv_spill.rs#L146-L152
🤖 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 892 - 898, The eviction and spill paths
must fail closed when serialize_collection returns None. In
src/storage/eviction.rs:892-898, update evict_batch_durable_no_aof to skip and
retain the victim while logging the serialization failure; in
src/storage/eviction.rs:1022-1028, make evict_one_async_spill return without
db.remove, matching the try_send failure path; in
src/storage/tiered/kv_spill.rs:146-152, propagate or skip None in
spill_to_datafile instead of writing an empty body.

…umented module (W1)

The value-body wire format existed as two hand-maintained copies:
rdb::write_entry's value section and kv_serde::serialize_collection
(whose doc comment admitted "format identical to rdb.rs" — a promise
kept by discipline, not by the compiler). The decode side was likewise
duplicated. Every format evolution (e.g. the v2 hash-TTL trailer) had
to be applied twice; a missed edit silently corrupts the other plane.

This PR makes src/storage/value_codec.rs the single implementation:

- encode_value_body/decode_value_body cover all collection types;
  strings remain container-framed (StringHasNoBody guard so a caller
  can never persist an empty body for a string).
- HashTtlTrailer::{Absent,Required,Lenient} models the three container
  semantics (RDB v1, RDB v2, unversioned spill blobs with legacy
  pre-trailer forgiveness) that previously lived as divergent ad-hoc
  logic in the two decoders.
- value_type_of (RedisValueRef -> ValueType) exists once; kv_spill
  re-exports it and the two inline copies in eviction.rs delegate.
- Decode hardening: every count field is validated against remaining
  input BEFORE Vec::with_capacity (the RDB-DoS fix had missed the
  spill decoder — corrupt .mpf pages could drive multi-GB allocations).
- Fail-closed scores: an unparseable sorted-set listpack score now
  refuses to encode (ValueCodecError::CorruptScore) instead of silently
  writing 0.0 on both planes (red-first test).

Format compatibility is pinned by golden byte-spec tests that rebuild
the expected bytes by hand per type (so the tests pin the FORMAT, not
"whatever the encoder does"), plus legacy pre-trailer lenient-decode
and oversized-count rejection tests. Existing RDB files and spill
pages load unchanged; full lib suite 4419 green.

Groundwork for W2 (single spill pipeline): the three spill call sites
still map encode errors to unwrap_or_default(); W2 turns that into
abort-eviction fail-closed.

author: Tin Dang
@TinDang97
TinDang97 force-pushed the refactor/w1-value-codec branch from 81599ed to 3f4fe04 Compare July 20, 2026 05:33
@pilotspacex-byte
pilotspacex-byte merged commit 21fccea into main Jul 20, 2026
7 of 8 checks passed
TinDang97 added a commit that referenced this pull request Jul 20, 2026
…the durable batch writer (W2)

Moon had two spill families: the async/batch path (SpillThread
flush_buffer: shared multi-entry files, one manifest commit per file)
and a sync per-victim path (kv_spill::spill_to_datafile: ONE
single-entry file + ONE shard-thread-blocking durable manifest commit
PER KEY) used by the background eviction tick, the memory-pressure
cascade's sync fallback, and db_quota. The two evolved independently
and that seam already bit three times: the task #34 plain-drop
misclassification, the task #45 is_string gate, and the #139 spill-test
blindness were all divergence bugs between these copies.

This PR makes the batch machinery the ONE spill implementation:

- select_victim (policy dispatch) and build_spill_payload (victim
  serialization) exist once — previously copy-pasted at all three
  entry points.
- evict_batch_durable_no_aof is renamed evict_batch_durable and now
  backs every SpillContext sync path: the sync wrapper reclaims its
  whole deficit as batches; evict_one_with_spill's spill arm is a
  deficit-1 delegation. Write-then-durable-then-drop ordering, D1
  remove-before-cold-index, and fail-closed retention on I/O error are
  inherited, not re-implemented.
- Red-first: sync-evicting 40 keys now produces 1 shared .mpf file
  (was 40 files + 40 blocking manifest fsync round-trips).
- Fail-closed serialization (red-first): all three sites previously
  did serialize_collection(..).unwrap_or_default() — a corrupt value
  was "spilled" as an EMPTY body and evicted (silent durable data
  loss on reload). Unserializable victims are now retained hot,
  skipped, and loudly logged, on the sync, batch, and async paths.
- evict_batch_durable now calls record_eviction() per removed key
  (the batch path previously under-counted the eviction metric).
- kv_spill::spill_to_datafile is no longer a production eviction path;
  it remains the single-entry primitive test fixtures use (same page
  layout the batch salvage path emits).

Gates: lib suite 4421 green (macOS + Linux VM release-fast); eviction
pin tests (spill-I/O-failure OOM retention, non-string durable spill,
db_index restamp) all green; offload/cold integration suites + crash
matrix on Linux VM; clippy -D warnings on default and tokio,jemalloc
feature sets.

Stacked on refactor/w1-value-codec (PR #396).

author: Tin Dang
pilotspacex-byte added a commit that referenced this pull request Jul 20, 2026
…the durable batch writer (W2) (#397)

Moon had two spill families: the async/batch path (SpillThread
flush_buffer: shared multi-entry files, one manifest commit per file)
and a sync per-victim path (kv_spill::spill_to_datafile: ONE
single-entry file + ONE shard-thread-blocking durable manifest commit
PER KEY) used by the background eviction tick, the memory-pressure
cascade's sync fallback, and db_quota. The two evolved independently
and that seam already bit three times: the task #34 plain-drop
misclassification, the task #45 is_string gate, and the #139 spill-test
blindness were all divergence bugs between these copies.

This PR makes the batch machinery the ONE spill implementation:

- select_victim (policy dispatch) and build_spill_payload (victim
  serialization) exist once — previously copy-pasted at all three
  entry points.
- evict_batch_durable_no_aof is renamed evict_batch_durable and now
  backs every SpillContext sync path: the sync wrapper reclaims its
  whole deficit as batches; evict_one_with_spill's spill arm is a
  deficit-1 delegation. Write-then-durable-then-drop ordering, D1
  remove-before-cold-index, and fail-closed retention on I/O error are
  inherited, not re-implemented.
- Red-first: sync-evicting 40 keys now produces 1 shared .mpf file
  (was 40 files + 40 blocking manifest fsync round-trips).
- Fail-closed serialization (red-first): all three sites previously
  did serialize_collection(..).unwrap_or_default() — a corrupt value
  was "spilled" as an EMPTY body and evicted (silent durable data
  loss on reload). Unserializable victims are now retained hot,
  skipped, and loudly logged, on the sync, batch, and async paths.
- evict_batch_durable now calls record_eviction() per removed key
  (the batch path previously under-counted the eviction metric).
- kv_spill::spill_to_datafile is no longer a production eviction path;
  it remains the single-entry primitive test fixtures use (same page
  layout the batch salvage path emits).

Gates: lib suite 4421 green (macOS + Linux VM release-fast); eviction
pin tests (spill-I/O-failure OOM retention, non-string durable spill,
db_index restamp) all green; offload/cold integration suites + crash
matrix on Linux VM; clippy -D warnings on default and tokio,jemalloc
feature sets.

Stacked on refactor/w1-value-codec (PR #396).

author: Tin Dang

Co-authored-by: Tin Dang <tindang.ht97@gmail.com>
@TinDang97
TinDang97 deleted the refactor/w1-value-codec branch July 20, 2026 05:38
TinDang97 added a commit that referenced this pull request Jul 20, 2026
…L fidelity (#404)

Patch release rolling up the storage-unification stacked train
(#396#401) and the follow-up db.rs directory split (#403).

Correctness: millisecond-TTL keys no longer expire up to 999 ms early
(CompactEntry stored expiry as ms/1000-floored seconds; now absolute
Unix ms end-to-end, exact PTTL readback, exact-ms RDB round-trips).

Unification (behavior-preserving, pinned by golden byte-spec tests):
one value codec behind RDB/spill/kv_serde with fail-closed corrupt
decode + DoS count guards on every path; one spill pipeline — sync
eviction batches victims into shared files (~N/256 manifest fsyncs
instead of N per-key) and retains unserializable victims fail-closed;
one eviction entry point (evict_to_budget + EvictionRun/EvictionSink,
was 13 variants / 5 reclaim-loop copies); one typed-accessor skeleton
(storage::db_kind static-dispatch markers); AofShardManifest rename;
db.rs split into db/{mod,hash_ttl,kv_ops,accessors}.rs.

Validation: same-VM A/B v0.8.1-vs-main — p=1 parity-or-better,
eviction-plain faster every paired round, spill +3%, p=16 within
noise. Release gate: crash-matrix nightly full matrix + ITERS=20
soak dispatched on the RC, green before tag (soak-first-then-tag).

Rolls CHANGELOG [Unreleased] into [0.8.2], bumps Cargo.toml/lock,
adds the RELEASES.md row, updates the README milestone table.

author: Tin Dang
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants