Skip to content

Commit 0f6a1a2

Browse files
committed
fix(catalog): validate counter and segment rows in bounded batches
Reading an entire prefix scan into memory to validate it at open, or to sum segment bytes for stats(), sizes an allocation by however many counters or segments the embedder happens to have. Add collect_prefix_batch_from to the B+ tree for bounded, resumable prefix scans, and stream both catalog walks through it. Also replace a saturating segment-bytes sum with a checked one that reports overflow instead of silently wrapping.
1 parent 6fb4fd9 commit 0f6a1a2

4 files changed

Lines changed: 93 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ version, since none exists yet.
4040
- **Online rekey** — rekey the database under a new key with mixed-cipher and
4141
mixed-epoch page coexistence (no full-file migration).
4242
- **Handle modes**`Standalone`, `Follower`, `ReadOnly`, and `Observer`.
43+
- **Failures report themselves** — an unreadable free-list chain, main file, or
44+
segment catalog fails `stats()` instead of reporting zero; compaction never
45+
skips a catalog entry whose file it cannot open; segment open distinguishes a
46+
missing file from a permission or backend error; and only genuine contention
47+
is reported as contention. Persisted named-counter rows are validated at open,
48+
and commit-history keys are rejected unless exactly eight bytes.
4349

4450
### Known limitations
4551

src/btree/tree/scan.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,29 @@ impl<V: Vfs> BTree<V> {
114114
}
115115
}
116116

