Skip to content

Commit 4e8ee7c

Browse files
committed
fix(btree): report precise chain-cycle and sibling-mismatch corruption
Replace the generic HeaderUnverifiable/IO stand-ins used for btree descent cycles, leaf sibling-link mismatches, and non-monotonic bulk_load input with dedicated PagedbError variants that name the walk (btree_descent, leaf_siblings, free_list) and the pages involved, so fsck and callers can act on what actually went wrong. Also stop discarding put_append's cached rightmost path before its monotonic check has passed, so a rejected append leaves the session exactly as it found it, and log (rather than silently drop) an overflow chain that can't be reclaimed on a failed put.
1 parent d6113f3 commit 4e8ee7c

9 files changed

Lines changed: 165 additions & 40 deletions

File tree

src/btree/tree/bulk.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,7 @@ impl<V: Vfs> BTree<V> {
3737

3838
for pair in pairs.windows(2) {
3939
if pair[0].0 >= pair[1].0 {
40-
return Err(PagedbError::Io(std::io::Error::new(
41-
std::io::ErrorKind::InvalidInput,
42-
"bulk_load input keys must be strictly increasing",
43-
)));
40+
return Err(PagedbError::BulkLoadNotMonotonic);
4441
}
4542
}
4643
for (key, value) in &pairs {

src/btree/tree/core.rs

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
use std::collections::{BTreeSet, HashMap};
44
use std::sync::Arc;
55

6-
use crate::errors::{CorruptionDetail, PagedbError};
6+
use crate::errors::PagedbError;
77
use crate::pager::format::page_kind::PageKind;
88
use crate::pager::{PageGuard, Pager};
99
use crate::vfs::Vfs;
@@ -14,18 +14,29 @@ use crate::btree::leaf::Leaf;
1414
use crate::btree::node::{NodeKind, body_capacity, read_header};
1515
use crate::btree::overflow;
1616

17+
/// Encoded size a value of `value_len` bytes will occupy in a leaf record,
18+
/// computed before the value is built.
19+
///
20+
/// The preflight counterpart to [`LeafValue::encoded_size`], and it must agree
21+
/// with it — both sides read the same constant rather than restating the
22+
/// layout.
1723
fn stored_leaf_value_encoded_size(value_len: usize, page_size: usize) -> Result<usize> {
1824
if value_len > overflow::inline_value_threshold(page_size) {
19-
Ok(2 + 8 + 8)
25+
Ok(crate::btree::leaf::OVERFLOW_REF_ENCODED_SIZE)
2026
} else {
2127
2usize
2228
.checked_add(value_len)
2329
.ok_or(PagedbError::PayloadTooLarge)
2430
}
2531
}
2632

27-
pub(super) fn malformed_btree_topology() -> PagedbError {
28-
PagedbError::corruption(CorruptionDetail::HeaderUnverifiable)
33+
/// A descent reached a page twice, or followed a zero child pointer.
34+
///
35+
/// Both mean the pointer graph has no terminator, which no honest writer can
36+
/// produce. Named for the walk rather than reported as a generic header
37+
/// failure, so `fsck` can say which page repeated and in which structure.
38+
pub(super) fn malformed_btree_topology(structure: &'static str, page_id: u64) -> PagedbError {
39+
PagedbError::page_chain_cycle(structure, page_id)
2940
}
3041

3142
// Keep common shallow descents stack-only and small; deeper trees spill into
@@ -35,22 +46,25 @@ const INLINE_SEEN_PAGE_IDS: usize = 4;
3546
/// Duplicate-page detector for B-tree descents. Shallow trees stay on the
3647
/// inline array; deeper trees preserve exact detection through the overflow set.
3748
pub(super) struct SeenPageIds {
49+
/// Which walk this is, so a rejection can name the structure it was in.
50+
structure: &'static str,
3851
inline: [u64; INLINE_SEEN_PAGE_IDS],
3952
len: usize,
4053
overflow: Option<BTreeSet<u64>>,
4154
}
4255

4356
impl SeenPageIds {
44-
pub(super) fn new() -> Self {
57+
pub(super) fn new(structure: &'static str) -> Self {
4558
Self {
59+
structure,
4660
inline: [0; INLINE_SEEN_PAGE_IDS],
4761
len: 0,
4862
overflow: None,
4963
}
5064
}
5165

52-
pub(super) fn from_existing(path: &[u64]) -> Result<Self> {
53-
let mut seen = Self::new();
66+
pub(super) fn from_existing(structure: &'static str, path: &[u64]) -> Result<Self> {
67+
let mut seen = Self::new(structure);
5468
for &page_id in path {
5569
seen.insert(page_id)?;
5670
}
@@ -59,7 +73,7 @@ impl SeenPageIds {
5973

6074
pub(super) fn insert(&mut self, page_id: u64) -> Result<()> {
6175
if page_id == 0 || !self.insert_inner(page_id) {
62-
return Err(malformed_btree_topology());
76+
return Err(malformed_btree_topology(self.structure, page_id));
6377
}
6478
Ok(())
6579
}

src/btree/tree/navigate.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ impl<V: Vfs> BTree<V> {
195195
pub(super) async fn path_to_leaf_for_key(&self, key: &[u8]) -> Result<Vec<u64>> {
196196
let mut path = Vec::new();
197197
let mut page_id = self.root_page_id;
198-
let mut seen = SeenPageIds::new();
198+
let mut seen = SeenPageIds::new("btree_descent");
199199
loop {
200200
seen.insert(page_id)?;
201201
path.push(page_id);
@@ -226,7 +226,7 @@ impl<V: Vfs> BTree<V> {
226226
// Root is a leaf; no next leaf.
227227
return Ok(None);
228228
}
229-
let mut seen = SeenPageIds::from_existing(path)?;
229+
let mut seen = SeenPageIds::from_existing("btree_descent", path)?;
230230
let mut child = path[path.len() - 1];
231231
for i in (0..path.len() - 1).rev() {
232232
let (guard, _kind) = self.read_node_guard(path[i]).await?;
@@ -264,7 +264,7 @@ impl<V: Vfs> BTree<V> {
264264
pub(super) async fn path_to_rightmost_leaf(&self) -> Result<Vec<u64>> {
265265
let mut path = Vec::new();
266266
let mut page_id = self.root_page_id;
267-
let mut seen = SeenPageIds::new();
267+
let mut seen = SeenPageIds::new("btree_descent");
268268
loop {
269269
seen.insert(page_id)?;
270270
path.push(page_id);

src/btree/tree/read.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ impl<V: Vfs> BTree<V> {
9393
NodeKind::Internal => InternalAccessor::new(start_guard.body_ref())?.child_for(key),
9494
};
9595
// Descend from the first child onward. Subsequent guards are owned.
96-
let mut seen = SeenPageIds::new();
96+
let mut seen = SeenPageIds::new("btree_descent");
9797
seen.insert(start_page_id)?;
9898
let mut page_id = next_page_id;
9999
loop {

src/btree/tree/write.rs

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
use std::sync::Arc;
44

55
use crate::Result;
6-
use crate::errors::{CorruptionDetail, PagedbError};
6+
use crate::errors::PagedbError;
77
use crate::vfs::Vfs;
88

99
use crate::btree::leaf::{Leaf, LeafValue};
@@ -49,15 +49,28 @@ impl<V: Vfs> BTree<V> {
4949
}
5050
}
5151

52+
/// Return the pages of a chain that was written for a value the caller
53+
/// never got to commit.
54+
///
55+
/// Best-effort by design: this runs on an error path, and a failure here
56+
/// must not replace the error the caller actually needs to see. It is
57+
/// logged rather than swallowed, because the cost of giving up is a page
58+
/// leak that nothing else will notice.
5259
async fn discard_uncommitted_value(&mut self, value: Option<LeafValue>) {
53-
if let Some(LeafValue::Overflow { root_page_id, .. }) = value {
54-
if let Ok(page_ids) =
55-
overflow::collect_chain(&self.pager, self.realm_id, root_page_id).await
56-
{
60+
let Some(LeafValue::Overflow { root_page_id, .. }) = value else {
61+
return;
62+
};
63+
match overflow::collect_chain(&self.pager, self.realm_id, root_page_id).await {
64+
Ok(page_ids) => {
5765
for page_id in page_ids {
5866
self.free_page(page_id);
5967
}
6068
}
69+
Err(error) => tracing::warn!(
70+
root_page_id,
71+
error = %error,
72+
"could not reclaim the overflow chain of an uncommitted value; its pages are leaked"
73+
),
6174
}
6275
}
6376

@@ -236,7 +249,11 @@ impl<V: Vfs> BTree<V> {
236249
/// # Errors
237250
///
238251
/// Returns [`PagedbError::AppendNotMonotonic`] if `key` is not strictly
239-
/// greater than the previously-appended key in this `BTree` session.
252+
/// greater than the previously-appended key in this `BTree` session, or —
253+
/// on the first append of a session, when there is no such key yet — not
254+
/// strictly greater than the largest key already in the tree. Both are the
255+
/// same invariant: the cached rightmost path is only meaningful while every
256+
/// append lands to the right of everything before it.
240257
/// Intended for op-logs, time-series indexes, FTS posting-list builds —
241258
/// any workload where the embedder can guarantee monotonic-key insert.
242259
pub async fn put_append(&mut self, key: &[u8], value: &[u8]) -> Result<()> {
@@ -262,11 +279,12 @@ impl<V: Vfs> BTree<V> {
262279
}
263280

264281
// Fast path: cached rightmost path is valid → skip descent.
265-
let path = if let Some(cached) = self.append_cached_path.take() {
266-
cached
267-
} else {
268-
self.path_to_rightmost_leaf().await?
282+
let path = match &self.append_cached_path {
283+
Some(cached) => cached.clone(),
284+
None => self.path_to_rightmost_leaf().await?,
269285
};
286+
// Checked before the cached path is consumed: a rejected append must
287+
// leave the session exactly as it found it, cache included.
270288
if self.append_last_key.is_none()
271289
&& self
272290
.max_key_at_path(&path)
@@ -275,6 +293,7 @@ impl<V: Vfs> BTree<V> {
275293
{
276294
return Err(PagedbError::AppendNotMonotonic);
277295
}
296+
self.append_cached_path = None;
278297
let leaf_value = self.leaf_value_for(value).await?;
279298
let path_for_retry = path.clone();
280299
let split = self.put_at_path(path, key, leaf_value).await?;
@@ -394,7 +413,7 @@ impl<V: Vfs> BTree<V> {
394413
// their sibling links remain zero until flush.
395414
let mut path = self.path_to_leaf_for_key(start).await?;
396415
let mut keys_to_delete: Vec<Vec<u8>> = Vec::new();
397-
let mut seen_leaves = SeenPageIds::new();
416+
let mut seen_leaves = SeenPageIds::new("leaf_siblings");
398417
'outer: loop {
399418
let leaf_page_id = *path.last().expect("non-empty path");
400419
seen_leaves.insert(leaf_page_id)?;
@@ -416,9 +435,11 @@ impl<V: Vfs> BTree<V> {
416435
{
417436
path = parent_next;
418437
}
419-
_ => {
420-
return Err(PagedbError::corruption(
421-
CorruptionDetail::HeaderUnverifiable,
438+
(right_sibling, parent_next) => {
439+
return Err(PagedbError::leaf_sibling_mismatch(
440+
leaf_page_id,
441+
right_sibling,
442+
parent_next.and_then(|path| path.last().copied()),
422443
));
423444
}
424445
}

src/errors.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,16 @@ pub enum PagedbError {
127127
#[error("put_append called with non-monotonic key")]
128128
AppendNotMonotonic,
129129

130+
/// `BTree::bulk_load` was handed input whose keys are not strictly
131+
/// increasing — descending, or repeating the same key.
132+
///
133+
/// The sibling of [`Self::AppendNotMonotonic`]: same invariant, different
134+
/// entry point. Bulk load builds the tree bottom-up from the input order
135+
/// itself, so unsorted input does not merely misplace a record — it would
136+
/// produce a tree whose separators do not describe its leaves.
137+
#[error("bulk_load keys must be strictly increasing")]
138+
BulkLoadNotMonotonic,
139+
130140
/// The deferred-free backlog exceeds the configured threshold and
131141
/// active reader pins prevent draining it.
132142
#[non_exhaustive]
@@ -306,6 +316,33 @@ pub enum CorruptionDetail {
306316
/// has no terminator. Distinct from a truncated chain: the links
307317
/// authenticate, they just form a loop.
308318
OverflowChainCycle { root_page_id: u64, page_id: u64 },
319+
/// A linked page structure revisited a page it had already walked, so the
320+
/// walk has no terminator.
321+
///
322+
/// The overflow-chain form is [`Self::OverflowChainCycle`], which can also
323+
/// name the chain's root. This is the general case — a B+ tree root-to-leaf
324+
/// descent, a leaf sibling walk, or the durable free-list chain — where
325+
/// `structure` says which walk found the loop and `page_id` is the page it
326+
/// reached twice. Every link authenticates; they simply form a loop, which
327+
/// no honest writer can produce.
328+
PageChainCycle {
329+
structure: &'static str,
330+
page_id: u64,
331+
},
332+
/// A leaf's persisted right-sibling link disagrees with the leaf its parent
333+
/// path says comes next.
334+
///
335+
/// Both encode "the next leaf", so both must name it. A disagreement means
336+
/// one of them outlived the page it points at — a sibling link left behind
337+
/// by a page that was freed and handed to another node, or a parent whose
338+
/// child pointer was rewritten without its leaves. `parent_next` is `None`
339+
/// when the parent path has no successor at all yet the leaf still claims
340+
/// one.
341+
LeafSiblingMismatch {
342+
leaf_page_id: u64,
343+
right_sibling: u64,
344+
parent_next: Option<u64>,
345+
},
309346
/// One physical page was reached twice in a single traversal under two
310347
/// incompatible page kinds.
311348
///
@@ -426,6 +463,29 @@ impl PagedbError {
426463
})
427464
}
428465

466+
/// Canonical constructor for a cyclic linked page structure. `structure`
467+
/// names the walk that found the loop — `"btree_descent"`,
468+
/// `"leaf_siblings"`, `"free_list"`.
469+
#[must_use]
470+
pub const fn page_chain_cycle(structure: &'static str, page_id: u64) -> Self {
471+
Self::Corruption(CorruptionDetail::PageChainCycle { structure, page_id })
472+
}
473+
474+
/// Canonical constructor for a leaf whose sibling link and parent path
475+
/// disagree about which leaf comes next.
476+
#[must_use]
477+
pub const fn leaf_sibling_mismatch(
478+
leaf_page_id: u64,
479+
right_sibling: u64,
480+
parent_next: Option<u64>,
481+
) -> Self {
482+
Self::Corruption(CorruptionDetail::LeafSiblingMismatch {
483+
leaf_page_id,
484+
right_sibling,
485+
parent_next,
486+
})
487+
}
488+
429489
/// Canonical constructor for a cyclic overflow chain.
430490
#[must_use]
431491
pub const fn overflow_chain_cycle(root_page_id: u64, page_id: u64) -> Self {

src/pager/freelist.rs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,7 @@ pub async fn read_chain<V: Vfs + Clone>(
5050
let mut page = head;
5151
while page != 0 {
5252
if !seen.insert(page) {
53-
return Err(PagedbError::corruption(
54-
crate::errors::CorruptionDetail::CatalogRowInvalid {
55-
field: "freelist chain contains a cycle",
56-
},
57-
));
53+
return Err(PagedbError::page_chain_cycle("free_list", page));
5854
}
5955
let guard = pager.read_main_page(page, realm_id, PageKind::Free).await?;
6056
let body = guard.body_ref();
@@ -238,6 +234,15 @@ mod tests {
238234
.await
239235
.expect("cycle detection should return before timeout")
240236
.expect_err("free-list cycles must be corruption");
241-
assert!(matches!(error, PagedbError::Corruption(_)));
237+
assert!(
238+
matches!(
239+
error,
240+
PagedbError::Corruption(crate::errors::CorruptionDetail::PageChainCycle {
241+
structure: "free_list",
242+
page_id: 10,
243+
})
244+
),
245+
"expected a free-list PageChainCycle naming page 10, got {error:?}"
246+
);
242247
}
243248
}

0 commit comments

Comments
 (0)