Skip to content

Commit 46f7a8f

Browse files
committed
fix(rekey): stream retained-history roots in bounded batches
Rekeying the commit-history index used to collect every retained row into memory before rewriting the roots it names, making resident cost proportional to retention depth. Stream the rows in fixed-size batches via a new bounded scan primitive instead, and detect a page reached under two incompatible kinds during the shared traversal as a reported corruption rather than a generic illegal-kind error.
1 parent f494811 commit 46f7a8f

5 files changed

Lines changed: 495 additions & 91 deletions

File tree

src/btree/tree/maintenance.rs

Lines changed: 284 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,22 @@ impl<V: Vfs> BTree<V> {
2828

2929
/// Rekey this tree while sharing traversal state with other live roots.
3030
///
31-
/// Retained snapshots commonly share most of their pages. A caller walking
32-
/// several roots supplies one page-kind map so each physical page is
33-
/// authenticated and rewritten exactly once without hiding a cross-kind
34-
/// alias.
31+
/// Copy-on-write snapshots share most of their physical pages, so a caller
32+
/// rewriting several roots — the live tree plus every root a retained
33+
/// commit-history row still names — passes one map and each page is
34+
/// authenticated and re-sealed exactly once. Without it the work is
35+
/// `O(roots × pages)` of AEAD rather than `O(unique pages)`.
36+
///
37+
/// The map holds each page's *kind*, not just its id, because the two
38+
/// failure modes are not the same. A page already walked as a node and
39+
/// referenced again as a node is an ordinary snapshot share and is skipped;
40+
/// the same id presented under a different role means one of the two
41+
/// references survived the page being freed and reused, and is reported as
42+
/// [`CorruptionDetail::PageKindAliased`](crate::errors::CorruptionDetail::PageKindAliased).
43+
/// A plain id set could not tell them apart.
44+
///
45+
/// Returns the number of pages this call rewrote — pages an earlier walk
46+
/// already covered are not counted twice.
3547
pub(crate) async fn rekey_walk_unique(
3648
&self,
3749
visited: &mut BTreeMap<u64, PageKind>,
@@ -48,79 +60,108 @@ impl<V: Vfs> BTree<V> {
4860
page_id,
4961
));
5062
}
51-
if let Some(kind) = visited.get(&page_id) {
52-
match kind {
63+
if let Some(&walked_as) = visited.get(&page_id) {
64+
match walked_as {
5365
PageKind::BTreeLeaf | PageKind::BTreeInternal => continue,
54-
_ => return Err(PagedbError::IllegalPageKind),
66+
other => {
67+
// Named for the role, not a kind: which of the two node
68+
// kinds this page is has not been read yet, and cannot
69+
// be — the alias is decided before any read.
70+
return Err(PagedbError::page_kind_aliased(
71+
page_id,
72+
other.name(),
73+
"btree_node",
74+
));
75+
}
5576
}
5677
}
57-
// Determine kind by reading the node under its own header kind byte.
58-
let (is_leaf, page_kind, body_bytes) = {
59-
let (g, page_kind) = self.pager.read_main_node(page_id, self.realm_id).await?;
60-
let body = g.body();
61-
let header = read_header(&body)?;
62-
let is_leaf = header.kind == NodeKind::Leaf;
63-
(is_leaf, page_kind, body.to_vec())
78+
// `read_node_guard` is the only accessor that proves the
79+
// authenticated envelope kind and the encrypted body header agree.
80+
// This walk both records a page's kind and re-seals the page under
81+
// it, so taking those two from different sources would let a
82+
// mis-routed page be laundered into a freshly authenticated one and
83+
// would leave `visited` describing a kind that is no longer on disk.
84+
let (guard, node_kind) = self.read_node_guard(page_id).await?;
85+
let page_kind = match node_kind {
86+
NodeKind::Leaf => PageKind::BTreeLeaf,
87+
NodeKind::Internal => PageKind::BTreeInternal,
6488
};
6589
visited.insert(page_id, page_kind);
6690

67-
if is_leaf {
68-
// Collect overflow chains referenced by this leaf.
69-
let leaf = Leaf::decode(&body_bytes)?;
70-
for (_k, v) in &leaf.records {
71-
if let LeafValue::Overflow {
72-
root_page_id: ov_root,
73-
..
74-
} = v
75-
{
91+
match node_kind {
92+
NodeKind::Leaf => {
93+
let leaf = Leaf::decode(guard.body_ref())?;
94+
let overflow_roots: Vec<u64> = leaf
95+
.records
96+
.iter()
97+
.filter_map(|(_, value)| match value {
98+
LeafValue::Overflow { root_page_id, .. } => Some(*root_page_id),
99+
LeafValue::Inline(_) => None,
100+
})
101+
.collect();
102+
drop(guard);
103+
104+
for overflow_root in overflow_roots {
76105
count += self
77-
.rekey_overflow_unique(page_id, *ov_root, visited)
106+
.rekey_overflow_unique(page_id, overflow_root, visited)
78107
.await?;
79108
}
80109
}
81-
// Rewrite the leaf page.
82-
self.pager
83-
.rewrite_page_under_current_epoch(page_id, self.realm_id, PageKind::BTreeLeaf)
84-
.await?;
85-
count += 1;
86-
} else {
87-
// Internal node: push children onto stack.
88-
let internal = internal::Internal::decode(&body_bytes)?;
89-
if internal.leftmost_child != 0 {
90-
stack.push((page_id, internal.leftmost_child));
91-
}
92-
for entry in &internal.entries {
93-
if entry.right_child != 0 {
94-
stack.push((page_id, entry.right_child));
110+
NodeKind::Internal => {
111+
let node = internal::Internal::decode(guard.body_ref())?;
112+
drop(guard);
113+
114+
// A zero child id is an absent slot, not a pointer.
115+
if node.leftmost_child != 0 {
116+
stack.push((page_id, node.leftmost_child));
117+
}
118+
for entry in &node.entries {
119+
if entry.right_child != 0 {
120+
stack.push((page_id, entry.right_child));
121+
}
95122
}
96123
}
97-
// Rewrite the internal page.
98-
self.pager
99-
.rewrite_page_under_current_epoch(
100-
page_id,
101-
self.realm_id,
102-
PageKind::BTreeInternal,
103-
)
104-
.await?;
105-
count += 1;
106124
}
125+
126+
self.pager
127+
.rewrite_page_under_current_epoch(page_id, self.realm_id, page_kind)
128+
.await?;
129+
count += 1;
107130
}
108131
Ok(count)
109132
}
110133

134+
/// Rewrite the overflow chain rooted at `root` — referenced by leaf
135+
/// `leaf_page_id` — returning the number of pages this call rewrote.
136+
///
137+
/// Overflow roots are reference-counted, so a chain reached a second time
138+
/// through a different leaf (in this tree or in another snapshot's tree)
139+
/// arrives at the *same* root and is skipped whole. A chain *page* reached
140+
/// twice therefore cannot be a legitimate share: either the chain loops, or
141+
/// two distinct roots claim one page. Both mean the chain has no honest
142+
/// terminator, which is why neither is treated as a stopping condition.
111143
async fn rekey_overflow_unique(
112144
&self,
113145
leaf_page_id: u64,
114146
root: u64,
115147
visited: &mut BTreeMap<u64, PageKind>,
116148
) -> Result<u64> {
149+
// Unlike an internal child slot or a chain terminator, zero is not a
150+
// valid overflow root: an `Overflow` leaf value always owns at least
151+
// its root page.
117152
if is_reserved(root) {
118153
return Err(PagedbError::reserved_page_referenced(leaf_page_id, root));
119154
}
120-
if let Some(kind) = visited.get(&root) {
121-
return match kind {
155+
if let Some(&walked_as) = visited.get(&root) {
156+
return match walked_as {
157+
// Already rewritten with its whole chain, by a leaf holding the
158+
// other reference to this refcounted value.
122159
PageKind::OverflowRoot => Ok(0),
123-
_ => Err(PagedbError::IllegalPageKind),
160+
other => Err(PagedbError::page_kind_aliased(
161+
root,
162+
other.name(),
163+
PageKind::OverflowRoot.name(),
164+
)),
124165
};
125166
}
126167
visited.insert(root, PageKind::OverflowRoot);
@@ -135,10 +176,14 @@ impl<V: Vfs> BTree<V> {
135176
if is_reserved(next) {
136177
return Err(PagedbError::reserved_page_referenced(root, next));
137178
}
138-
if let Some(kind) = visited.get(&next) {
139-
return match kind {
179+
if let Some(&walked_as) = visited.get(&next) {
180+
return match walked_as {
140181
PageKind::Overflow => Err(PagedbError::overflow_chain_cycle(root, next)),
141-
_ => Err(PagedbError::IllegalPageKind),
182+
other => Err(PagedbError::page_kind_aliased(
183+
next,
184+
other.name(),
185+
PageKind::Overflow.name(),
186+
)),
142187
};
143188
}
144189
visited.insert(next, PageKind::Overflow);
@@ -419,3 +464,189 @@ impl<V: Vfs> BTree<V> {
419464
None
420465
}
421466
}
467+
468+
#[cfg(test)]
469+
mod tests {
470+
use std::collections::BTreeMap;
471+
472+
use crate::errors::CorruptionDetail;
473+
use crate::pager::format::page_kind::PageKind;
474+
use crate::vfs::memory::MemVfs;
475+
use crate::{Db, PagedbError, RealmId};
476+
477+
use super::super::core::BTree;
478+
479+
const PAGE: usize = 4096;
480+
const KEK: [u8; 32] = [0x7B; 32];
481+
const REALM: RealmId = RealmId::new([0x7C; 16]);
482+
483+
/// A tree deep enough to hold internal nodes, plus one overflow value so
484+
/// the walk covers both the node and the chain path.
485+
async fn populated_db() -> Db<MemVfs> {
486+
let db = Db::open_internal(MemVfs::new(), KEK, PAGE, REALM)
487+
.await
488+
.unwrap();
489+
let mut txn = db.begin_write().await.unwrap();
490+
for index in 0u16..256 {
491+
txn.put(format!("key-{index:04}").as_bytes(), &index.to_le_bytes())
492+
.await
493+
.unwrap();
494+
}
495+
txn.put(b"overflowing", &vec![0x33; PAGE * 3])
496+
.await
497+
.unwrap();
498+
txn.commit().await.unwrap();
499+
db
500+
}
501+
502+
async fn data_tree(db: &Db<MemVfs>) -> BTree<MemVfs> {
503+
let state = db.writer.lock().await;
504+
BTree::open(
505+
db.pager.clone(),
506+
REALM,
507+
state.root_page_id,
508+
state.next_page_id,
509+
db.page_size,
510+
)
511+
}
512+
513+
/// The whole point of the shared map: snapshots overlap almost entirely, so
514+
/// a second root covering already-walked pages must cost nothing. Without
515+
/// it, rekey re-authenticates and re-seals every shared page once per
516+
/// retained root.
517+
#[tokio::test(flavor = "current_thread")]
518+
async fn a_second_walk_over_shared_pages_rewrites_nothing() {
519+
let db = populated_db().await;
520+
let tree = data_tree(&db).await;
521+
522+
let mut shared = BTreeMap::new();
523+
let first = tree.rekey_walk_unique(&mut shared).await.unwrap();
524+
assert!(
525+
first > 1,
526+
"fixture must span more than a single page, walked {first}"
527+
);
528+
let recorded = shared.len();
529+
530+
let second = tree.rekey_walk_unique(&mut shared).await.unwrap();
531+
assert_eq!(
532+
second, 0,
533+
"every page was already covered by the first walk"
534+
);
535+
assert_eq!(
536+
shared.len(),
537+
recorded,
538+
"a repeat walk must not discover new pages"
539+
);
540+
}
541+
542+
/// Each walk on its own map does the full work — the dedup above is a
543+
/// property of the shared map, not of the tree being walked twice.
544+
#[tokio::test(flavor = "current_thread")]
545+
async fn an_unshared_walk_covers_every_page_again() {
546+
let db = populated_db().await;
547+
let tree = data_tree(&db).await;
548+
549+
let first = tree.rekey_walk_unique(&mut BTreeMap::new()).await.unwrap();
550+
let second = tree.rekey_walk().await.unwrap();
551+
assert_eq!(first, second);
552+
}
553+
554+
/// The map records kinds, not just ids, so that a page reached under two
555+
/// different roles is a reported alias rather than a silent skip. Here a
556+
/// real overflow root — already walked as `OverflowRoot` — is presented as
557+
/// a B+ tree root, which is what a freed-and-reused page looks like from
558+
/// the second reference.
559+
#[tokio::test(flavor = "current_thread")]
560+
async fn a_page_reached_under_two_kinds_is_reported_not_skipped() {
561+
let db = populated_db().await;
562+
let tree = data_tree(&db).await;
563+
564+
let mut shared = BTreeMap::new();
565+
tree.rekey_walk_unique(&mut shared).await.unwrap();
566+
let (&overflow_root, _) = shared
567+
.iter()
568+
.find(|(_, kind)| **kind == PageKind::OverflowRoot)
569+
.expect("the fixture stores one overflow value");
570+
571+
let aliased = BTree::open(
572+
db.pager.clone(),
573+
REALM,
574+
overflow_root,
575+
overflow_root + 1,
576+
db.page_size,
577+
);
578+
let error = aliased.rekey_walk_unique(&mut shared).await.unwrap_err();
579+
assert!(
580+
matches!(
581+
error,
582+
PagedbError::Corruption(CorruptionDetail::PageKindAliased {
583+
page_id,
584+
walked_as: "overflow_root",
585+
referenced_as: "btree_node",
586+
}) if page_id == overflow_root
587+
),
588+
"expected an alias naming the page and both roles, got {error:?}"
589+
);
590+
}
591+
592+
/// Rekey re-seals each page under the kind it walked it as. Taking that
593+
/// kind from the body header while the envelope says otherwise would launder
594+
/// a mis-routed page into a freshly authenticated one, so the walk must go
595+
/// through the accessor that proves the two agree.
596+
#[tokio::test(flavor = "current_thread")]
597+
async fn a_page_whose_envelope_contradicts_its_body_is_never_re_sealed() {
598+
let db = populated_db().await;
599+
let (leaf_page_id, forged) = {
600+
let state = db.writer.lock().await;
601+
let tree = BTree::open(
602+
db.pager.clone(),
603+
REALM,
604+
state.root_page_id,
605+
state.next_page_id,
606+
db.page_size,
607+
);
608+
let mut reachable = std::collections::BTreeSet::new();
609+
tree.collect_all_page_ids(&mut reachable).await.unwrap();
610+
let mut leaf = None;
611+
for &page_id in &reachable {
612+
if let Ok((_, PageKind::BTreeLeaf)) = db.pager.read_main_node(page_id, REALM).await
613+
{
614+
leaf = Some(page_id);
615+
break;
616+
}
617+
}
618+
(leaf.expect("the fixture has leaves"), state.next_page_id)
619+
};
620+
621+
// Copy a live leaf's bytes under the internal-node envelope kind: the
622+
// body stays structurally valid, so the only defect is the routing.
623+
let guard = db
624+
.pager
625+
.read_main_node(leaf_page_id, REALM)
626+
.await
627+
.unwrap()
628+
.0;
629+
let body = guard.body_ref().to_vec();
630+
drop(guard);
631+
db.pager
632+
.write_main_page(forged, REALM, PageKind::BTreeInternal, &body)
633+
.await
634+
.unwrap();
635+
db.pager.flush_main(REALM).await.unwrap();
636+
db.pager.reset_main_pages();
637+
638+
let tree = BTree::open(db.pager.clone(), REALM, forged, forged + 1, db.page_size);
639+
let error = tree.rekey_walk().await.unwrap_err();
640+
assert!(
641+
matches!(
642+
error,
643+
PagedbError::Corruption(CorruptionDetail::NodeKindMismatch {
644+
page_id: Some(page_id),
645+
expected: "internal",
646+
found: "leaf",
647+
}) if page_id == forged
648+
),
649+
"expected the kind disagreement to stop the walk, got {error:?}"
650+
);
651+
}
652+
}

0 commit comments

Comments
 (0)