Skip to content

Commit 4a2bfc1

Browse files
committed
fix(btree): detect leaf-revisit cycles during forward scans
The per-descent guard inside next_leaf_after is rebuilt on every call and only proves a single step doesn't revisit a page. An internal node whose leftmost_child and a later entry both point at the same child answers "after A comes B" and "after B comes A", so a scan built from individually acyclic steps can alternate between two leaves forever. Track visited leaves for the lifetime of the whole scan instead of per descent.
1 parent d6796b9 commit 4a2bfc1

2 files changed

Lines changed: 105 additions & 2 deletions

File tree

src/btree/tree/scan.rs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,27 @@
33
use crate::Result;
44
use crate::vfs::Vfs;
55

6-
use super::core::BTree;
6+
use super::core::{BTree, SeenPageIds};
7+
8+
/// Duplicate-leaf detector spanning one whole forward scan.
9+
///
10+
/// Every scan below is a loop over `next_leaf_after`, and that function builds
11+
/// a fresh descent guard per call, seeded only from the path it was handed. So
12+
/// it can prove one *step* does not revisit a page, and cannot see the scan as
13+
/// a whole revisiting a leaf it already yielded.
14+
///
15+
/// That gap is reachable from a single authenticated internal node, because
16+
/// the leaf successor is parent-mediated and resolves a child by its first
17+
/// occurrence: a node whose `leftmost_child` is `A` and whose entries are
18+
/// `[k1 → B, k2 → A]` answers "after A comes B" and "after B comes A". Each
19+
/// step is individually acyclic and each page authenticates; the scan simply
20+
/// alternates forever. Only a guard that lives as long as the scan ends it.
21+
///
22+
/// A healthy tree visits each leaf exactly once in key order, so this never
23+
/// fires on a well-formed scan.
24+
fn scan_guard() -> SeenPageIds {
25+
SeenPageIds::new("btree_scan")
26+
}
727

