@@ -48,15 +48,33 @@ use crate::vfs::Vfs;
4848use crate :: { CommitId , Result } ;
4949use 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} ;
5252use super :: txn:: WriteTxn ;
5353
5454impl < 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 ) ]
408389enum FreedPageInvariantViolation {
409390 Dangling {
0 commit comments