Skip to content

fix(blob-store): keep undurable frees pending on write rollback - #39

Merged
feichai0017 merged 1 commit into
mainfrom
fix/reserved-slot-release-fences
Aug 7, 2026
Merged

fix(blob-store): keep undurable frees pending on write rollback#39
feichai0017 merged 1 commit into
mainfrom
fix/reserved-slot-release-fences

Conversation

@wchwawa

@wchwawa wchwawa commented Aug 6, 2026

Copy link
Copy Markdown
Member

What changed

release_reserved_slots returns the slots a failed write reserved. It did that
through Manifest::trim_trailing_free_slots, whose first statement is
publish_pending_free_slots() — so a single failed write also promoted the
entire pending_free_slots queue into the reusable pool.

Those slots are not the caller's to publish. They belong to other guids'
superseded generations: the last durable manifest still references them, and no
reader has been drained off them. Publishing them there bypasses both fences
flush_locked applies (persist_pending_deltas, then enter_slot_write()).

Consequence, with no concurrency required:

  1. write_blob(A, v1); flush() → durable manifest maps A → slot 0
  2. write_blob(A, v2) → shadow slot 1 published in memory, pending_free = [0], no flush
  3. any write whose pwrite/ensure_data_capacity fails → rollback drains [0] into the allocator
  4. next write pops slot 0 and overwrites it
  5. crash before the next successful flush → reopen reads the durable manifest, which still says A → slot 0, and read_blob(A) returns the other blob's frame

Nothing downstream detects it: there is no frame checksum, and BlobHeader::blob_guid
is written (set_frame_blob_guid) but never read back anywhere in the crate.

delete_blob also feeds pending_free_slots, so a delete plus one failed write is
enough — the trigger is any genuine device error inside the store (ENOSPC, EIO, EDQUOT).

The fix splits the trim in two. trim_trailing_reusable_slots lowers the high-water
mark over already-reusable slots only; trim_trailing_free_slots keeps its
publish-then-trim behaviour for flush_locked and vacuum. The rollback path uses
the former.

Only the I/O-error path changes: superseded slots stay pending until the next
successful flush, which is the only place both fences hold.

Test plan

New regression test rolling_back_reserved_slots_keeps_undurable_frees_pending,
the error-path sibling of the existing replaced_slot_is_reused_only_after_manifest_flush.
Verified it fails on the parent commit and passes here:

--- before ---
assertion `left == right` failed: rollback must not publish slots the
durable manifest still references
  left: 0
 right: 1
--- after ---
test ... ok

It drives release_reserved_slots directly — the same call the pwrite and capacity
error arms make — because the failure it rolls back is a device error raised inside
FileBlobStore, below the injectable BlobStore boundary that
tests/checkpoint_failpoint.rs uses.

Full local run on macOS (aarch64-apple-darwin):

  • cargo fmt --all --check — pass
  • cargo clippy --workspace --all-targets --all-features --locked -- -D warnings — pass
  • cargo test --workspace --all-features --lib --tests --locked — 659 passed, 0 failed
  • cargo test --workspace --all-features --doc --locked — pass
  • RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps — pass

No on-disk layout change, no public API change, no unsafe touched.

Related

Follows #38 / a97630c ("Make blob rewrites crash-safe"), which introduced shadow
paging and the pending_free_slots / reusable_slots split. This closes the
rollback path that the success path already pinned in
replaced_slot_is_reused_only_after_manifest_flush and
manifest_flush_drains_old_slot_readers_before_reuse.

Also relevant to TESTING.md's "Known Gaps" entry on host-level disk-full / ENOSPC
coverage: that is exactly the class of failure that reaches this path.

🤖 Generated with Claude Code

`release_reserved_slots` returns the slots a failed write reserved, but
it did so through `Manifest::trim_trailing_free_slots`, whose first
statement drains `pending_free_slots` into the reusable pool. Those
slots belong to other guids' superseded generations: the last durable
manifest still references them and no reader has been drained off them.

Publishing them there bypasses both fences `flush_locked` applies — the
manifest-delta durability fence and the `enter_slot_write` reader drain.
A later write can then land on a slot the durable manifest still maps to
a live guid, so a crash before the next successful flush resolves that
guid to another blob's frame. Nothing detects it: there is no frame
checksum, and the header's `blob_guid` is written but never read back.

Split the trim in two. `trim_trailing_reusable_slots` lowers the
high-water mark over already-reusable slots only; `trim_trailing_free_slots`
keeps its publish-then-trim behaviour for `flush_locked` and `vacuum`.
The rollback path uses the former.

Only the I/O-error path changes: superseded slots now stay pending until
the next successful flush, which is the only place both fences hold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: wchwawa <wch19961116@gmail.com>
@wchwawa
wchwawa requested a review from feichai0017 August 6, 2026 14:56
@feichai0017
feichai0017 merged commit e9393fe into main Aug 7, 2026
11 checks passed
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