828
impl<V: Vfs> BTree<V> {
929
/// Forward range scan: `start` inclusive, `end` exclusive.
@@ -18,9 +38,11 @@ impl<V: Vfs> BTree<V> {
1838
return Ok(Vec::new());
1939
}
2040
let mut path = self.path_to_leaf_for_key(start).await?;
41+
let mut seen_leaves = scan_guard();
2142
let mut out: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
2243
loop {
2344
let leaf_id = *path.last().expect("non-empty path");
45+
seen_leaves.insert(leaf_id)?;
2446
let leaf = self.read_leaf(leaf_id).await?;
2547
for (k, v) in &leaf.records {
2648
if k.as_slice() >= end {
@@ -53,9 +75,11 @@ impl<V: Vfs> BTree<V> {
5375
// The empty key sorts below every stored key, so the descent lands on
5476
// the leftmost leaf.
5577
let mut path = self.path_to_leaf_for_key(&[]).await?;
78+
let mut seen_leaves = scan_guard();
5679
let mut out: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
5780
loop {
5881
let leaf_id = *path.last().expect("non-empty path");
82+
seen_leaves.insert(leaf_id)?;
5983
let leaf = self.read_leaf(leaf_id).await?;
6084
for (k, v) in &leaf.records {
6185
let val = self.resolve_leaf_value(v).await?;
@@ -90,9 +114,11 @@ impl<V: Vfs> BTree<V> {
90114
return Ok(Vec::new());
91115
}
92116
let mut path = self.path_to_leaf_for_key(start).await?;
117+
let mut seen_leaves = scan_guard();
93118
let mut out: Vec<(Vec<u8>, Vec<u8>)> = Vec::with_capacity(limit);
94119
loop {
95120
let leaf_id = *path.last().expect("non-empty path");
121+
seen_leaves.insert(leaf_id)?;
96122
let leaf = self.read_leaf(leaf_id).await?;
97123
for (k, v) in &leaf.records {
98124
if k.as_slice() < start {
@@ -166,9 +192,11 @@ impl<V: Vfs> BTree<V> {
166192
return Ok(Vec::new());
167193
}
168194
let mut path = self.path_to_leaf_for_key(prefix).await?;
195+
let mut seen_leaves = scan_guard();
169196
let mut out: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
170197
loop {
171198
let leaf_id = *path.last().expect("non-empty path");
199+
seen_leaves.insert(leaf_id)?;
172200
let leaf = self.read_leaf(leaf_id).await?;
173201
let mut past_prefix = false;
174202
for (k, v) in &leaf.records {

tests/btree_basic.rs

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use std::sync::mpsc;
33
use std::time::Duration;
44

55
use pagedb::btree::BTree;
6-
use pagedb::btree::internal::Internal;
6+
use pagedb::btree::internal::{Internal, InternalEntry};
77
use pagedb::btree::leaf::{Leaf, LeafValue};
88
use pagedb::btree::node::body_capacity;
99
use pagedb::btree::overflow::encode_overflow;
@@ -585,6 +585,81 @@ fn put_rejects_internal_child_cycle_without_hanging() {
585585
);
586586
}
587587

588+
/// A forward scan's leaf successor is parent-mediated, and a child is resolved
589+
/// by its *first* occurrence in the parent. An internal node that lists one
590+
/// child twice therefore answers "after A comes B" and "after B comes A", and
591+
/// every individual step is acyclic — the cycle only exists across steps. The
592+
/// per-descent guards inside `next_leaf_after` are rebuilt on each call and
593+
/// cannot see it, so the scan needs a guard of its own.
594+
#[test]
595+
fn collect_all_rejects_alternating_leaf_successors_without_hanging() {
596+
let (tx, rx) = mpsc::channel();
597+
598+
std::thread::spawn(move || {
599+
let runtime = tokio::runtime::Builder::new_current_thread()
600+
.enable_all()
601+
.build()
602+
.unwrap();
603+
let result = runtime.block_on(async {
604+
let pager = fresh_pager().await;
605+
let realm = RealmId::new([1; 16]);
606+
let (root_page_id, left_leaf, right_leaf) = (71u64, 72u64, 73u64);
607+
608+
for (page_id, key) in [(left_leaf, b"a"), (right_leaf, b"b")] {
609+
let mut leaf = Leaf::new();
610+
leaf.upsert(key, LeafValue::Inline(b"v".to_vec()));
611+
let mut body = vec![0u8; body_capacity(PAGE)];
612+
leaf.encode(&mut body).unwrap();
613+
pager
614+
.write_main_page(page_id, realm, PageKind::BTreeLeaf, &body)
615+
.await
616+
.unwrap();
617+
}
618+
619+
// `left_leaf` appears twice: as the leftmost child and again as the
620+
// child to the right of `right_leaf`.
621+
let internal = Internal {
622+
leftmost_child: left_leaf,
623+
entries: vec![
624+
InternalEntry {
625+
key: b"b".to_vec(),
626+
right_child: right_leaf,
627+
},
628+
InternalEntry {
629+
key: b"c".to_vec(),
630+
right_child: left_leaf,
631+
},
632+
],
633+
};
634+
let mut body = vec![0u8; body_capacity(PAGE)];
635+
internal.encode(&mut body).unwrap();
636+
pager
637+
.write_main_page(root_page_id, realm, PageKind::BTreeInternal, &body)
638+
.await
639+
.unwrap();
640+
641+
let tree = BTree::open(pager, realm, root_page_id, 74, PAGE);
642+
tree.collect_all().await.map(|_| ())
643+
});
644+
let _ = tx.send(result);
645+
});
646+
647+
let result = rx
648+
.recv_timeout(Duration::from_secs(5))
649+
.expect("scan leaf-revisit detection should return before the timeout");
650+
let error = result.expect_err("a scan that revisits a leaf must not be accepted");
651+
assert!(
652+
matches!(
653+
error,
654+
PagedbError::Corruption(CorruptionDetail::PageChainCycle {
655+
structure: "btree_scan",
656+
..
657+
})
658+
),
659+
"expected a scan PageChainCycle, got {error:?}"
660+
);
661+
}
662+
588663
#[test]
589664
fn bulk_load_rejects_separator_that_cannot_fit_without_hanging() {
590665
let (tx, rx) = mpsc::channel();

0 commit comments

Comments
 (0)