Skip to content

fix: fail the database closed when a write fails after changing cached frames, and renumber the next 0.8 release 0.8.9 - #83

Merged
wchwawa merged 5 commits into
release/0.8from
fix/fail-closed-torn-batch-0.8
Oct 31, 2026
Merged

wchwawa merged 5 commits into
release/0.8from
fix/fail-closed-torn-batch-0.8

Conversation

@wchwawa

@wchwawa wchwawa commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

TLDR

A write that fails after it has already changed cached frames now latches the database closed, so the partial change can never reach a checkpoint. Before, a cross-tree atomic batch that failed part-way could become half durable, and NoKV's metadata store then refused to open. The main line gets the same fix in #84. Holt 0.8.8 shipped this fix alone from v0.8.7 (#85), so this branch also records 0.8.8 in its changelog and renumbers its unreleased blob integrity release (#81) to 0.8.9.

Problem

DB::atomic applies each tree's ops to cached frames under the commit gate and then submits one WAL record. When an op of a later tree fails (an allocation error, a blob read error) or the submit fails, the earlier ops stay applied in memory with no WAL record. The call reports AtomicErrorKind::OutcomeUnknown, but the next checkpoint round snapshots every dirty frame and writes it. Tree batches and single put, delete and rename whose WAL submit fails have the same shape, and so does a rename whose insert fails after its erase.

Consequences

After a reopen, one tree holds its half of a batch without the other. NoKV 0.12.x on Holt 0.8.7 hit this: an OutOfSpace ("need 520 bytes, 512 available", a Node256 allocation) in one keyspace of a metadata batch left the recovery tail one log sequence number ahead of the outbox on disk, and the metadata store refused to open until an operator intervened.

Example

A two-tree batch whose second tree fails part-way. Before: after a checkpoint and a reopen, the first tree has its keys and the second does not. After: the batch still fails with OutcomeUnknown; every later write, DB::checkpoint and background round fails with Error::FailedClosed; after a reopen neither tree has the batch.

Solution

BufferManager gains a failed_closed latch. A write that may have changed cached frames before failing sets it while it still holds the commit gate: an atomic DB batch whose apply or submit fails, a tree batch, a single put, delete or rename whose WAL submit fails, and a rename whose insert fails after its erase. Every write checks the latch first, and every path that persists dirty state checks it under the commit gate: background checkpoint rounds, DB and tree checkpoint captures, inline flushes, and the final round on drop. The new Error::FailedClosed says to reopen. Reopening recovers the last durable state.

Two follow-up commits make the latch hold under load. The checkpoint planner stops scheduling rounds once it sees the latch, drains the epochs in flight, and logs the refusal once; before, a latched store under write pressure retried without parking and logged every refusal. A write that queued behind the failing writer re-checks the latch once it holds the gates that serialize it, before it evaluates a condition or changes a frame: DB atomic batches after their tree gates, tree batches and put_many_if_absent after the mutation gate, single put, delete and rename (conditional and deferred) after their endpoint locks, and create_tree, drop_tree and the dropped-tree finalizer after the maintenance gate. Before, such a write evaluated its conditions against the partial change and committed a WAL record that a reopen replayed onto a state that never held that change.

A last commit keeps the version line honest. 0.8.8 was published from v0.8.7 with only this fix, so release/0.8 moves its changelog entry under [0.8.8] — 2026-10-31, moves #81's blob integrity entry under [Unreleased], and sets the crate version to 0.8.9 in Cargo.toml and the three lockfiles. Nothing else changes.

Gain

A failed write can no longer become partially durable; the database refuses to continue instead, and a reopen recovers.

Tradeoffs

The latch stops writes and checkpoints of the whole database, not only the tree that failed, until a reopen: a failure that used to be silent corruption becomes an outage that needs a restart. Reads keep serving the in-memory state, which may include the partial change, until the reopen; an application that cannot tolerate that should stop when it sees FailedClosed or OutcomeUnknown. NoKV already does: its store poisons itself on OutcomeUnknown and refuses reads and writes until it is reopened. What it lacked was the guarantee that Holt's own background checkpoint would not persist the partial batch before that reopen. A latched store skips the final checkpoint on drop, so with asynchronous WAL acknowledgements the records acknowledged before the failure that still sit in the WAL writer's buffer are lost, as they already are when the checkpointer is off; handing that buffer to the OS on stop would change a trade-off three existing tests assert, and is left for a separate decision. The OutOfSpace itself is not fixed: it was not reproduced by targeted spillover probes, a spill fuzz, eleven NoKV workload runs, or 192 replays of operation logs captured from four NoKV workloads (10,244 to 31,235 batches each), so a future occurrence fails closed instead of corrupting. No crate is published by this PR.

Validation

