Skip to content

Commit 127d203

Browse files
committed
refactor(txn): defer writer-state advances until commit is durable
Split commit into commit_body plus a shared spill-cleanup wrapper, and replace the invariant-only rollback with PendingWriterState: candidate advances to the allocation cursor and commit-history root now land in a local copy that is only folded into the shared WriterState once the A/B header naming them is durable, so a refused commit leaves no trace regardless of which fallible step rejects it. Add a concurrent stress suite exercising these interleavings under genuinely concurrent tasks, checking structural integrity via deep walk, commit atomicity across generations, and error discipline, and wire its binary into the nextest invariant-checks lane.
1 parent b54fbe2 commit 127d203

6 files changed

Lines changed: 1120 additions & 80 deletions

File tree

.config/nextest.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ filter = """
2222
| binary(compaction_incremental)
2323
| binary(deferred_free_reclaim)
2424
| binary(reader_stall_policy)
25+
| binary(concurrency_stress)
2526
| (binary(pagedb) & (
2627
test(/crash/) | test(/recover/) | test(/rekey/)
2728
| test(/anchor/) | test(/torn/) | test(/journal/) | test(/replay/)

src/txn/db/catalog.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ use crate::vfs::Vfs;
1111
use crate::{RealmId, Result};
1212

1313
use super::core::{
14-
CommitHistoryMeta, Db, HeaderFieldsParams, WriterState, decode_commit_meta, encode_commit_meta,
14+
CommitHistoryMeta, Db, HeaderFieldsParams, decode_commit_meta, encode_commit_meta,
1515
encode_root_ref,
1616
};
17+
use super::pending::PendingWriterState;
1718

1819
/// Named-counter rows read per batch while validating them at open. Rows are a
1920
/// fixed-width authenticated value, so this is a few KiB resident regardless of
@@ -213,10 +214,15 @@ impl<V: Vfs + Clone> Db<V> {
213214
/// anywhere else would be handed to an allocator without the chain entry
214215
/// naming it ever being located, so nothing would delete it and the
215216
/// unscanned tail would keep naming it. One page id, two owners.
217+
///
218+
/// `state` is the *candidate* writer state, not the shared one: this call
219+
/// is fallible at several points and runs long before the header that
220+
/// would make its new root durable, so a commit that never publishes must
221+
/// leave the shared state naming the old root. See [`PendingWriterState`].
216222
#[allow(clippy::too_many_lines)]
217223
pub(crate) async fn write_commit_history_entry(
218224
&self,
219-
state: &mut WriterState,
225+
state: &mut PendingWriterState,
220226
new_commit_id: u64,
221227
meta: CommitHistoryMeta,
222228
) -> Result<Vec<u64>> {

src/txn/db/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ mod history_carry;
1717
mod manifest_validation;
1818
mod misc;
1919
mod open;
20+
mod pending;
2021
mod reader;
2122
// Serves the native-only incremental-snapshot apply path, like
2223
// `manifest_validation` above; gate it the same way.
@@ -32,3 +33,4 @@ mod util;
3233

3334
pub use core::Db;
3435
pub(crate) use core::{CommitHistoryMeta, WriterState, encode_free_list_root};
36+
pub(crate) use pending::PendingWriterState;

src/txn/db/pending.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
//! The shared writer fields a candidate commit advances, held aside until the
2+
//! header that makes them durable has been written.
3+
4+
use super::core::WriterState;
5+
6+
/// The subset of [`WriterState`] a commit must advance *before* it can know
7+
/// whether it will succeed: the allocation cursor (advanced by materializing
8+
/// the trees) and the commit-history root, version, and cached row count
9+
/// (advanced by writing this commit's history entry).
10+
///
11+
/// The invariant this type exists to hold: **a candidate commit that does not
12+
/// become durable must leave no trace in shared state.** Advancing the live
13+
/// `WriterState` in place breaks it two ways. The allocation cursor would stay
14+
/// advanced over pages the refused attempt bump-allocated and then discarded,
15+
/// and the next *successful* commit would persist that inflated cursor — so
16+
/// those ids end up below the durable cursor, referenced by no root and named
17+
/// by no free-list entry, leaked for the life of the store. Worse, the
18+
/// commit-history root would name a tree whose pages died with the failed
19+
/// transaction's dirty set, so the next commit would open a root that was
20+
/// never written.
21+
///
22+
/// So the commit path advances *this* copy and calls [`Self::publish`] only
23+
/// once the A/B header naming these values is durable. Every fallible step
24+
/// before that point may return with no unwind, because there is nothing to
25+
/// unwind — which is also why a fallible check added between materialization
26+
/// and the header write cannot reintroduce the leak.
27+
#[derive(Clone, Copy)]
28+
pub(crate) struct PendingWriterState {
29+
pub next_page_id: u64,
30+
pub commit_history_root_page_id: u64,
31+
pub commit_history_root_version: u64,
32+
pub commit_history_count: Option<u64>,
33+
}
34+
35+
impl PendingWriterState {
36+
/// Start from what the still-durable header describes.
37+
pub(crate) fn capture(state: &WriterState) -> Self {
38+
Self {
39+
next_page_id: state.next_page_id,
40+
commit_history_root_page_id: state.commit_history_root_page_id,
41+
commit_history_root_version: state.commit_history_root_version,
42+
commit_history_count: state.commit_history_count,
43+
}
44+
}
45+
46+
/// Fold the candidate's advances into the shared writer state. Call only
47+
/// after the header carrying these values is durable.
48+
pub(crate) fn publish(self, state: &mut WriterState) {
49+
state.next_page_id = self.next_page_id;
50+
state.commit_history_root_page_id = self.commit_history_root_page_id;
51+
state.commit_history_root_version = self.commit_history_root_version;
52+
state.commit_history_count = self.commit_history_count;
53+
}
54+
}

src/txn/write/commit.rs

Lines changed: 59 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,33 @@ use crate::vfs::Vfs;
4848
use crate::{CommitId, Result};
4949
use std::collections::HashSet;
5050

51-
use super::super::db::{CommitHistoryMeta, encode_free_list_root};
51+
use super::super::db::{CommitHistoryMeta, PendingWriterState, encode_free_list_root};
5252
use super::txn::WriteTxn;
5353

5454
impl<V: Vfs + Clone> WriteTxn<'_, V> {
5555
/// Flush dirty pages, write the A/B header, apply pending segment side
5656
/// effects, and publish the new root to readers. Returns the assigned
5757
/// `CommitId`.
58-
#[allow(clippy::too_many_lines)]
5958
pub async fn commit(mut self) -> Result<CommitId> {
59+
let outcome = self.commit_body().await;
60+
// The per-transaction spill file and the byte gauge it feeds belong to
61+
// this transaction whichever way it ended: a refused commit that left
62+
// `tmp/scratch-<seq>` on disk and the gauge non-zero would be a
63+
// candidate leaving a trace behind, the same class of bug as an
64+
// advanced allocation cursor. Cleaned up here, once, so no exit from
65+
// the body can forget it.
66+
self.cleanup_spill_async().await;
67+
self.db
68+
.spill_bytes_in_use
69+
.store(0, std::sync::atomic::Ordering::Relaxed);
70+
outcome
71+
}
72+
73+
/// The commit protocol proper. Split from [`Self::commit`] so that every
74+
/// early return — and every fallible step added between materialization and
75+
/// the durable header write — passes through one shared cleanup.
76+
#[allow(clippy::too_many_lines)]
77+
async fn commit_body(&mut self) -> Result<CommitId> {
6078
debug_assert!(
6179
self.db.write_lock_satisfied(),
6280
"page write without held writer lock"
@@ -65,13 +83,15 @@ impl<V: Vfs + Clone> WriteTxn<'_, V> {
6583
// Note: span is not entered via `.entered()` to keep this async fn's
6684
// future `Send`. Use `tracing::instrument` or enter in sync sections only.
6785
let new_commit_id = self.guard.latest_commit_id + 1;
68-
// `Some` is both the enablement signal for the opt-in invariant and the
69-
// means of undoing what materialization is about to advance — the check
70-
// is never reachable without the rollback it needs. Presence of the
71-
// variable enables it, including a non-Unicode value.
72-
let invariant_rollback = std::env::var_os("PAGEDB_INVARIANT_CHECKS")
73-
.is_some()
74-
.then(|| InvariantRollback::capture(&self.guard));
86+
// Presence of the variable enables the opt-in structural invariant,
87+
// including a non-Unicode value.
88+
let invariant_checks = std::env::var_os("PAGEDB_INVARIANT_CHECKS").is_some();
89+
// A candidate commit that does not become durable must leave no trace
90+
// in shared state. Everything this function advances before the header
91+
// write lands here rather than in the shared `WriterState`, and is
92+
// published only once that header is durable — so every fallible step
93+
// below (and any added later) can return with nothing to unwind.
94+
let mut pending = PendingWriterState::capture(&self.guard);
7595

7696
// ── Materialize all trees first ──────────────────────────────────────
7797
// Done before accounting freed pages so every copy-on-write spine free
@@ -82,7 +102,7 @@ impl<V: Vfs + Clone> WriteTxn<'_, V> {
82102
self.sync_allocator_from_catalog();
83103
let new_root = self.btree.root_page_id();
84104
let new_catalog_root = self.catalog_tree.root_page_id();
85-
self.guard.next_page_id = self
105+
pending.next_page_id = self
86106
.btree
87107
.next_page_id()
88108
.max(self.catalog_tree.next_page_id());
@@ -96,7 +116,7 @@ impl<V: Vfs + Clone> WriteTxn<'_, V> {
96116
active_root_page_id: new_root,
97117
catalog_root_page_id: new_catalog_root,
98118
free_list_root_page_id: self.guard.free_list_root_page_id,
99-
next_page_id: self.guard.next_page_id,
119+
next_page_id: pending.next_page_id,
100120
unix_seconds,
101121
};
102122
let mut hist_freed: Vec<u64> = Vec::new();
@@ -106,7 +126,7 @@ impl<V: Vfs + Clone> WriteTxn<'_, V> {
106126
) {
107127
hist_freed = self
108128
.db
109-
.write_commit_history_entry(&mut self.guard, new_commit_id, history_meta)
129+
.write_commit_history_entry(&mut pending, new_commit_id, history_meta)
110130
.await?;
111131
}
112132

@@ -176,7 +196,9 @@ impl<V: Vfs + Clone> WriteTxn<'_, V> {
176196
// a pin — those at/above the reclamation floor. Drainable entries (below
177197
// it) are being recycled and must not count, or an inherited-but-
178198
// drainable backlog would spuriously abort a reader on reopen. Evaluated
179-
// before the chain write so a reject aborts cleanly.
199+
// before the chain write so a reject aborts cleanly: it returns without
200+
// unwinding because the candidate's advances are held in `pending` and
201+
// the shared state still describes the durable header.
180202
//
181203
// The threshold is defined over the whole chain, which a windowed
182204
// rewrite cannot produce on its own: `entries` covers only the scanned
@@ -215,27 +237,27 @@ impl<V: Vfs + Clone> WriteTxn<'_, V> {
215237
// superseded pages. Entries in the retained tail were already checked by
216238
// the commit that put them there and are not re-walked, which keeps the
217239
// check's cost tied to the same budget as the rewrite it guards.
218-
if let Some(rollback) = invariant_rollback {
240+
//
241+
// No unwind is needed here: the shared writer state still describes the
242+
// durable header, and `Drop` discards this candidate's dirty pages, so
243+
// the next writer bump-allocates over the pages it abandoned instead of
244+
// leaking their ids.
245+
if invariant_checks {
219246
let freed = entries.iter().map(|&(_, page_id)| page_id).collect();
220247
let roots = [
221248
("data", new_root),
222249
("catalog", new_catalog_root),
223-
("commit-history", self.guard.commit_history_root_page_id),
250+
("commit-history", pending.commit_history_root_page_id),
224251
];
225252
if let Err(violation) = assert_freed_pages_unreachable(
226253
self.db,
227254
roots,
228-
self.guard.next_page_id,
255+
pending.next_page_id,
229256
&freed,
230257
new_commit_id,
231258
)
232259
.await
233260
{
234-
// Unwind to exactly the state the still-durable header
235-
// describes, so the next writer bump-allocates over the pages
236-
// this candidate abandoned instead of leaking their ids.
237-
// `Drop` discards the dirty pages themselves.
238-
rollback.restore(&mut self.guard);
239261
panic!("{violation}");
240262
}
241263
}
@@ -251,16 +273,16 @@ impl<V: Vfs + Clone> WriteTxn<'_, V> {
251273
self.db.page_size,
252274
entries,
253275
host_candidates,
254-
self.guard.next_page_id,
276+
pending.next_page_id,
255277
retained_tail,
256278
)
257279
.await?;
258-
self.guard.next_page_id = new_next_page;
280+
pending.next_page_id = new_next_page;
259281

260282
// ── Flush + header swap ──────────────────────────────────────────────
261283
self.db.pager.flush_main(self.db.realm_id).await?;
262284

263-
let new_next = self.guard.next_page_id;
285+
let new_next = pending.next_page_id;
264286
let new_seq = self.guard.seq + 1;
265287
let counter_anchor = self.db.pager.pending_anchor();
266288

@@ -288,8 +310,8 @@ impl<V: Vfs + Clone> WriteTxn<'_, V> {
288310
catalog_root: catalog_root_bytes,
289311
apply_journal_root_page_id: 0,
290312
apply_journal_root_version: 0,
291-
commit_history_root_page_id: self.guard.commit_history_root_page_id,
292-
commit_history_root_version: self.guard.commit_history_root_version,
313+
commit_history_root_page_id: pending.commit_history_root_page_id,
314+
commit_history_root_version: pending.commit_history_root_version,
293315
restore_mode: 0,
294316
next_page_id: new_next,
295317
commit_retain_policy_tag: policy_tag,
@@ -310,26 +332,29 @@ impl<V: Vfs + Clone> WriteTxn<'_, V> {
310332
// any fallible post-header work so a failed reconciliation can never
311333
// regress the next durable write. Keep the prior reader snapshot until
312334
// segment effects have completed and their directories are synced.
335+
//
336+
// This is also the single point at which the candidate's advances
337+
// become shared: everything up to here could still have refused the
338+
// commit, and a refusal must leave nothing behind.
339+
pending.publish(&mut self.guard);
313340
self.guard.root_page_id = new_root;
314-
self.guard.next_page_id = new_next;
315341
self.guard.active_slot = new_slot;
316342
self.guard.seq = new_seq;
317343
self.guard.latest_commit_id = new_commit_id;
318344
self.guard.catalog_root_page_id = new_catalog_root;
319345
self.guard.catalog_root_txn_id = new_commit_id;
320346
self.guard.free_list_root_page_id = new_free_list_root;
321-
// commit_history_root_page_id and commit_history_root_version are
322-
// already updated inside write_commit_history_entry.
323347
self.committed_or_aborted = true;
324348

325349
// Black box: record the commit + how many pages it freed into the
326350
// flight recorder. Freeing commits are the operations most implicated
327351
// in page recycling, so this trail is what a corruption report needs.
328352
crate::diag::committed(new_commit_id, all_freed.len());
329353

330-
// `visibility_guard` was acquired before the reclamation-floor scan
331-
// in `WriteTxn::begin` and remains held through this publication.
332-
let _visibility = &self.visibility_guard;
354+
// `visibility_guard` was acquired before the reclamation-floor scan in
355+
// `WriteTxn::begin` and is still held here — it is passed straight into
356+
// the publication below, so the floor a reader could have observed
357+
// cannot have moved between the scan and this commit becoming visible.
333358
if self
334359
.db
335360
.finish_durable_commit_visible(
@@ -342,24 +367,15 @@ impl<V: Vfs + Clone> WriteTxn<'_, V> {
342367
.await
343368
.is_err()
344369
{
345-
self.cleanup_spill_async().await;
346-
self.db
347-
.spill_bytes_in_use
348-
.store(0, std::sync::atomic::Ordering::Relaxed);
349370
return Err(PagedbError::durably_committed_but_unpublished(CommitId(
350371
new_commit_id,
351372
)));
352373
}
353374

354375
// The allocator cache is rebuilt from the durable free-list at the next
355-
// `begin_write`, so nothing is handed off here.
376+
// `begin_write`, so nothing is handed off here. The spill tmp file is
377+
// removed by the caller, on this and every other exit.
356378

357-
// Remove the spill tmp file (best-effort; error is non-fatal).
358-
self.cleanup_spill_async().await;
359-
360-
self.db
361-
.spill_bytes_in_use
362-
.store(0, std::sync::atomic::Ordering::Relaxed);
363379
tracing::debug!(
364380
name = "txn.commit",
365381
commit_id = new_commit_id,
@@ -369,41 +385,6 @@ impl<V: Vfs + Clone> WriteTxn<'_, V> {
369385
}
370386
}
371387

372-
/// The writer fields a candidate commit advances before the opt-in invariant
373-
/// runs, captured so a rejected candidate can be unwound.
374-
///
375-
/// These four are exactly what tree materialization and
376-
/// `write_commit_history_entry` assign. Everything else the pre-invariant
377-
/// stretch of `commit` touches is either transaction-local (dies with the
378-
/// `WriteTxn`) or rebuilt from the durable free-list by the next
379-
/// `begin_write` — the shared allocator caches among them — so it needs no
380-
/// rollback of its own.
381-
#[derive(Clone, Copy)]
382-
struct InvariantRollback {
383-
next_page_id: u64,
384-
commit_history_root_page_id: u64,
385-
commit_history_root_version: u64,
386-
commit_history_count: Option<u64>,
387-
}
388-
389-
impl InvariantRollback {
390-
fn capture(state: &super::super::db::WriterState) -> Self {
391-
Self {
392-
next_page_id: state.next_page_id,
393-
commit_history_root_page_id: state.commit_history_root_page_id,
394-
commit_history_root_version: state.commit_history_root_version,
395-
commit_history_count: state.commit_history_count,
396-
}
397-
}
398-
399-
fn restore(self, state: &mut super::super::db::WriterState) {
400-
state.next_page_id = self.next_page_id;
401-
state.commit_history_root_page_id = self.commit_history_root_page_id;
402-
state.commit_history_root_version = self.commit_history_root_version;
403-
state.commit_history_count = self.commit_history_count;
404-
}
405-
}
406-
407388
#[derive(Debug)]
408389
enum FreedPageInvariantViolation {
409390
Dangling {

0 commit comments

Comments
 (0)