Skip to content

Commit 42789f7

Browse files
authored
Merge pull request #5 from NodeDB-Lab/fix/durable-freelist-reclamation
fix: durable header-rooted free-list (closes #1, #2)
2 parents 77ff0ac + ee6bc24 commit 42789f7

36 files changed

Lines changed: 2263 additions & 1657 deletions

.github/workflows/test.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ jobs:
4646
with:
4747
components: rustfmt, clippy
4848

49+
# The comparison benchmark links rocksdb (librocksdb-sys), whose build
50+
# runs bindgen and needs libclang. clippy --all-targets compiles it.
51+
- name: Install LLVM/Clang
52+
run: sudo apt-get update && sudo apt-get install -y clang libclang-dev
53+
4954
- uses: Swatinem/rust-cache@v2
5055
with:
5156
prefix-key: lint
@@ -90,6 +95,19 @@ jobs:
9095
- name: Install Rust
9196
uses: dtolnay/rust-toolchain@stable
9297

98+
# nextest compiles the dev-dependency graph (incl. rocksdb →
99+
# librocksdb-sys → bindgen → libclang). Windows runner images ship LLVM;
100+
# Linux and macOS need it installed.
101+
- name: Install LLVM/Clang (Linux)
102+
if: runner.os == 'Linux'
103+
run: sudo apt-get update && sudo apt-get install -y clang libclang-dev
104+
105+
- name: Install LLVM/Clang (macOS)
106+
if: runner.os == 'macOS'
107+
run: |
108+
brew install llvm
109+
echo "LIBCLANG_PATH=$(brew --prefix llvm)/lib" >> "$GITHUB_ENV"
110+
93111
- uses: Swatinem/rust-cache@v2
94112
with:
95113
prefix-key: test-${{ matrix.os }}
@@ -198,6 +216,11 @@ jobs:
198216
- name: Install Rust
199217
uses: dtolnay/rust-toolchain@stable
200218

219+
# --benches compiles the comparison bench, which links rocksdb
220+
# (librocksdb-sys) — its build runs bindgen and needs libclang.
221+
- name: Install LLVM/Clang
222+
run: sudo apt-get update && sudo apt-get install -y clang libclang-dev
223+
201224
- uses: Swatinem/rust-cache@v2
202225
with:
203226
prefix-key: features
@@ -215,6 +238,11 @@ jobs:
215238
- name: Install Rust
216239
uses: dtolnay/rust-toolchain@stable
217240

241+
# cargo bench builds the dev-dependency graph (rocksdb → librocksdb-sys →
242+
# bindgen → libclang), even when only the btree/segment benches are named.
243+
- name: Install LLVM/Clang
244+
run: sudo apt-get update && sudo apt-get install -y clang libclang-dev
245+
218246
- uses: Swatinem/rust-cache@v2
219247
with:
220248
prefix-key: bench

Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,17 +129,24 @@ fastrand = "2"
129129
name = "pagedb-fsck"
130130
path = "src/bin/pagedb-fsck.rs"
131131

132+
# `test = false`: these are benchmarks, not tests. Without it, `cargo test`
133+
# (and nextest) compile every `harness = false` bench and run its `main` as a
134+
# test — pulling heavy dev-deps (rocksdb → libclang) into the test build on
135+
# every platform and executing benchmarks during the test run.
132136
[[bench]]
133137
name = "btree"
134138
harness = false
139+
test = false
135140

136141
[[bench]]
137142
name = "segment"
138143
harness = false
144+
test = false
139145

140146
[[bench]]
141147
name = "comparison"
142148
harness = false
149+
test = false
143150

144151
[profile.release]
145152
lto = "thin"

benches/comparison.rs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,11 @@ fn open_pagedb_fresh() -> PagedbBench {
8484
// Fair-comparison: redb has no equivalent commit-history index;
8585
// disable pagedb's so the bench measures the same feature surface.
8686
// (`Disabled` is a pagedb extension; not in the architecture spec.)
87+
// With history disabled and no readers, the commit path recycles freed
88+
// pages through the in-memory allocator cache without persisting a
89+
// deferred-free row, so per-commit work matches redb's for a fair
90+
// write-latency comparison.
8791
.with_commit_history_retain(RetainPolicy::Disabled)
88-
// Fair-comparison: redb does not persist a deferred-free queue per
89-
// commit. Opt into pagedb's fast-free path so write-latency numbers
90-
// measure the same per-commit work. Production embedders that turn
91-
// this on must compact periodically.
92-
.with_skip_freelist_persistence_when_no_readers(true)
9392
.with_metrics_enabled(false)
9493
// Large bulk loads need a generous nonce budget per txn.
9594
.with_anchor_budget(10_000_000);

src/btree/tree/core.rs

Lines changed: 40 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,18 @@ pub struct BTree<V: Vfs> {
5252
/// parallel-AEAD flush. In-place mutation by subsequent puts targeting
5353
/// the same leaf is allowed; no further allocation needed.
5454
pub(super) fresh_leaves: HashMap<u64, Leaf>,
55-
/// Cross-commit pool of pre-vetted reusable page IDs, shared (via `Arc`)
56-
/// across all `BTree`s opened by the same `Db`. Allocation pops from
57-
/// here before bumping `next_page_id`, keeping the file size bounded
58-
/// when `OpenOptions::skip_freelist_persistence_when_no_readers` orphans
59-
/// would otherwise accumulate. `None` for callers that haven't wired a
60-
/// shared cache (compaction, history tree) — those keep bump-only
61-
/// allocation.
55+
/// Cross-commit pool of reusable page IDs, shared (via `Arc`) across the
56+
/// main, catalog, and history `BTree`s of one `Db`. Allocation pops from
57+
/// here before bumping `next_page_id`, recycling pages freed by earlier
58+
/// commits (once no reader or retained-history root can observe them) so
59+
/// the file stays bounded under sustained writes. `None` for callers that
60+
/// haven't wired a shared cache (e.g. compaction's repack trees), which
61+
/// keep bump-only allocation.
6262
pub(super) free_page_cache: Option<Arc<parking_lot::Mutex<Vec<u64>>>>,
63+
/// Sink recording page ids drawn from `free_page_cache` and reused this
64+
/// session. The commit path removes them from the durable free-list (they
65+
/// now hold live committed data). Shared (via `Arc`) across the txn's trees.
66+
pub(super) free_page_consumed: Option<Arc<parking_lot::Mutex<Vec<u64>>>>,
6367
/// Last key successfully appended via [`Self::put_append`]. Used to
6468
/// enforce the monotonic-key invariant on subsequent calls and to
6569
/// invalidate the cached path when any non-append mutation (regular
@@ -96,6 +100,7 @@ impl<V: Vfs> BTree<V> {
96100
dirty_parent_paths: HashMap::new(),
97101
fresh_leaves: HashMap::new(),
98102
free_page_cache: None,
103+
free_page_consumed: None,
99104
append_last_key: None,
100105
append_cached_path: None,
101106
}
@@ -110,22 +115,18 @@ impl<V: Vfs> BTree<V> {
110115
}
111116

112117
/// Wire in the `Db`'s shared free-page cache. After this call,
113-
/// `allocate_page` will pop from the shared pool before bumping
114-
/// `next_page_id`. Pages pushed into the pool by an earlier writer
115-
/// commit (via [`Self::push_to_shared_cache`]) become reusable here.
118+
/// `allocate_page` pops from the shared pool before bumping `next_page_id`.
119+
/// The pool is loaded at `begin_write` with the durable free-list's
120+
/// floor-safe pages, so recycling from it is always snapshot-safe.
116121
pub fn set_free_page_cache(&mut self, cache: Arc<parking_lot::Mutex<Vec<u64>>>) {
117122
self.free_page_cache = Some(cache);
118123
}
119124

120-
/// Push `page_ids` into the shared free-page cache, if one is wired.
121-
/// Used by the writer commit path when the no-reader fast-free option
122-
/// is active: instead of orphaning freed pages, hand them to the next
123-
/// txn's allocator.
124-
pub fn push_to_shared_cache(&self, page_ids: &[u64]) {
125-
if let Some(cache) = &self.free_page_cache {
126-
let mut guard = cache.lock();
127-
guard.extend(page_ids.iter().copied());
128-
}
125+
/// Wire the shared sink that records cache pages reused this session, so the
126+
/// commit path can remove them from the durable free-list. Set alongside
127+
/// [`Self::set_free_page_cache`].
128+
pub fn set_free_page_consumed(&mut self, consumed: Arc<parking_lot::Mutex<Vec<u64>>>) {
129+
self.free_page_consumed = Some(consumed);
129130
}
130131

131132
#[must_use]
@@ -148,34 +149,31 @@ impl<V: Vfs> BTree<V> {
148149
}
149150

150151
pub(super) fn allocate_page(&mut self) -> u64 {
151-
// Reuse a freed page only if it is at or above the reuse threshold.
152-
// Pages below the threshold may still be live in pinned reader snapshots.
152+
// First, recycle a page freed earlier *in this same session*, gated by
153+
// the reuse threshold: a page below it may still be live in a pinned
154+
// reader's snapshot, so it can't be reused until the durable free-list
155+
// clears it (it leaves via `drain_freed` at commit instead).
153156
if self.reuse_threshold == 0 {
154-
// No readers pinned: recycle freely.
155157
if let Some(id) = self.freed.pop() {
156158
return id;
157159
}
158-
// Consult the cross-commit shared cache (pages handed off by
159-
// earlier writer commits under the no-reader fast-free option).
160-
// Only safe to draw from this when no readers are pinned — the
161-
// cache contract is "always safe to immediately reuse."
162-
if let Some(cache) = &self.free_page_cache {
163-
if let Some(id) = cache.lock().pop() {
164-
return id;
160+
} else if let Some(pos) = self
161+
.freed
162+
.iter()
163+
.rposition(|&id| id >= self.reuse_threshold)
164+
{
165+
return self.freed.remove(pos);
166+
}
167+
// Then draw from the shared cross-commit cache. It is loaded at txn
168+
// begin with *only* free-list pages below the reclamation floor — pages
169+
// no live reader and no retained-history root can observe — so reusing
170+
// them is safe regardless of `reuse_threshold`. Record each draw so the
171+
// commit path removes it from the durable free-list.
172+
if let Some(cache) = &self.free_page_cache {
173+
if let Some(id) = cache.lock().pop() {
174+
if let Some(consumed) = &self.free_page_consumed {
175+
consumed.lock().push(id);
165176
}
166-
}
167-
} else {
168-
// Readers pinned: only recycle pages that were allocated during
169-
// this session (>= reuse_threshold) and thus cannot be in any
170-
// prior snapshot. The shared cache is bypassed here because
171-
// pages in it predate this session and may be visible to a
172-
// pinned reader at an older commit.
173-
if let Some(pos) = self
174-
.freed
175-
.iter()
176-
.rposition(|&id| id >= self.reuse_threshold)
177-
{
178-
let id = self.freed.remove(pos);
179177
return id;
180178
}
181179
}

src/btree/tree/scan.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,20 @@ impl<V: Vfs> BTree<V> {
3838
}
3939
}
4040

41+
/// Return the smallest key in the tree, or `None` if the tree is empty.
42+
/// Descends the leftmost spine only — O(tree height), not O(tree size).
43+
pub async fn first_key(&self) -> Result<Option<Vec<u8>>> {
44+
if self.root_page_id == 0 {
45+
return Ok(None);
46+
}
47+
// The empty key sorts below every stored key, so the descent lands on
48+
// the leftmost leaf.
49+
let path = self.path_to_leaf_for_key(&[]).await?;
50+
let leaf_id = *path.last().expect("non-empty path");
51+
let leaf = self.read_leaf(leaf_id).await?;
52+
Ok(leaf.records.first().map(|(k, _)| k.clone()))
53+
}
54+
4155
/// Reverse range scan: `start` inclusive, `end` exclusive. Returns results
4256
/// in descending key order. Collects matching records forward then reverses.
4357
pub async fn scan_rev(&self, start: &[u8], end: &[u8]) -> Result<Vec<(Vec<u8>, Vec<u8>)>> {

src/catalog/codec.rs

Lines changed: 3 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,9 @@ pub enum CatalogRowKind {
2323
/// (singleton; no name suffix). Value is `RekeyState` encoded as 13 bytes:
2424
/// `target_mk_epoch[8] || main_db_done[1] || segments_remaining_idx[4]`.
2525
RekeyState = 0x03,
26-
/// Persistent free-list entry. Key is `[0x04] || page_id_big_endian[8]`
27-
/// (big-endian so lexicographic sort matches numeric order). Value is empty.
28-
FreeList = 0x04,
29-
/// Deferred-free queue. Singleton row; key is `[0x05]`. Value is a
30-
/// length-prefixed list of `(commit_id[8] || page_id[8])` pairs, in
31-
/// ascending `commit_id` order. Pages here are not yet safe to reallocate
32-
/// because a reader snapshot at or before that `commit_id` may still observe
33-
/// them. Pairs are promoted to the free-list once no tracked reader pins
34-
/// a `commit_id` ≤ the pair's `commit_id`.
35-
DeferredFree = 0x05,
26+
// 0x04 and 0x05 are reserved: they were the in-catalog free-list and
27+
// deferred-free queue, superseded by the durable free-list chain rooted in
28+
// the A/B header (see `crate::pager::freelist`). Do not reuse these bytes.
3629
/// Durable reader pin. One row per active cross-process read transaction.
3730
/// Key: `[0x06] || pid_u32_be[4] || lease_id_u64_be[8]` (13 bytes).
3831
/// Value: `commit_id[8] || root_page_id[8] || catalog_root_page_id[8] ||
@@ -200,56 +193,6 @@ impl Catalog {
200193
vec![CatalogRowKind::RekeyState as u8]
201194
}
202195

203-
/// Free-list entry key: `[0x04] || page_id_be[8]`.
204-
/// Big-endian so lexicographic order == numeric order.
205-
#[must_use]
206-
pub fn free_list_key(page_id: u64) -> [u8; 9] {
207-
let mut k = [0u8; 9];
208-
k[0] = CatalogRowKind::FreeList as u8;
209-
k[1..9].copy_from_slice(&page_id.to_be_bytes());
210-
k
211-
}
212-
213-
/// Deferred-free queue key: `[0x05]` (singleton).
214-
#[must_use]
215-
pub fn deferred_free_key() -> Vec<u8> {
216-
vec![CatalogRowKind::DeferredFree as u8]
217-
}
218-
219-
/// Encode a slice of `(commit_id, page_id)` pairs. Empty slice encodes to
220-
/// empty bytes.
221-
#[must_use]
222-
pub fn encode_deferred_free(pairs: &[(u64, u64)]) -> Vec<u8> {
223-
let mut out = Vec::with_capacity(pairs.len() * 16);
224-
for (commit_id, page_id) in pairs {
225-
out.extend_from_slice(&commit_id.to_le_bytes());
226-
out.extend_from_slice(&page_id.to_le_bytes());
227-
}
228-
out
229-
}
230-
231-
/// Decode a deferred-free value. Returns `Err` if length is not a multiple
232-
/// of 16.
233-
pub fn decode_deferred_free(bytes: &[u8]) -> Result<Vec<(u64, u64)>> {
234-
if bytes.len() % 16 != 0 {
235-
return Err(PagedbError::corruption(
236-
crate::errors::CorruptionDetail::HeaderUnverifiable,
237-
));
238-
}
239-
let mut out = Vec::with_capacity(bytes.len() / 16);
240-
let mut i = 0;
241-
while i + 16 <= bytes.len() {
242-
let mut b8 = [0u8; 8];
243-
b8.copy_from_slice(&bytes[i..i + 8]);
244-
let commit_id = u64::from_le_bytes(b8);
245-
b8.copy_from_slice(&bytes[i + 8..i + 16]);
246-
let page_id = u64::from_le_bytes(b8);
247-
out.push((commit_id, page_id));
248-
i += 16;
249-
}
250-
Ok(out)
251-
}
252-
253196
/// Encode a `RekeyStateRow` as 13 bytes.
254197
#[must_use]
255198
pub fn encode_rekey_state(r: &RekeyStateRow) -> [u8; REKEY_STATE_LEN] {

0 commit comments

Comments
 (0)