Skip to content

Commit 3287ab0

Browse files
committed
fix(rekey): re-seal the catalog and reclaim its superseded pages
Rekey previously re-sealed main.db by scanning every allocated page between the reserved header and the allocation cursor, which skipped the catalog until its own commit. It also discarded the pages each catalog copy-on-write rewrite superseded instead of folding them into the free list, leaking them permanently once the source epoch was retired. Replace the residual-page scan with a bounded, deduplicating walk of the live catalog tree, and route every rekey-time catalog rewrite's freed pages through the durable free list before recording the new allocation cursor. Bound the memory a rekey walk pins by flushing the buffer pool mid-walk once the dirty set reaches the configured budget, since dirty cache entries are never evicted.
1 parent ee1034a commit 3287ab0

5 files changed

Lines changed: 288 additions & 57 deletions

File tree

src/pager/cache.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,16 @@ impl PageCache {
269269
self.dirty.contains(&key)
270270
}
271271

272+
/// How many entries are currently dirty, across every file.
273+
///
274+
/// Dirty entries are never evicted, so this is the part of the cache that
275+
/// can push it past its configured capacity. Callers that seal pages in a
276+
/// long loop watch this to decide when they must flush.
277+
#[must_use]
278+
pub fn dirty_len(&self) -> usize {
279+
self.dirty.len()
280+
}
281+
272282
/// Sorted iterator over the dirty page ids for one file, ascending by
273283
/// `page_id`. Used by the Pager to flush in physical-id order.
274284
#[must_use]

src/pager/core.rs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,15 @@ impl<V: Vfs> Pager<V> {
608608
///
609609
/// `read_main_page` now always routes to the on-disk epoch/cipher, so this
610610
/// simply reads then marks dirty.
611+
///
612+
/// The dirty set is kept inside the configured buffer-pool budget. A rekey
613+
/// re-seals every page a root can reach, and dirty entries are never
614+
/// evicted (the cache's eviction scan skips them), so without an intermediate
615+
/// flush the walk would pin the whole decrypted database in memory no
616+
/// matter what budget the caller asked for. Flushing mid-walk is safe and
617+
/// idempotent: the active epoch is already the target, so a page re-read
618+
/// after being re-sealed opens under the same key, and a crash resumes
619+
/// from the durable rekey intent.
611620
pub async fn rewrite_page_under_current_epoch(
612621
&self,
613622
page_id: u64,
@@ -618,10 +627,15 @@ impl<V: Vfs> Pager<V> {
618627
.read_main_page(page_id, realm_id, expected_kind)
619628
.await?;
620629
let file = FileKey::Main;
621-
let mut cache = self.inner.cache_for_key(file).lock();
622-
cache.mark_dirty((file, page_id));
623-
drop(cache);
630+
let over_budget = {
631+
let mut cache = self.inner.cache_for_key(file).lock();
632+
cache.mark_dirty((file, page_id));
633+
cache.dirty_len() >= self.cfg.buffer_pool_pages.max(1)
634+
};
624635
drop(guard);
636+
if over_budget {
637+
self.flush_main(realm_id).await?;
638+
}
625639
Ok(())
626640
}
627641

src/txn/db/rekey/main.rs

Lines changed: 134 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use crate::errors::PagedbError;
1414
use crate::pager::PageKind;
1515
use crate::pager::header::commit_header;
1616
use 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)]
2020
use super::super::core::RekeyTestFault;
@@ -28,20 +28,30 @@ use super::intent::{intent_proof, migrate_legacy, validate_intent_for_current_ci
2828
/// retention runs.
2929
const 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();

src/txn/db/rekey/segments.rs

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use crate::vfs::Vfs;
1818
#[cfg(test)]
1919
use super::super::core::RekeyTestFault;
2020
use super::super::core::{Db, WriterState};
21+
use super::main::RekeyCatalogCommit;
2122

2223
struct SegmentEntry {
2324
key: Vec<u8>,
@@ -249,6 +250,7 @@ impl<V: Vfs + Clone> Db<V> {
249250
tree.put(&source.key, &Catalog::encode_segment_meta(replacement))
250251
.await?;
251252
tree.flush().await?;
253+
let freed_pages = tree.drain_freed();
252254
let effects = [
253255
SegmentSideEffect::Promote {
254256
segment_id: replacement.segment_id,
@@ -260,11 +262,14 @@ impl<V: Vfs + Clone> Db<V> {
260262
];
261263
self.commit_rekey_catalog_root(
262264
state,
263-
tree.root_page_id(),
264-
tree.next_page_id(),
265+
RekeyCatalogCommit {
266+
catalog_root_page_id: tree.root_page_id(),
267+
next_page_id: tree.next_page_id(),
268+
freed_pages: &freed_pages,
269+
effects: &effects,
270+
},
265271
intent.target_mk_epoch,
266272
target_hk,
267-
&effects,
268273
)
269274
.await
270275
.map(|_| ())
@@ -285,13 +290,17 @@ impl<V: Vfs + Clone> Db<V> {
285290
)
286291
.await?;
287292
tree.flush().await?;
293+
let freed_pages = tree.drain_freed();
288294
self.commit_rekey_catalog_root(
289295
state,
290-
tree.root_page_id(),
291-
tree.next_page_id(),
296+
RekeyCatalogCommit {
297+
catalog_root_page_id: tree.root_page_id(),
298+
next_page_id: tree.next_page_id(),
299+
freed_pages: &freed_pages,
300+
effects: &[],
301+
},
292302
header_epoch,
293303
header_hk,
294-
&[],
295304
)
296305
.await?;
297306
#[cfg(test)]
@@ -311,13 +320,17 @@ impl<V: Vfs + Clone> Db<V> {
311320
.delete(&Catalog::rekey_segment_progress_key(source_id))
312321
.await?;
313322
tree.flush().await?;
323+
let freed_pages = tree.drain_freed();
314324
self.commit_rekey_catalog_root(
315325
state,
316-
tree.root_page_id(),
317-
tree.next_page_id(),
326+
RekeyCatalogCommit {
327+
catalog_root_page_id: tree.root_page_id(),
328+
next_page_id: tree.next_page_id(),
329+
freed_pages: &freed_pages,
330+
effects: &[],
331+
},
318332
header_epoch,
319333
header_hk,
320-
&[],
321334
)
322335
.await?;
323336
#[cfg(test)]

0 commit comments

Comments
 (0)