Skip to content

perf(storage): SCAN cold-plane range resume — hash-ordered cold index (#368) - #382

Merged
pilotspacex-byte merged 2 commits into
mainfrom
perf/scan-368-cold-range
Jul 17, 2026
Merged

perf(storage): SCAN cold-plane range resume — hash-ordered cold index (#368)#382
pilotspacex-byte merged 2 commits into
mainfrom
perf/scan-368-cold-range

Conversation

@pilotspacex-byte

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

Copy link
Copy Markdown
Contributor

Summary

Completes the two-plane O(COUNT) SCAN page walk (#368). PR #380 made the hot plane a hash-range segment walk; this makes the cold plane (spilled keys under disk-offload) range-resume too.

  • ColdIndex's primary map becomes a BTreeMap keyed by (scan_hash48(key), key) — the identical 48-bit cursor hash the hot plane uses. New ColdIndex::range_from + Database::cold_only_keys_from seek to the cursor in O(log n); scan_core takes the first COUNT live candidates (ascending order ⇒ exactly the smallest) instead of filtering every spilled key on every page.
  • The cold-only liveness predicate (TTL + hot-shadow check) is extracted into one shared helper so the full-walk (KEYS/RANDOMKEY) and range-resume paths cannot diverge on the two-plane partition invariant (SCAN/KEYS/RANDOMKEY enumerate the hot plane only under disk-offload (spilled keys invisible) #364).
  • Trade-offs (deliberate): lookup/remove pay an O(log n) descent via an equal-hash range probe — they sit on disk-read-through/promotion/sweep paths where the descent is noise next to the I/O. The ordered map replaces the hash map (no sidecar): no duplicate per-entry RAM at G2 scale and no dual-structure drift risk (the map stays private; every mutation funnels through ColdIndex methods).
  • Public API, recovery rebuild, sweep semantics, and the file_refs reclamation spine unchanged.

Test plan

  • New unit tests: range_from hash-order/resume/exactly-once (200 entries paged); equal-hash-probe lookup/remove independence; SCAN paged drain across planes exactly-once (50 hot + 40 cold + 10 both-planes @ COUNT 7); cold spill churn between pages keeps stable keys
  • Full lib suite 4389 passed; tokio-feature suite 3566 passed; clippy -D warnings both feature sets
  • VM offload integration suites (all green, incl. --ignored real-server cells): scan_offload_visibility, dbsize_offload_logical, cold_collection_visibility, cold_orphan_sweep, cold_shadow_overwrite_resurrection, crash_recovery_disk_offload_no_aof, crash_recovery_spill_batch_kill9, crash_recovery_cold_del_resurrection
  • VM e2e with real spilling (2 shards, 60k×2KB behind a 64MB cap, allkeys-lru, 148 spill files): DBSIZE = 60000 exactly; SCAN drained 60000/60000 in 121 pages on the majority-spilled keyspace; spilled read-through intact

Refs #368

Summary by CodeRabbit

  • Performance

    • Improved cold-plane SCAN by resuming from the current cursor instead of re-filtering the full cold dataset per page.
    • Cold entries are now selected in hash-ordered, bounded fashion consistent with hot-plane traversal for better per-page seeking.
  • Bug Fixes

    • Fixed edge cases around equal-hash groups, cold “extra emission,” and cursor wrap behavior during multi-page scans.
    • Ensured expired and shadowed cold entries are handled consistently across full walks and cursor-based resumes.
  • Tests

    • Added coverage for multi-page draining correctness and “cold churn” behavior during active scans.

…#368)

Completes the two-plane O(COUNT) SCAN page walk. PR #380 made the hot
plane a hash-range segment walk; this change gives the cold plane
(spilled keys under disk-offload) the same property.

ColdIndex's primary map becomes a BTreeMap keyed by
(scan_hash48(key), key) — the identical 48-bit cursor hash the hot
plane uses (xxh64 top 48 bits). The new `ColdIndex::range_from` +
`Database::cold_only_keys_from` seek to the cursor in O(log n) and
yield ascending live cold-only candidates; scan_core takes the first
COUNT (ascending order makes them exactly the smallest cold
candidates) instead of filtering every spilled key on every page. The
pathological all-one-hash branch range-scans just the equal-hash
group. The cold-only liveness predicate (TTL from the cached
ColdLocation + hot-shadow check) is extracted into one shared helper
so the full-walk (KEYS/RANDOMKEY) and range-resume paths cannot
diverge on the two-plane partition invariant (#364).

Trade-offs, deliberate:
- lookup/remove pay an O(log n) tree descent instead of an O(1) hash
  probe via an equal-hash range probe. These sit on disk-read-through,
  promotion, DEL-of-cold-key, and sweep paths where the descent is
  noise next to the I/O they front; no command hot path touches them.
- The ordered map REPLACES the hash map rather than sidecar-ing it:
  no duplicate per-entry Bytes, per-entry RAM stays comparable at the
  G2 tens-of-millions-of-entries scale, and there is no dual-structure
  drift risk — every mutation still funnels through ColdIndex's own
  methods (map stays private).
- BTreeMap cannot borrow-match a (u64, &[u8]) probe against a
  (u64, Bytes) key, so remove_raw finds the owned key via the
  equal-hash range (Bytes clone = refcount bump, no data copy).

Public ColdIndex API, recovery rebuild, sweep semantics, and the
file_refs reclamation spine are unchanged. sweep_expired's batch-cap
comment updated: iteration is now deterministic (hash, key) order;
capped-out entries are still never permanently skipped because
reclaimed entries stop occupying batch slots.

Red/green coverage: range_from hash-order/resume/exactly-once (200
entries, paged), equal-hash-probe lookup/remove independence, SCAN
paged drain across planes exactly-once (50 hot + 40 cold + 10
both-planes at COUNT 7), cold spill churn between pages keeps stable
keys. All existing cold_enumeration, cold_index, and SCAN semantics
tests green on the new path.

Refs #368
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 17, 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: f383a36c-28a0-4c4c-81f2-092320c27290

📥 Commits

Reviewing files that changed from the base of the PR and between 9da2c32 and 9ee0933.

📒 Files selected for processing (1)
  • src/command/key.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/command/key.rs

📝 Walkthrough

Walkthrough

The cold index now uses hash-ordered storage to support SCAN cursor range resume. Cold-only enumeration shares liveness checks, and SCAN consumes bounded cold ranges across pages. Tests cover cross-plane draining, churn, ordering, and hash collisions.

Changes

SCAN cold-plane range resume

Layer / File(s) Summary
Ordered cold index and collision-safe access
src/storage/tiered/cold_index.rs
ColdIndex stores entries by (scan_h48, key), supports ordered range_from traversal, and uses equal-hash probes for lookup and removal.
Cold index maintenance adaptation
src/storage/tiered/cold_index.rs
Orphan and TTL sweeps adapt to composite map keys and probe-based removal; tests cover ordered draining and equal-hash behavior.
Shared cold-only enumeration
src/storage/db.rs
Cold-only iterators use a shared predicate for expiration and hot-key shadowing, with cursor-based range seeking.
SCAN cursor integration and validation
src/command/key.rs, CHANGELOG.md
SCAN uses bounded cursor-resumed cold candidates, handles equal-hash continuation, and adds cross-plane and churn tests. The changelog records the optimization.

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

Possibly related issues

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant SCAN
  participant Database
  participant ColdIndex
  SCAN->>Database: cold_only_keys_from(cursor, now_ms)
  Database->>ColdIndex: range_from(cursor)
  ColdIndex-->>Database: ordered cold entries
  Database-->>SCAN: bounded cold candidates
  SCAN->>Database: cold_only_keys_from(h_last, now_ms)
  Database-->>SCAN: equal-hash continuation candidates
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: hash-ordered cold-plane range resume for SCAN.
Description check ✅ Passed Contains a clear summary, trade-offs, and test results, with only minor deviation from 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 perf/scan-368-cold-range

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.

🧹 Nitpick comments (1)
src/storage/tiered/cold_index.rs (1)

514-530: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Make TTL sweep resumable to avoid O(N²) scanning stalls.

The documentation correctly notes that restarting from the front eventually reclaims all expired entries. However, this causes an O(N²) worst-case scanning pattern.
If there are millions of unexpired keys at the front of the ordered map followed by a cluster of expired keys, every sweep tick will scan all the unexpired keys, find max_batch expired ones, and break. On the next tick, it starts at the beginning again, rescanning the exact same unexpired keys.

Since self.map is an ordered BTreeMap, you can completely eliminate this overhead by storing a sweep_cursor: u64 field in ColdIndex and resuming the iterator using range.

⚡ Proposed fix

First, add a cursor field to the struct (e.g., sweep_cursor: u64) and initialize it to 0. Then update the sweep loop:

-        for (k, loc) in self.map.iter() {
+        for (k, loc) in self.map.range((self.sweep_cursor, Bytes::new())..) {
             if loc.ttl_ms.is_some_and(|t| now_ms > t) {
                 if expired_keys.len() >= max_batch {
                     more_remain = true;
+                    self.sweep_cursor = k.0;
                     break;
                 }
                 expired_keys.push(k.1.clone());
             }
         }
+
+        if !more_remain {
+            self.sweep_cursor = 0;
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/storage/tiered/cold_index.rs` around lines 514 - 530, Make the TTL sweep
resumable by adding and initializing a sweep_cursor field on ColdIndex, then use
self.map.range starting from that cursor instead of restarting iteration at the
beginning. Advance the cursor as entries are examined, preserve max_batch and
expiration filtering, and reset or wrap it appropriately when the ordered
keyspace is exhausted so all entries continue to be considered.
🤖 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/storage/tiered/cold_index.rs`:
- Around line 514-530: Make the TTL sweep resumable by adding and initializing a
sweep_cursor field on ColdIndex, then use self.map.range starting from that
cursor instead of restarting iteration at the beginning. Advance the cursor as
entries are examined, preserve max_batch and expiration filtering, and reset or
wrap it appropriately when the ordered keyspace is exhausted so all entries
continue to be considered.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 41a520ba-7470-4527-958e-e866b0a51e97

📥 Commits

Reviewing files that changed from the base of the PR and between 1395fa4 and 9da2c32.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • src/command/key.rs
  • src/storage/db.rs
  • src/storage/tiered/cold_index.rs

…tarting

Review follow-up on #382: the pathological all-one-hash branch advanced
the cursor to h_last + 1; at the exact 48-bit maximum that value (2^48)
is masked back to 0 by the cursor clamp on the next call — an infinite
scan restart. Nothing can hash above the max, so the scan is complete:
return cursor 0. Not practically constructible (needs a full page of
keys at the exact max hash); documented in-line, no test.

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

Copy link
Copy Markdown
Contributor Author

Adversarial review (independent agent, 7 attack vectors incl. two empirical refutation attempts): no defects confirmed. Highlights: BTreeMap::insert verified to keep the pre-existing key on byte-equal overwrite (allocator-instrumented standalone repro), and per-entry RAM measured at 1M entries: 173.2 B/entry (BTreeMap) vs 200.3 B/entry (HashMap) — the ordered index is actually smaller. Boundary/deferral logic hand-traced incl. equal-hash groups straddling pages. One theoretical flag folded into 9ee0933: a full page of keys at the exact 48-bit max hash would have made h_last + 1 mask back to 0 and restart the scan forever; now terminates (nothing can hash above the max).

@pilotspacex-byte
pilotspacex-byte merged commit 6f9b44a into main Jul 17, 2026
8 checks passed
@TinDang97
TinDang97 deleted the perf/scan-368-cold-range branch July 17, 2026 17:24
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