@@ -14,7 +14,7 @@ use crate::errors::PagedbError;
1414use crate :: pager:: PageKind ;
1515use crate :: pager:: header:: commit_header;
1616use crate :: pager:: structural_header:: MainDbHeaderFields ;
17- use crate :: vfs:: { OpenMode , Vfs , VfsFile } ;
17+ use crate :: vfs:: { OpenMode , Vfs , VfsFile , read_exact_at } ;
1818
1919#[ cfg( test) ]
2020use super :: super :: core:: RekeyTestFault ;
@@ -28,20 +28,30 @@ use super::intent::{intent_proof, migrate_legacy, validate_intent_for_current_ci
2828/// retention runs.
2929const HISTORY_ROOT_BATCH : usize = 512 ;
3030
31- async fn read_main_page_kind < F : VfsFile > ( file : & F , offset : u64 ) -> Result < Option < PageKind > > {
31+ /// One catalog rewrite a rekey stage is about to make durable.
32+ ///
33+ /// The new root and allocation cursor, the pages the rewrite superseded, and
34+ /// the segment side effects it publishes all describe the same transition, so
35+ /// they travel together rather than as loose positional arguments.
36+ pub ( super ) struct RekeyCatalogCommit < ' a > {
37+ pub catalog_root_page_id : u64 ,
38+ pub next_page_id : u64 ,
39+ pub freed_pages : & ' a [ u64 ] ,
40+ pub effects : & ' a [ crate :: txn:: write:: SegmentSideEffect ] ,
41+ }
42+
43+ /// Read the cleartext kind byte of a `main.db` page, or `None` when the page
44+ /// has been cleared.
45+ ///
46+ /// Only selects which AAD binding to authenticate under; the pager still
47+ /// verifies the full envelope against that same kind, so a tampered byte
48+ /// cannot launder a page into another role — it fails the tag instead.
49+ async fn read_main_page_kind < F : VfsFile + ?Sized > (
50+ file : & mut F ,
51+ offset : u64 ,
52+ ) -> Result < Option < PageKind > > {
3253 let mut envelope = [ 0u8 ; 2 ] ;
33- let mut filled = 0 ;
34- while filled < envelope. len ( ) {
35- let read = file
36- . read_at ( offset + filled as u64 , & mut envelope[ filled..] )
37- . await ?;
38- if read == 0 {
39- return Err ( PagedbError :: Io ( std:: io:: Error :: from (
40- std:: io:: ErrorKind :: UnexpectedEof ,
41- ) ) ) ;
42- }
43- filled += read;
44- }
54+ read_exact_at ( file, offset, & mut envelope) . await ?;
4555 if envelope == [ 0 , 0 ] {
4656 return Ok ( None ) ;
4757 }
@@ -287,12 +297,12 @@ impl<V: Vfs + Clone> Db<V> {
287297 return Ok ( ( ) ) ;
288298 }
289299
290- let file = self . vfs . open ( & self . main_db_path , OpenMode :: Read ) . await ?;
300+ let mut file = self . vfs . open ( & self . main_db_path , OpenMode :: Read ) . await ?;
291301 for page_id in reusable_pages {
292302 let offset = page_id
293303 . checked_mul ( self . page_size as u64 )
294304 . ok_or_else ( || PagedbError :: arithmetic_overflow ( "free-list page offset" ) ) ?;
295- let Some ( kind) = read_main_page_kind ( & file, offset) . await ? else {
305+ let Some ( kind) = read_main_page_kind ( & mut file, offset) . await ? else {
296306 continue ;
297307 } ;
298308 self . pager
@@ -302,23 +312,30 @@ impl<V: Vfs + Clone> Db<V> {
302312 Ok ( ( ) )
303313 }
304314
305- /// Re-encrypt pages superseded by copy-on-write catalog transitions after
306- /// the target-authenticated header is durable. No live root discovers
307- /// these residual pages, but physical integrity scans still authenticate
308- /// every non-zero page and the source epoch is retired on success.
309- async fn rewrite_rekey_residual_main_pages ( & self , state : & WriterState ) -> Result < ( ) > {
310- let file = self . vfs . open ( & self . main_db_path , OpenMode :: Read ) . await ?;
311- for page_id in 4 ..state. next_page_id {
312- let offset = page_id
313- . checked_mul ( self . page_size as u64 )
314- . ok_or_else ( || PagedbError :: arithmetic_overflow ( "main-db page offset" ) ) ?;
315- let Some ( kind) = read_main_page_kind ( & file, offset) . await ? else {
316- continue ;
317- } ;
318- self . pager
319- . rewrite_page_under_current_epoch ( page_id, self . realm_id , kind)
320- . await ?;
315+ /// Re-seal the live catalog tree under the target epoch.
316+ ///
317+ /// Every other reader-visible root is re-sealed before the target header is
318+ /// published. The catalog is the one that cannot be: until the target
319+ /// header is durable it must stay source-readable, because it carries the
320+ /// rekey intent that an open able to verify only the stale A/B side uses to
321+ /// admit recovery. So it is re-sealed here instead, once that anchor is in
322+ /// place and while both keys are still installed.
323+ ///
324+ /// The walk is the same bounded, deduplicating traversal the data tree
325+ /// uses — proportional to unique reachable catalog pages, not to the size
326+ /// of the file.
327+ async fn rewrite_rekey_catalog_pages ( & self , state : & WriterState ) -> Result < ( ) > {
328+ if state. catalog_root_page_id == 0 {
329+ return Ok ( ( ) ) ;
321330 }
331+ let catalog = BTree :: open (
332+ self . pager . clone ( ) ,
333+ self . realm_id ,
334+ state. catalog_root_page_id ,
335+ state. next_page_id ,
336+ self . page_size ,
337+ ) ;
338+ catalog. rekey_walk_unique ( & mut BTreeMap :: new ( ) ) . await ?;
322339 Ok ( ( ) )
323340 }
324341
@@ -427,7 +444,13 @@ impl<V: Vfs + Clone> Db<V> {
427444 target_header_key : & DerivedKey ,
428445 ) -> Result < ( ) > {
429446 if matches ! ( intent. stage, RekeyStage :: HeaderTargetPublished ) {
430- self . rewrite_rekey_residual_main_pages ( state) . await ?;
447+ self . rewrite_rekey_catalog_pages ( state) . await ?;
448+ // The pages the intent write above superseded are already on the
449+ // durable free list, so this pass also re-seals them; from here on
450+ // every catalog page is target-sealed and later transitions can
451+ // only supersede target-epoch pages.
452+ self . rewrite_free_list_pages ( state. free_list_root_page_id )
453+ . await ?;
431454 self . pager . flush_main ( self . realm_id ) . await ?;
432455 intent. stage = RekeyStage :: MainDone ;
433456 self . write_rekey_intent_locked (
@@ -520,13 +543,17 @@ impl<V: Vfs + Clone> Db<V> {
520543 )
521544 . await ?;
522545 tree. flush ( ) . await ?;
546+ let freed_pages = tree. drain_freed ( ) ;
523547 self . commit_rekey_catalog_root (
524548 state,
525- tree. root_page_id ( ) ,
526- tree. next_page_id ( ) ,
549+ RekeyCatalogCommit {
550+ catalog_root_page_id : tree. root_page_id ( ) ,
551+ next_page_id : tree. next_page_id ( ) ,
552+ freed_pages : & freed_pages,
553+ effects : & [ ] ,
554+ } ,
527555 header_epoch,
528556 header_hk,
529- & [ ] ,
530557 )
531558 . await
532559 . map ( |_| ( ) )
@@ -550,27 +577,85 @@ impl<V: Vfs + Clone> Db<V> {
550577 ) ;
551578 let _ = tree. delete ( & Catalog :: rekey_state_key ( ) ) . await ?;
552579 tree. flush ( ) . await ?;
580+ let freed_pages = tree. drain_freed ( ) ;
553581 self . commit_rekey_catalog_root (
554582 state,
555- tree. root_page_id ( ) ,
556- tree. next_page_id ( ) ,
583+ RekeyCatalogCommit {
584+ catalog_root_page_id : tree. root_page_id ( ) ,
585+ next_page_id : tree. next_page_id ( ) ,
586+ freed_pages : & freed_pages,
587+ effects : & [ ] ,
588+ } ,
557589 header_epoch,
558590 header_hk,
559- & [ ] ,
560591 )
561592 . await
562593 . map ( |_| ( ) )
563594 }
564595
565- pub ( super ) async fn commit_rekey_catalog_root (
596+ /// Fold pages a rekey-time catalog rewrite superseded into the durable free
597+ /// list, returning the allocation cursor the header must record.
598+ ///
599+ /// Every ordinary commit routes its copy-on-write leftovers here. Without
600+ /// it a rekey abandons pages at each intent transition: unreachable from
601+ /// any root, absent from the free list, and — once the source epoch is
602+ /// retired — sealed under a key that no longer exists. Entries carry the
603+ /// current commit id, so the reclamation floor still withholds any page a
604+ /// live reader or retained history root can still name.
605+ async fn record_rekey_freed_pages (
566606 & self ,
567607 state : & mut WriterState ,
568- catalog_root_page_id : u64 ,
608+ freed_pages : & [ u64 ] ,
569609 next_page_id : u64 ,
610+ ) -> Result < u64 > {
611+ if freed_pages. is_empty ( ) {
612+ return Ok ( next_page_id) ;
613+ }
614+ let ( mut entries, old_chain) = crate :: pager:: freelist:: read_chain (
615+ & self . pager ,
616+ self . realm_id ,
617+ state. free_list_root_page_id ,
618+ )
619+ . await ?;
620+ entries. extend (
621+ freed_pages
622+ . iter ( )
623+ . map ( |& page_id| ( state. latest_commit_id , page_id) ) ,
624+ ) ;
625+ // Chain pages are writer-only metadata that no reader snapshot ever
626+ // traverses, so they carry a commit id below every real floor and are
627+ // immediately recyclable — the same rule the ordinary commit path uses.
628+ entries. extend ( old_chain. into_iter ( ) . map ( |page_id| ( 0 , page_id) ) ) ;
629+ let ( new_free_list_root, new_next_page_id) = crate :: pager:: freelist:: rewrite_chain (
630+ & self . pager ,
631+ self . realm_id ,
632+ self . page_size ,
633+ entries,
634+ Vec :: new ( ) ,
635+ next_page_id,
636+ )
637+ . await ?;
638+ state. free_list_root_page_id = new_free_list_root;
639+ self . pager . flush_main ( self . realm_id ) . await ?;
640+ Ok ( new_next_page_id)
641+ }
642+
643+ pub ( super ) async fn commit_rekey_catalog_root (
644+ & self ,
645+ state : & mut WriterState ,
646+ catalog : RekeyCatalogCommit < ' _ > ,
570647 header_epoch : u64 ,
571648 hk : & DerivedKey ,
572- effects : & [ crate :: txn:: write:: SegmentSideEffect ] ,
573649 ) -> Result < super :: super :: segment:: SegmentReconciliation > {
650+ let RekeyCatalogCommit {
651+ catalog_root_page_id,
652+ next_page_id,
653+ freed_pages,
654+ effects,
655+ } = catalog;
656+ let next_page_id = self
657+ . record_rekey_freed_pages ( state, freed_pages, next_page_id)
658+ . await ?;
574659 let next_page_id = next_page_id. max ( state. next_page_id ) ;
575660 let new_seq = state
576661 . seq
@@ -953,13 +1038,17 @@ mod tests {
9531038 . unwrap ( ) ;
9541039 catalog. flush ( ) . await . unwrap ( ) ;
9551040 let source_header_key = db. hk . read ( ) . clone ( ) ;
1041+ let freed_pages = catalog. drain_freed ( ) ;
9561042 db. commit_rekey_catalog_root (
9571043 & mut state,
958- catalog. root_page_id ( ) ,
959- catalog. next_page_id ( ) ,
1044+ RekeyCatalogCommit {
1045+ catalog_root_page_id : catalog. root_page_id ( ) ,
1046+ next_page_id : catalog. next_page_id ( ) ,
1047+ freed_pages : & freed_pages,
1048+ effects : & [ ] ,
1049+ } ,
9601050 0 ,
9611051 & source_header_key,
962- & [ ] ,
9631052 )
9641053 . await
9651054 . unwrap ( ) ;
0 commit comments