117+
/// Collect at most `limit` records at or after `start` whose keys still
118+
/// carry `prefix`, in ascending key order.
119+
///
120+
/// The bounded counterpart to [`Self::scan_prefix`]. Rows sharing a prefix
121+
/// sort contiguously, so the first key outside it ends the range — the scan
122+
/// stops there rather than reading the rest of the tree.
123+
///
124+
/// Resume as with [`Self::collect_batch_from`]: pass the last returned key
125+
/// with a `0x00` byte appended. A batch shorter than `limit` means the
126+
/// prefix range ended.
127+
pub async fn collect_prefix_batch_from(
128+
&self,
129+
prefix: &[u8],
130+
start: &[u8],
131+
limit: usize,
132+
) -> Result<Vec<(Vec<u8>, Vec<u8>)>> {
133+
let mut batch = self.collect_batch_from(start, limit).await?;
134+
if let Some(end) = batch.iter().position(|(key, _)| !key.starts_with(prefix)) {
135+
batch.truncate(end);
136+
}
137+
Ok(batch)
138+
}
139+
117140
/// Return the smallest key in the tree, or `None` if the tree is empty.
118141
/// Descends the leftmost spine only — O(tree height), not O(tree size).
119142
pub async fn first_key(&self) -> Result<Option<Vec<u8>>> {

src/txn/db/catalog.rs

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ use super::core::{
1515
encode_root_ref,
1616
};
1717

18+
/// Named-counter rows read per batch while validating them at open. Rows are a
19+
/// fixed-width authenticated value, so this is a few KiB resident regardless of
20+
/// how many counters the embedder has named.
21+
const COUNTER_ROW_BATCH: usize = 512;
22+
1823
impl<V: Vfs + Clone> Db<V> {
1924
/// The oldest commit id still retained in the commit-history index, or
2025
/// `None` when history is disabled or the index is empty. Pages reachable
@@ -56,6 +61,9 @@ impl<V: Vfs + Clone> Db<V> {
5661
///
5762
/// Named counters are already atomic with catalog-root publication, so
5863
/// recovery validates their encoding but never rewrites their values.
64+
///
65+
/// Rows are streamed in bounded batches: how many counters an embedder has
66+
/// named is its business, and open must not size an allocation by it.
5967
pub(super) async fn validate_counter_rows(
6068
&self,
6169
catalog_root_page_id: u64,
@@ -73,10 +81,28 @@ impl<V: Vfs + Clone> Db<V> {
7381
next_page_id,
7482
self.page_size,
7583
);
76-
for (_key, value) in tree.scan_prefix(&prefix).await? {
77-
Catalog::decode_counter(&value)?;
84+
let mut cursor: Vec<u8> = prefix.to_vec();
85+
loop {
86+
let batch = tree
87+
.collect_prefix_batch_from(&prefix, &cursor, COUNTER_ROW_BATCH)
88+
.await?;
89+
let Some((last_key, _)) = batch.last() else {
90+
return Ok(());
91+
};
92+
cursor.clear();
93+
cursor.extend_from_slice(last_key);
94+
// The exact successor of `last_key` in the key ordering: resume
95+
// strictly past the row just validated without re-reading it.
96+
cursor.push(0);
97+
let exhausted = batch.len() < COUNTER_ROW_BATCH;
98+
99+
for (_key, value) in &batch {
100+
Catalog::decode_counter(value)?;
101+
}
102+
if exhausted {
103+
return Ok(());
104+
}
78105
}
79-
Ok(())
80106
}
81107

82108
/// Write per-realm quota caps into the catalog B+ tree and persist the

src/txn/db/misc.rs

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use crate::Result;
55
use crate::btree::BTree;
66
use crate::catalog::codec::{Catalog, CatalogRowKind};
77
use crate::crypto::SecretKey;
8+
use crate::errors::PagedbError;
89
use crate::observability::DbStats;
910
use crate::vfs::types::OpenMode;
1011
use crate::vfs::{Vfs, VfsFile};
@@ -13,6 +14,11 @@ use std::sync::atomic::Ordering as AtOrd;
1314
use super::super::mode::DbMode;
1415
use super::core::Db;
1516

17+
/// Segment catalog rows read per batch while aggregating `stats()`. Rows are a
18+
/// fixed-width authenticated value, so this is a few KiB resident regardless of
19+
/// how many segments a realm has linked.
20+
const SEGMENT_ROW_BATCH: usize = 512;
21+
1622
impl<V: Vfs + Clone> Db<V> {
1723
/// Return the mode this handle was opened with.
1824
pub fn mode(&self) -> DbMode {
@@ -157,13 +163,36 @@ impl<V: Vfs + Clone> Db<V> {
157163
self.page_size,
158164
);
159165

160-
let seg_start = vec![CatalogRowKind::Segment as u8];
161-
let seg_rows = tree.scan_prefix(&seg_start).await?;
162-
let seg_count = u32::try_from(seg_rows.len()).unwrap_or(u32::MAX);
166+
// Streamed in bounded batches: how many segments a realm has linked
167+
// is the embedder's business, and a metrics call must not size an
168+
// allocation by it.
169+
let seg_prefix = [CatalogRowKind::Segment as u8];
170+
let mut cursor: Vec<u8> = seg_prefix.to_vec();
171+
let mut seg_count = 0u32;
163172
let mut seg_bytes = 0u64;
164-
for (_key, value) in &seg_rows {
165-
seg_bytes =
166-
seg_bytes.saturating_add(Catalog::decode_segment_meta(value)?.total_bytes);
173+
loop {
174+
let batch = tree
175+
.collect_prefix_batch_from(&seg_prefix, &cursor, SEGMENT_ROW_BATCH)
176+
.await?;
177+
let Some((last_key, _)) = batch.last() else {
178+
break;
179+
};
180+
cursor.clear();
181+
cursor.extend_from_slice(last_key);
182+
cursor.push(0);
183+
let exhausted = batch.len() < SEGMENT_ROW_BATCH;
184+
185+
for (_key, value) in &batch {
186+
seg_count = seg_count.saturating_add(1);
187+
seg_bytes = seg_bytes
188+
.checked_add(Catalog::decode_segment_meta(value)?.total_bytes)
189+
.ok_or_else(|| {
190+
PagedbError::arithmetic_overflow("stats.segments_total_bytes")
191+
})?;
192+
}
193+
if exhausted {
194+
break;
195+
}
167196
}
168197

169198
(seg_count, seg_bytes)

0 commit comments

Comments
 (0)