Repository navigation
fix: fail the database closed when a write fails after changing cached frames - #84
Merged
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 on 0.8.7: 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. Batches that carry an attached journal envelope follow the same order on the file WAL and on the memory stream: they apply before they append the envelope. 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 with or without an envelope (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 with or without a WAL, the final round on drop, inline flushes) checks the latch and refuses with the new Error::FailedClosed; checkpoint captures check it under the commit gate. Reopening recovers the last durable state. The failed call still reports OutcomeUnknown, and a failed attached batch never appends its envelope. 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 an attached batch on a WAL store and on a memory stream. They check that later writes and checkpoints are refused and that the half that applied is absent after a reopen. Without the latch the first two reopen with the half batch durable. The WAL tests ack each batch with a sync, so every batch before the failure is durable when the DB drops with its checkpointer off. 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, 15,285 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. A queued attached batch also appended its envelope after the failed one. 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 and attached 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, the dropped-tree finalizer and journal stream initialization 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 tests hold a DB batch, a conditional tree put and an attached batch after their entry checks, fail a cross-tree batch part-way on a cold child read, then release them and reopen. Without the re-check all three returned Ok(true) and were durable after the reopen, and the attached stream's tail moved past the failed batch. 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 0.8 line gets the same fix in #83.
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. Main has the same apply-then-submit shape, and NoKV 0.12.x on Holt 0.8.7 hit it: 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. Main's batches that carry an attached journal envelope latch the same way on the file WAL and on the memory stream, and never append their envelope after a failure. Every write checks the latch first, and every path that persists dirty state checks it: background checkpoint rounds, DB and tree checkpoint captures with and without a WAL (under the commit gate where one is held), inline flushes, and the final round on drop. The newError::FailedClosedsays to reopen. Reopening recovers the last durable state.The checkpoint planner stops scheduling rounds once it sees the latch, drains the epochs in flight, and logs the refusal once. 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 including attached ones 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_tree, the dropped-tree finalizer andinitialize_journal_streamafter the maintenance gate.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: the Homebrew 1.97 clippy does not know theclippy::assert_is_emptylintsrc/lib.rsallows), macOS, exact headf28d5ad.8e7d8d7with the same scenarios as the new tests show the half batch durable after a reopen on a custom store and on a WAL store. On the final code, makingfail_closeda no-op fails all three latch tests (a_batch_that_fails_part_way_fails_the_store_closed_and_is_never_persisted,a_partial_batch_on_a_wal_store_is_absent_after_reopen,a_partial_attached_batch_is_absent_after_reopen), and removing each of the four DB latch points fails exactly its test.failed_closed_store_stops_the_plannerran 15,285 refused rounds in 100 ms without the planner stop. Without the re-check, a queued DB batch, aput_if_absentand an attached batch returnedOk(true)behind the failing batch and were durable after the reopen; removing each re-check point fails its test.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: 787 passed, 0 failed, 9 ignored (an earlier version of this description gave the first commit's 784); the doctests: 1 passed, 3 ignored. The four examples run;RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-depspasses;cargo publish --dry-run --lockedpasses.