Repository navigation
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
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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::atomicapplies 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 reportsAtomicErrorKind::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", aNode256allocation) 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::checkpointand background round fails withError::FailedClosed; after a reopen neither tree has the batch.Solution
BufferManagergains afailed_closedlatch. 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 newError::FailedClosedsays 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_absentafter the mutation gate, single put, delete and rename (conditional and deferred) after their endpoint locks, andcreate_tree,drop_treeand 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.7with only this fix, sorelease/0.8moves 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 inCargo.tomland 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
FailedClosedorOutcomeUnknown. NoKV already does: its store poisons itself onOutcomeUnknownand 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. TheOutOfSpaceitself 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 headf013f5e; the renumber commit changes no code, and9dc8662gave the same counts.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) anda_partial_batch_on_a_wal_store_is_absent_after_reopen(a WAL store, failed through thefail_next_prepared_parent_compactiontest failpoint) reopen with the half batch durable without the latch.failed_closed_store_stops_the_plannerran 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 returnedOk(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-depspasses;cargo publish --dry-run --lockedpackages 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.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 theatomic_model,db_modelandread_index_modelfuzz targets (Verus was not enabled). The same run on this head passed as well.