Skip to content

fix(storage): reclaim warm-tier staging directories instead of leaking them (#435) - #436

Merged
TinDang97 merged 1 commit into
mainfrom
fix/warm-tier-staging-leak
Aug 6, 2026
Merged

fix(storage): reclaim warm-tier staging directories instead of leaking them (#435)#436
TinDang97 merged 1 commit into
mainfrom
fix/warm-tier-staging-leak

Conversation

@TinDang97

@TinDang97 TinDang97 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

What

Found on a live instance: a 27 GB data directory against 2.53 GB of used_memory. 20 GB of it was 14,499 orphaned .segment-*.staging directories (99% of the vector store was abandoned scratch) vs 174 MB in the 94 real segments. All were written in a single three-minute window two days earlier and had already survived a restart. The dot prefix kept them out of ls and every vectors/* glob, so nothing surfaced them.

Root cause

transition_to_warm has ~10 fallible steps between create_dir_all(&staging) and the final rename. Every ? in that stretch leaked the whole directory.

Fix (two guards, because there are two ways to orphan)

  1. StagingGuard — a Drop guard armed at creation, disarmed right after the rename, so every early return in that stretch cleans up. A guard (rather than cleanup at each ?) precisely because the leak came from the paths nobody remembered to annotate. Cleanup is best-effort so it can't mask the error that caused the unwind.
  2. sweep_orphan_staging — called from recover_shard_v3_pitr before the manifest scan, for orphans no in-process guard can cover (kill -9 between manifest commit and rename, or anything left by a pre-guard build).

transition_to_warm also now removes a stale staging dir for the same id before creating it, matching the sibling writer in vector/persistence/segment_io.rs.

Safety

.staging paths are produced in exactly one place and consumed by nothing — every reader opens the final segment-{id} name — so a staging dir is unreachable the moment it is not mid-write. Real segment-* dirs don't match the pattern. A dangling manifest entry (commit succeeded, rename failed) was already dangling before this change and recovery already warns-and-skips it.

Tests (red/green)

  • failed_transition_leaves_no_staging_dir — forces the error at the rename (where production actually died; every orphan was fully written and fsynced) via a non-empty destination (ENOTEMPTY). Asserts is_err() first, so the cleanup assertion can't pass vacuously on a successful transition.
  • startup_sweep_removes_orphan_staging_dirs
  • startup_sweep_tolerates_missing_dir

All 12 warm_tier tests pass (fmt + tests green on this main-based branch).

Fixes #435

Summary by CodeRabbit

  • Bug Fixes
    • Improved recovery from failed warm-tier transitions by automatically cleaning up temporary staging data.
    • Removed stale staging data before retrying interrupted operations.
    • Added startup cleanup for orphaned staging directories while preserving valid segment data.
    • Improved handling when expected storage directories are missing.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@TinDang97, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 17 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f387bde8-5424-41bc-8424-b34c4fc822ca

📥 Commits

Reviewing files that changed from the base of the PR and between 360d0d9 and 3d8e378.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • src/persistence/recovery.rs
  • src/storage/tiered/warm_tier.rs
📝 Walkthrough

Walkthrough

Warm-tier transitions now clean staging directories after failures, remove stale same-ID staging data before retries, and disarm cleanup after successful renames. Recovery removes orphaned staging directories during startup.

Changes

Warm-tier staging cleanup

Layer / File(s) Summary
Staging cleanup and transition lifecycle
src/storage/tiered/warm_tier.rs
Adds StagingGuard, removes stale same-ID staging data before retries, and disarms cleanup after a successful rename.
Recovery sweep and validation
src/persistence/recovery.rs, src/storage/tiered/warm_tier.rs, CHANGELOG.md
Recovery sweeps orphaned staging directories. Tests cover failure cleanup, orphan removal, missing directories, and preservation of real segment contents. The changelog records the fixes.

Estimated code review effort: 2 (Simple) | ~15 minutes

Sequence Diagram(s)

sequenceDiagram
  participant transition_to_warm
  participant StagingGuard
  participant filesystem
  participant recovery
  transition_to_warm->>filesystem: Remove stale same-ID staging directory
  transition_to_warm->>StagingGuard: Arm cleanup guard
  transition_to_warm->>filesystem: Rename staging directory to final segment
  transition_to_warm->>StagingGuard: Disarm cleanup
  recovery->>filesystem: Sweep orphaned staging directories
Loading

Possibly related PRs

Suggested reviewers: pilotspacex-byte

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: reclaiming leaked warm-tier staging directories.
Description check ✅ Passed The description clearly explains the problem, root cause, fix, safety considerations, and tests, although it does not use the repository template headings.
Linked Issues check ✅ Passed The changes implement all three requirements in issue #435: RAII cleanup, startup sweeping, and stale same-ID staging removal.
Out of Scope Changes check ✅ Passed The changelog, recovery sweep, warm-tier cleanup, and related tests are all directly within issue #435 scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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 fix/warm-tier-staging-leak

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.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Fix warm-tier staging directory leaks with Drop guard and startup sweep

🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Prevent .segment-*.staging directories from leaking on failed warm-tier transitions.
• Sweep orphaned staging directories during shard recovery to reclaim pre-existing leftovers.
• Add regression tests covering failure cleanup, startup sweeping, and missing-directory tolerance.
Diagram

graph TD
  R["recover_shard_v3_pitr"] --> V[("vectors/ dir")] --> S["sweep_orphan_staging"] --> O[(".segment-*.staging")]
  T["transition_to_warm"] --> V --> G["StagingGuard (Drop)"] --> O
  T --> V --> M["Shard manifest"] --> F[("segment-{id} dir")]
  S --> V
  G --> V
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Inline cleanup at each fallible step
  • ➕ Makes cleanup explicit at each ? site
  • ➕ Avoids adding a new guard type
  • ➖ Easy to miss paths again (the original root cause)
  • ➖ More verbose and harder to keep correct as steps change
2. Use a tempdir-style API for staging (auto-delete on Drop)
  • ➕ Off-the-shelf RAII semantics similar to the guard
  • ➕ Can reduce custom code
  • ➖ Still needs explicit disarm/release semantics after rename
  • ➖ May not align with required on-disk naming/location constraints under vectors/
3. Two-phase commit ordering change (rename before manifest commit)
  • ➕ Avoids the commit-then-rename orphan window entirely
  • ➖ Likely larger behavioral change: readers/recovery may observe partially initialized segments unless additional fencing is added
  • ➖ Higher risk/cross-cutting changes vs the current minimal fix

Recommendation: Keep the PR’s approach (Drop guard + recovery sweep). It directly targets the two orphan scenarios (in-process early return and out-of-process crash/older builds) with minimal behavioral surface area, and the added tests pin the real-world failure mode (rename failure) to prevent regressions.

Files changed (3) +213 / -2

Bug fix (2) +194 / -2
recovery.rsSweep orphan warm-tier staging dirs during shard recovery +9/-0

Sweep orphan warm-tier staging dirs during shard recovery

• Invokes 'sweep_orphan_staging' on the shard 'vectors/' directory before scanning the manifest. This reclaims '.segment-*.staging' leftovers that an in-process guard cannot cover (e.g., crash/kill -9 or pre-fix builds).

src/persistence/recovery.rs

warm_tier.rsAdd staging cleanup guard, startup sweeper, and regression tests +185/-2

Add staging cleanup guard, startup sweeper, and regression tests

• Introduces 'StagingGuard' to automatically remove the staging directory on any early return between staging creation and rename, and disarms it after a successful rename. Adds 'sweep_orphan_staging' to delete orphaned '.segment-*.staging' directories safely, removes stale same-id staging dirs before creating a new one, and adds tests for failure cleanup, sweeping behavior, and missing directory tolerance.

src/storage/tiered/warm_tier.rs

Documentation (1) +19 / -0
CHANGELOG.mdDocument warm-tier staging directory leak fix (#435) +19/-0

Document warm-tier staging directory leak fix (#435)

• Adds a detailed Unreleased changelog entry describing the warm-tier staging leak, its observed production impact, and the two-part remediation (Drop guard + startup sweep).

CHANGELOG.md

@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: 1

🤖 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/storage/tiered/warm_tier.rs`:
- Around line 196-199: Make the transition around manifest.commit,
wal.flush_sync, and std::fs::rename transactional so a rename failure cannot
leave an Active manifest/FileCreate record after StagingGuard removes the staged
data. Restore the manifest and FileCreate state on failure, or adjust recovery
to mark incomplete transitions non-Active while preserving successful renames.
Extend failed_transition_leaves_no_staging_dir to assert the recovered manifest
contains no active entry for the failed segment.
🪄 Autofix

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 Plus

Run ID: a90695c3-bf3d-4a43-bd6e-44f2a9675ec9

📥 Commits

Reviewing files that changed from the base of the PR and between a7b6174 and 6334e5b.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • src/persistence/recovery.rs
  • src/storage/tiered/warm_tier.rs

Comment on lines +196 to +199
// Step 6: Rename staging -> final. The directory now lives under its final
// name, so the guard must not remove it.
std::fs::rename(&staging, &final_dir)?;
staging_guard.disarm();

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

Repair committed metadata when the rename fails.

manifest.commit() at Line 194 and wal.flush_sync() at Line 190 complete before rename() at Line 198. If the rename fails, StagingGuard removes the staged data, but the manifest and WAL still describe an active warm file.

During recovery, src/persistence/recovery.rs Lines 275-293 skip that active entry because segment-{id}/codes.mpf is absent. The failed transition therefore leaves durable metadata that references no recoverable segment.

Add a failure path that restores the manifest and FileCreate state, or redesign the transition and recovery protocol so an incomplete transition cannot remain Active. Extend failed_transition_leaves_no_staging_dir to verify the recovered manifest state.

🤖 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/warm_tier.rs` around lines 196 - 199, Make the transition
around manifest.commit, wal.flush_sync, and std::fs::rename transactional so a
rename failure cannot leave an Active manifest/FileCreate record after
StagingGuard removes the staged data. Restore the manifest and FileCreate state
on failure, or adjust recovery to mark incomplete transitions non-Active while
preserving successful renames. Extend failed_transition_leaves_no_staging_dir to
assert the recovered manifest contains no active entry for the failed segment.

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (1) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Sweep gated by manifest 🐞 Bug ☼ Reliability
Description
recover_shard_v3_pitr calls sweep_orphan_staging only inside the successful
ShardManifest::open branch, so orphan .segment-*.staging directories are not reclaimed when the
manifest is missing or unreadable. This can preserve the disk leak in exactly those degraded
recovery scenarios even though the sweep is designed to be safe on missing directories.
Code

src/persistence/recovery.rs[R271-274]

+            // and to any `vectors/*` glob because of the dot prefix. The
+            // in-process guard covers new failures; this covers orphans from a
+            // kill -9 or an older build.
+            crate::storage::tiered::warm_tier::sweep_orphan_staging(&vectors_dir);
Relevance

●●● Strong

Reliability fix matches PR intent; sweep is safe on missing dirs so moving it out is low-risk.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The recovery path currently performs the sweep only within the manifest_path.exists() +
ShardManifest::open() success block. The sweep implementation itself is explicitly safe to call
when the vectors directory is missing (it returns 0 on read_dir error), so coupling it to
manifest-open success is unnecessary and causes missed cleanup.

src/persistence/recovery.rs[263-275]
src/storage/tiered/warm_tier.rs[65-68]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`recover_shard_v3_pitr` currently invokes `sweep_orphan_staging()` only after confirming the manifest exists and opens successfully. If the manifest is missing/corrupt/unreadable, recovery skips the sweep and `.segment-*.staging` directories can remain indefinitely.

## Issue Context
`sweep_orphan_staging()` is explicitly written to tolerate missing directories by returning `0` when `read_dir(vectors_dir)` fails, so it can be called safely even when there is no manifest to scan.

## Fix Focus Areas
- src/persistence/recovery.rs[263-275]
- src/storage/tiered/warm_tier.rs[65-68]

## Suggested change
Move (or duplicate) the call to `sweep_orphan_staging(&vectors_dir)` so it runs before attempting to open the manifest (or runs even if `ShardManifest::open()` returns `Err`). Keep the manifest-scanning logic conditional on a successful open, but decouple cleanup from manifest readability.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

2. unwrap() lacks justification comments 📘 Rule violation ✧ Quality
Description
New test code introduces multiple .unwrap() calls without the required // ... justification line
and #[allow(clippy::unwrap_used)] in scope. This violates the unwrap-audit policy and can
reintroduce unreviewed panics/ratchet regressions.
Code

src/storage/tiered/warm_tier.rs[R232-235]

+        let tmp = tempfile::tempdir().unwrap();
+        let shard_dir = tmp.path().join("shard-0");
+        let vectors = shard_dir.join("vectors");
+        std::fs::create_dir_all(&vectors).unwrap();
Relevance

● Weak

Close precedent: requests to annotate new test unwraps were rejected in this repo.

PR-#211
PR-#217

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 302083 requires every .unwrap() to be covered by a directly preceding
justification comment and a #[allow(clippy::unwrap_used)] attribute. The newly added tests contain
multiple .unwrap() calls (e.g., tempfile::tempdir().unwrap(), create_dir_all(...).unwrap(),
read_dir(...).unwrap()) without any such allow+comment pair.

Rule 302083: Annotate safe unwrap calls with allow and justification
src/storage/tiered/warm_tier.rs[231-240]
src/storage/tiered/warm_tier.rs[261-275]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New `.unwrap()` calls were added without the required `// <why safe>` comment immediately above a `#[allow(clippy::unwrap_used)]` attribute, as required by the unwrap audit rule.

## Issue Context
These unwraps are in newly added unit tests in `src/storage/tiered/warm_tier.rs`. Even in tests, the rule requires either (a) avoiding unwraps (prefer `Result`-returning tests with `?`), or (b) annotating each unwrap’s containing scope with the required allow+comment pair.

## Fix Focus Areas
- src/storage/tiered/warm_tier.rs[231-276]
- src/storage/tiered/warm_tier.rs[281-313]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context used
✅ Compliance rules (platform): 55 rules

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment on lines +271 to +274
// and to any `vectors/*` glob because of the dot prefix. The
// in-process guard covers new failures; this covers orphans from a
// kill -9 or an older build.
crate::storage::tiered::warm_tier::sweep_orphan_staging(&vectors_dir);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Sweep gated by manifest 🐞 Bug ☼ Reliability

recover_shard_v3_pitr calls sweep_orphan_staging only inside the successful
ShardManifest::open branch, so orphan .segment-*.staging directories are not reclaimed when the
manifest is missing or unreadable. This can preserve the disk leak in exactly those degraded
recovery scenarios even though the sweep is designed to be safe on missing directories.
Agent Prompt
## Issue description
`recover_shard_v3_pitr` currently invokes `sweep_orphan_staging()` only after confirming the manifest exists and opens successfully. If the manifest is missing/corrupt/unreadable, recovery skips the sweep and `.segment-*.staging` directories can remain indefinitely.

## Issue Context
`sweep_orphan_staging()` is explicitly written to tolerate missing directories by returning `0` when `read_dir(vectors_dir)` fails, so it can be called safely even when there is no manifest to scan.

## Fix Focus Areas
- src/persistence/recovery.rs[263-275]
- src/storage/tiered/warm_tier.rs[65-68]

## Suggested change
Move (or duplicate) the call to `sweep_orphan_staging(&vectors_dir)` so it runs before attempting to open the manifest (or runs even if `ShardManifest::open()` returns `Err`). Keep the manifest-scanning logic conditional on a successful open, but decouple cleanup from manifest readability.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@TinDang97
TinDang97 force-pushed the fix/warm-tier-staging-leak branch from 6334e5b to abca63c Compare August 6, 2026 15:10
…g them (#435)

Found on a live instance: a 27 GB data directory against 2.53 GB of
used_memory. 20 GB of it was 14,499 orphaned `.segment-*.staging`
directories, versus 174 MB in the 94 real segments — 99% of the vector
store was abandoned scratch. All were written in a single three-minute
window two days earlier and had already survived a restart. The dot prefix
kept them out of `ls` and out of every `vectors/*` glob, so nothing
surfaced them.

`transition_to_warm` has roughly ten fallible steps between
`create_dir_all(&staging)` and the rename that moves the directory to its
final name. Every `?` in that stretch leaked the whole directory. Two
fixes, because there are two ways to get an orphan:

1. `StagingGuard` — a Drop guard armed at creation and disarmed after the
   rename, so every early return in that stretch cleans up. A guard rather
   than cleanup at each `?` precisely because the leak came from the paths
   nobody remembered to annotate. Cleanup is best-effort so a failure to
   remove cannot mask the error that caused the unwind.

2. `sweep_orphan_staging` — called from `recover_shard_v3_pitr` before the
   manifest scan, for orphans no in-process guard can cover: a kill -9
   between the manifest commit and the rename, or anything left by a build
   that predates the guard.

The sweep is safe by construction: `.staging` paths are produced in exactly
one place and consumed by nothing — every reader and recovery path opens
the final `segment-{id}` name — so a staging directory is unreachable the
moment it is not mid-write. Real `segment-*` directories do not match the
pattern. A dangling manifest entry (commit succeeded, rename failed) was
already dangling before this change and recovery already warns and skips
it; the guard does not make that case worse.

`transition_to_warm` also now removes a stale staging dir for the same id
before creating it, so a retry cannot inherit a previous attempt's partial
files. The sibling writer in `vector/persistence/segment_io.rs` has always
done this; this path did not.

The failure test forces the error at the RENAME — where production actually
died, since every orphan was fully written and fsynced — via a non-empty
destination (guaranteed ENOTEMPTY). It asserts `is_err()` first: "no
staging dir remains" is also true of a SUCCESSFUL transition, so without
pinning the failure path the cleanup assertion would pass with the guard
deleted.

Fixes #435
author: Tin Dang
@TinDang97
TinDang97 force-pushed the fix/warm-tier-staging-leak branch from abca63c to 3d8e378 Compare August 6, 2026 15:13
@TinDang97
TinDang97 merged commit ab5aa60 into main Aug 6, 2026
1 check was pending
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.

Warm-tier transition leaks its staging directory on failure — 14,499 orphans / 20 GB found on a live instance

1 participant