fix(storage): cold-tier correctness + reliability (DEL/FLUSH resurrection, expired-read leak, crash durability, orphan sweep, liveness)#212
Conversation
…ction, expired-read leak, crash durability, orphan sweep, liveness metrics
Five fixes for the disk-offload (cold tier) path, from the offload
architecture review (tmp/OFFLOAD-COMPRESSION-REVIEW.md), each red/green
TDD-proven:
D1 — DEL/UNLINK/FLUSH cold resurrection (P1 bug). Database::remove and
clear() never touched the ColdIndex, so deleting a spilled key left its
index entry alive and the next GET resurrected the deleted value from the
.mpf heap file; DEL even returned 0 for cold-only keys. remove()/clear()
now drop cold entries (queueing file unlinks), and DEL/UNLINK use a new
remove_counting_cold() so cold-only keys count as removed.
R1 — expired cold reads reclaim their index entry. cold_read returned a
bare Option, so an expired-on-disk entry left its index entry + file
refcount leaked forever. New ColdReadOutcome{Hit,Expired,Miss} lets the
Database::get read-through remove the index entry on Expired only —
transient I/O errors (Miss) never drop a key.
D3 — directory fsync after spill publication. Spill writes fsynced the
file but never data/, so a power loss could vanish the directory entry of
a file the (dir-fsynced) manifest references. Both the batch (tmp+rename)
and single-file spill paths now fsync the data directory.
R3 — startup sweep of crash-orphaned heap files. A crash between spill
write and manifest commit leaked unregistered heap-*.mpf/.tmp files
forever (invisible to the cold index). Recovery now unlinks heap files
not registered in the manifest — only after the manifest opened
successfully, so a corrupt-manifest signal can never trigger deletion.
R5 — spill-thread liveness metrics. A silently-dead spill thread was
observable only as unbounded eviction backlog. The spill loop now stamps
a heartbeat and counts flushed batches; INFO persistence exposes
spill_batches_flushed, spill_completions_dropped,
spill_last_heartbeat_ms.
Tests: 5 new unit tests (3 in cold_read, 1 in kv_spill, 1 in
spill_thread), all red before the fix; full lib suite 3658 pass; clippy
clean on default + tokio,jemalloc feature sets.
author: Tin Dang
PR Summary by QodoFix cold-tier correctness: DEL/FLUSH, expiry cleanup, fsync, sweep, metrics
AI Description
Diagram
High-Level Assessment
Files changed (9)
|
Code Review by Qodo
Context used✅ Compliance rules (platform):
40 rules 1. unwrap() missing allow comment
|
| fn test_spill_thread_liveness_metrics() { | ||
| let tmp = tempfile::tempdir().unwrap(); | ||
| let batches_before = spill_batches_flushed_total(); | ||
|
|
||
| let st = SpillThread::new(9); | ||
| let sender = st.sender(); | ||
| sender | ||
| .send(SpillRequest { | ||
| key: Bytes::from_static(b"liveness_key"), | ||
| db_index: 0, | ||
| value_bytes: Bytes::from_static(b"liveness_value"), | ||
| value_type: ValueType::String, | ||
| flags: 0, | ||
| ttl_ms: None, | ||
| file_id: 1, | ||
| shard_dir: tmp.path().to_path_buf(), | ||
| }) | ||
| .unwrap(); | ||
| drop(sender); |
There was a problem hiding this comment.
1. unwrap() missing allow comment 📘 Rule violation ✧ Quality
New .unwrap() calls were added without the required adjacent // ... justification comment and #[allow(clippy::unwrap_used)] attribute in scope. This violates the project’s unwrap audit policy and can cause lint/audit gate failures.
Agent Prompt
## Issue description
New `.unwrap()` calls were introduced without the required `#[allow(clippy::unwrap_used)]` attribute and a one-line justification comment immediately above it.
## Issue Context
PR Compliance requires every `.unwrap()` to be paired with a local allow attribute and an adjacent justification comment.
## Fix Focus Areas
- src/storage/tiered/spill_thread.rs[589-607]
- src/storage/tiered/cold_read.rs[205-223]
- src/storage/tiered/kv_spill.rs[463-480]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| if orphan { | ||
| match std::fs::remove_file(&path) { | ||
| Ok(()) => { | ||
| removed += 1; | ||
| tracing::info!("cold-tier sweep: removed crash-orphaned {}", name); | ||
| } | ||
| Err(e) => warn!("cold-tier sweep: failed to remove {}: {}", name, e), | ||
| } | ||
| } |
There was a problem hiding this comment.
3. Unlink lacks dir fsync 🐞 Bug ☼ Reliability
sweep_orphan_heap_files() removes crash-orphaned heap-* files but does not fsync the data/ directory afterward, so a crash can lose the unlink metadata and leave the orphaned files behind (undermining the sweep’s durability goal). The repo’s own fsync_directory helper explicitly documents directory fsync as required for unlink metadata durability.
Agent Prompt
### Issue description
`sweep_orphan_heap_files()` unlinks files from `{shard_dir}/data` but never fsyncs the directory. On filesystems that require an explicit directory fsync for metadata durability, the unlink can be lost after a crash, leaving the crash-orphaned files behind.
### Issue Context
The project’s own helper documents directory fsync as required for **rename/unlink metadata durability**.
### Fix Focus Areas
- src/storage/tiered/kv_spill.rs[381-420]
### Suggested fix
After the sweep loop, if `removed > 0`, do a best-effort `fsync_directory(&data_dir)`.
- Keep the sweep’s “never abort recovery” contract by logging fsync errors (warn) instead of returning early.
- Optionally, only fsync when at least one unlink succeeded (to avoid extra syscalls on no-op sweeps).
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
|
Warning Review limit reached
Next review available in: 30 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 (9)
✨ 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 |
Summary
Five fixes for the disk-offload (cold tier) path, from the offload architecture review (
tmp/OFFLOAD-COMPRESSION-REVIEW.md). Each fix was red/green TDD-proven — every new test failed before its fix.D1 — DEL/UNLINK/FLUSH cold resurrection (P1 bug)
Database::remove/clear()never touched theColdIndex, so deleting a spilled key left its index entry alive and the next GET resurrected the deleted value from the.mpfheap file (DEL even returned 0 for cold-only keys).remove()/clear()now drop cold entries (queueing file unlinks via the existing refcount/pending-unlink machinery), and DEL/UNLINK use a newDatabase::remove_counting_cold()so cold-only keys count as removed.R1 — expired cold reads reclaim their index entry
The cold read path returned a bare
Option, so an expired-on-disk entry left its index entry + file refcount leaked forever (nothing else reclaims them — the orphan sweep only checks hot-shadowing). NewColdReadOutcome{Hit, Expired, Miss}letsDatabase::getremove the index entry onExpiredonly; transient I/O errors (Miss) never drop a key.D3 — directory fsync after spill publication
Spill writes fsynced the file but never
data/, so a power loss could vanish the directory entry of a file the (dir-fsynced) manifest references. Both the batch (tmp+rename) and single-file spill paths nowfsync_directory(data/)after publishing.R3 — startup sweep of crash-orphaned heap files
A crash between spill write and manifest commit leaked unregistered
heap-*.mpf/.tmpfiles forever. Recovery now unlinks heap files not registered in the manifest — gated on the manifest opening successfully, so a corrupt-manifest signal can never trigger deletion.R5 — spill-thread liveness metrics
A silently-dead spill thread was observable only as unbounded eviction backlog.
INFO persistencenow exposesspill_batches_flushed,spill_completions_dropped,spill_last_heartbeat_ms(0 = never ran).Testing
cold_read, 1 inkv_spill, 1 inspill_thread), all confirmed RED before the fixescargo fmt --check, clippy-D warningson default andruntime-tokio,jemalloc,cargo test --release(monoio),cargo test --no-default-features --features runtime-tokio,jemalloc(26 suites, 0 failures)Not in this PR (documented follow-ups in tmp/OFFLOAD-COMPRESSION-REVIEW.md)