Rust stable (the rustup toolchain first on PATH), macOS, exact head f013f5e; the renumber commit changes no code, and 9dc8662 gave the same counts.

  • Red first: a_batch_that_fails_part_way_fails_the_store_closed_and_is_never_persisted (a cross-tree batch on a custom store whose cold child read fails) and a_partial_batch_on_a_wal_store_is_absent_after_reopen (a WAL store, failed through the fail_next_prepared_parent_compaction test failpoint) reopen with the half batch durable without the latch. failed_closed_store_stops_the_planner ran 16,929 refused rounds in 100 ms without the planner stop. Without the re-check, a DB batch and a conditional put held after their entry checks returned Ok(true) behind the failing batch and were durable after the reopen; removing each re-check point fails its test. The partial WAL batch test acknowledges each batch with a sync: with asynchronous acknowledgements it failed 9 of 12 runs, for the buffer reason under Tradeoffs.
  • cargo fmt --all --check, cargo clippy --workspace --all-targets --all-features --locked -- -D warnings, and the soak tool's fmt and clippy pass.
  • cargo test --workspace --all-features --lib --tests --examples --locked: 720 passed, 0 failed, 8 ignored; the doctests: 1 passed, 3 ignored. The four examples run; RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps passes; cargo publish --dry-run --locked packages 0.8.8 and verifies it. Earlier versions of this description reported 978 and then 717 tests: the first count came from a log that also held an aborted run's output, and the second was the first commit's count, not the head's. cargo +1.82 build --lib --locked (MSRV) and the two soak smokes with the CI parameters pass.
  • The CI workflow does not run on pull requests to release/0.8. A manual Nightly Validation run with the default 600-second soaks and 180-second fuzz campaign passed on the first commit, 56a8b22: the checkpoint and WAL fault matrix, the property tests, the crash, DB crash, DB normal and three normal soaks, and the atomic_model, db_model and read_index_model fuzz targets (Verus was not enabled). The same run on this head passed as well.
  • Not run: the Linux io-uring backend tests and the benchmarks. No crate was published.

…d frames

An atomic DB batch applies its ops to cached frames and only then submits
its WAL record. When an op of a later tree failed (an allocation error, a
blob read error) or the submit itself failed, the earlier ops stayed applied
in memory with no WAL record, and the next checkpoint round wrote those
frames: after a reopen, one tree held its half of the batch without the
other. NoKV hit exactly this: an allocation failure in one keyspace of a
metadata batch left the recovery tail one LSN ahead of the outbox on disk,
and the metadata store then refused to open.

Any write that may have changed cached frames before failing now latches
the shared buffer manager closed while it still holds the commit gate:
atomic DB batches (apply or submit), tree batches, and single put, delete
and rename whose WAL submit failed (rename also when its insert fails after
its erase). Once latched, every write is refused, and every path that
persists dirty state (background rounds, DB and tree checkpoints, the final
round on drop, inline flushes) checks the latch under the commit gate and
refuses with the new Error::FailedClosed. Reopening recovers the last
durable state; the failed call still reports OutcomeUnknown.

Tests fail a cross-tree batch part-way on a custom store (a cold child read
fails) and on a WAL store (a spillover failpoint), and check that later
writes and checkpoints are refused and that the half that applied is absent
after a reopen. Without the latch both reopen with the half batch durable.

Signed-off-by: wchwawa <wch19961116@gmail.com>
The test reopens a WAL store after a failed batch and expects every batch
before it to be durable. It ran with async acks and the checkpointer off,
so the last batches could still sit in the WAL writer's user-space buffer
when the DB dropped, and the reopen then missed them: the test failed in 8
of 8 local runs. Synced acks make every earlier batch durable before the
failure, which is what the assertions need.

Signed-off-by: wchwawa <wch19961116@gmail.com>
A store latched closed refuses every checkpoint round until it is reopened.
The planner still scheduled them: under write pressure (dirty blobs at the
threshold or a pending delete) it retried without parking and logged every
refusal, tens of thousands of lines a second until the database dropped;
otherwise it logged one refusal per idle interval.

The planner now stops scheduling rounds as soon as it sees the latch and
exits after draining the epochs already in flight. The refusal is logged
once, shared with the final round on drop, which is still attempted and
refused. A refused round now counts as attempted and failed in the
checkpointer stats, so the planner's behaviour is observable.

The test latches a store under write pressure and checks that at most one
round runs afterwards; without the stop, 16,929 refused rounds ran in
100 ms.

Signed-off-by: wchwawa <wch19961116@gmail.com>
Writes checked the latch only on entry. A write that passed that check and
then queued behind a writer failing part-way acquired the gates after the
latch was set, evaluated its conditions against the partial change and
committed a WAL record, which a reopen replayed onto a state that never
held the partial change.

Every write that can queue behind another now re-checks the latch once it
holds the gates that serialize it, before it evaluates a condition or
changes a frame: DB atomic batches after their tree gates, tree batches
and put_many_if_absent after the mutation gate, single put, delete and
rename (conditional and deferred) after their endpoint locks, and
create_tree, drop_tree and the dropped-tree finalizer after the
maintenance gate. A failing writer latches before it releases those
gates, so a queued write sees the latch and is refused with FailedClosed;
DB batches report it as DefinitelyNotApplied.

The test holds a DB batch and a conditional tree put after their entry
checks, fails a cross-tree batch part-way on a cold child read, then
releases them and reopens. Without the re-check both returned Ok(true)
and were durable after the reopen.

Signed-off-by: wchwawa <wch19961116@gmail.com>
Holt 0.8.8 shipped from v0.8.7 with only the fail-closed fix. Record it in this branch's changelog and move the unreleased blob integrity checks under Unreleased, with the crate version set to 0.8.9.

Signed-off-by: wchwawa <wch19961116@gmail.com>
@wchwawa wchwawa changed the title fix: fail the database closed when a write fails after changing cached frames fix: fail the database closed when a write fails after changing cached frames, and renumber the next 0.8 release 0.8.9 Oct 31, 2026
@wchwawa
wchwawa merged commit 9173fed into release/0.8 Oct 31, 2026
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.

1 participant