Skip to content

Commit 7487e5f

Browse files
committed
perf(btree): decide page-reuse eligibility at free time, not per allocation
allocate_page evaluated the in-session reuse rule by scanning the whole `freed` list and, for each entry, all of `free_page_consumed` — both plain Vecs, so `contains` is linear. Because `reuse_threshold` is the session-start `next_page_id`, every page freed during the txn sits below it and the cheap first disjunct is false for nearly every entry, so the full O(|freed| x |consumed|) scan ran on every single page allocation, with both vectors growing monotonically through the flush. The result is unbounded work rather than a deadlock: a server was found pinning one core at 99.4% for 84 minutes inside a single batch_write, 5048s of user CPU against 2.9s of system time and 104 read syscalls for the whole process lifetime, with 99.51% of perf samples in allocate_page. Both inputs to the eligibility decision are monotonic — reuse_threshold is fixed for the session and free_page_consumed only grows — so deciding once in free_page is equivalent to deciding per allocation. Eligible pages go to a second `freed_reusable` list that allocate_page pops in O(1). set_reuse_threshold re-partitions, for callers that set it after a free (normally a walk over two empty vectors), and drain_freed drains both lists so an eligible page no allocation claimed still reaches the deferred-free queue. Three tests cover the eligibility rule, post-hoc threshold setting, and a hang detector for the scan's return: with the old logic that test does not finish in 300s, with this change it completes in 0.00s.
1 parent 8217994 commit 7487e5f

3 files changed

Lines changed: 171 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
44

5+
## [Unreleased]
6+
7+
### Fixed
8+
9+
- **Page allocation no longer rescans the freed list.** `allocate_page` decided in-session reuse eligibility per call, scanning every page freed so far and, for each, every page drawn from the shared free-page cache — both `Vec<u64>`, so the membership test was linear. Since the reuse threshold is the session-start `next_page_id`, every page freed during a transaction fell below it and took the slow arm, making allocation quadratic in the pages a flush touches: a flush large enough would occupy a core indefinitely without completing. Eligibility is now decided once, when the page is freed, and allocation is a pop. Behaviour is unchanged — both inputs to the decision are monotonic within a session, so deciding early is equivalent.
10+
511
## [0.1.0] - 2026-07-28
612

713
The first release. Pre-releases were published as `0.1.0-beta.N`; the entries below describe `0.1.0` as a whole rather than deltas against a shipped version, since none exists yet.

src/btree/tree/core.rs

Lines changed: 160 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,17 @@ pub struct BTree<V: Vfs> {
108108
pub(super) root_page_id: u64,
109109
pub(super) next_page_id: u64,
110110
pub(super) freed: Vec<u64>,
111+
/// The subset of pages freed this session that `allocate_page` may hand
112+
/// back immediately — i.e. those that satisfied the reuse rule below at the
113+
/// moment they were freed. Eligibility is decided once, in
114+
/// [`Self::free_page`], because both inputs to that decision are
115+
/// monotonic: `reuse_threshold` is fixed for the session and
116+
/// `free_page_consumed` only grows. Deciding it here keeps allocation O(1);
117+
/// evaluating it per allocation instead meant rescanning `freed` against
118+
/// `free_page_consumed` on every call, which is quadratic in the number of
119+
/// pages a flush touches and made large flushes effectively never finish.
120+
/// Drained together with `freed` by `drain_freed`.
121+
pub(super) freed_reusable: Vec<u64>,
111122
pub(super) page_size: usize,
112123
/// Minimum `page_id` that may be recycled from `freed` within this session.
113124
/// Pages below this threshold existed before the session began: they are
@@ -187,6 +198,7 @@ impl<V: Vfs> BTree<V> {
187198
root_page_id,
188199
next_page_id: next,
189200
freed: Vec::new(),
201+
freed_reusable: Vec::new(),
190202
page_size,
191203
reuse_threshold: 0,
192204
dirty_leaves: FxHashMap::default(),
@@ -205,8 +217,21 @@ impl<V: Vfs> BTree<V> {
205217
/// deferred-queue promotion. Call with the session-start `next_page_id`:
206218
/// pages below it are still referenced by the last durable header and must
207219
/// keep their on-disk bytes until the header that frees them is durable.
220+
///
221+
/// Re-partitions anything already freed, since eligibility is decided at
222+
/// free time: callers normally set the threshold before the first free, so
223+
/// this is a no-op walk over two empty vectors.
208224
pub fn set_reuse_threshold(&mut self, threshold: u64) {
209225
self.reuse_threshold = threshold;
226+
let mut pending = std::mem::take(&mut self.freed);
227+
pending.append(&mut self.freed_reusable);
228+
for id in pending {
229+
if self.is_reusable_in_session(id) {
230+
self.freed_reusable.push(id);
231+
} else {
232+
self.freed.push(id);
233+
}
234+
}
210235
}
211236

212237
/// Wire in the `Db`'s shared free-page cache. After this call,
@@ -263,28 +288,15 @@ impl<V: Vfs> BTree<V> {
263288
// content. Without this, every cache-drawn page freed by a later
264289
// in-session split is burned for the rest of the txn and allocation
265290
// falls through to bump growth.
266-
if self.reuse_threshold == 0 {
267-
if let Some(id) = self.freed.pop() {
268-
assert!(
269-
id >= 4,
270-
"allocate_page recycled reserved page {id} from freed"
271-
);
272-
return id;
273-
}
274-
} else {
275-
let consumed = self.free_page_consumed.as_ref().map(|c| c.lock());
276-
let pos = self.freed.iter().rposition(|&id| {
277-
id >= self.reuse_threshold || consumed.as_ref().is_some_and(|c| c.contains(&id))
278-
});
279-
drop(consumed);
280-
if let Some(pos) = pos {
281-
let id = self.freed.remove(pos);
282-
assert!(
283-
id >= 4,
284-
"allocate_page recycled reserved page {id} from freed"
285-
);
286-
return id;
287-
}
291+
// `free_page` has already applied that rule to each freed page, so the
292+
// eligible ones are exactly `freed_reusable` and this is a pop, not a
293+
// search.
294+
if let Some(id) = self.freed_reusable.pop() {
295+
assert!(
296+
id >= 4,
297+
"allocate_page recycled reserved page {id} from freed"
298+
);
299+
return id;
288300
}
289301
// Then draw from the shared cross-commit cache. It is loaded at txn
290302
// begin with *only* free-list pages below the reclamation floor — pages
@@ -320,7 +332,26 @@ impl<V: Vfs> BTree<V> {
320332
page_id >= 4,
321333
"free_page called on reserved page {page_id} (use-after-free / wild pointer)"
322334
);
323-
self.freed.push(page_id);
335+
if self.is_reusable_in_session(page_id) {
336+
self.freed_reusable.push(page_id);
337+
} else {
338+
self.freed.push(page_id);
339+
}
340+
}
341+
342+
/// Whether a page freed now may be recycled within this session. See
343+
/// [`Self::allocate_page`] for the reasoning behind each arm: below the
344+
/// threshold a page may still be live in the last durable header or a
345+
/// pinned reader's snapshot, unless it was drawn from the shared free-page
346+
/// cache this session, in which case the durable header references none of
347+
/// its content.
348+
fn is_reusable_in_session(&self, page_id: u64) -> bool {
349+
if self.reuse_threshold == 0 || page_id >= self.reuse_threshold {
350+
return true;
351+
}
352+
self.free_page_consumed
353+
.as_ref()
354+
.is_some_and(|c| c.lock().contains(&page_id))
324355
}
325356

326357
pub(super) fn validate_insert_record_fits(&self, key: &[u8], value: &[u8]) -> Result<()> {
@@ -463,3 +494,109 @@ impl<V: Vfs> BTree<V> {
463494
.await
464495
}
465496
}
497+
498+
#[cfg(test)]
499+
mod tests {
500+
use std::sync::Arc;
501+
502+
use super::BTree;
503+
use crate::RealmId;
504+
use crate::crypto::CipherId;
505+
use crate::crypto::kdf::derive_mk;
506+
use crate::pager::{Pager, PagerConfig};
507+
use crate::vfs::memory::MemVfs;
508+
509+
const PAGE: usize = 4096;
510+
511+
async fn fresh_tree() -> BTree<MemVfs> {
512+
let mk = derive_mk(&[1u8; 32], &[0u8; 16], 0).unwrap();
513+
let cfg = PagerConfig {
514+
page_size: PAGE,
515+
buffer_pool_pages: 256,
516+
segment_cache_pages: 16,
517+
cipher_id: CipherId::Aes256Gcm,
518+
mk_epoch: 0,
519+
main_db_file_id: [0xAB; 16],
520+
main_db_path: "/main.db".into(),
521+
anchor_budget: 100_000_000,
522+
dek_lru_capacity: 16,
523+
observer_retry_count: 0,
524+
metrics_enabled: true,
525+
};
526+
let pager = Arc::new(Pager::open(MemVfs::new(), mk, cfg).await.unwrap());
527+
BTree::open(pager, RealmId::new([1; 16]), 0, 4, PAGE)
528+
}
529+
530+
/// Below-threshold frees must not be recycled in-session, above-threshold
531+
/// ones must be, and a cache-drawn page recorded in `free_page_consumed`
532+
/// must be recyclable even below the threshold. Same rule the per-allocation
533+
/// scan used to apply; asserted here now that `free_page` decides it once.
534+
#[tokio::test(flavor = "current_thread")]
535+
async fn reuse_eligibility_matches_the_threshold_rule() {
536+
let mut tree = fresh_tree().await;
537+
let consumed = Arc::new(parking_lot::Mutex::new(Vec::new()));
538+
tree.set_free_page_consumed(Arc::clone(&consumed));
539+
tree.set_reuse_threshold(1000);
540+
541+
// Below threshold and never drawn from the cache: not recyclable now.
542+
tree.free_page(500);
543+
assert_eq!(tree.allocate_page(), 4, "bumped, not recycled");
544+
545+
// At or above the threshold: recyclable immediately.
546+
tree.free_page(1500);
547+
assert_eq!(tree.allocate_page(), 1500);
548+
549+
// Below threshold but drawn from the shared cache this session.
550+
consumed.lock().push(600);
551+
tree.free_page(600);
552+
assert_eq!(tree.allocate_page(), 600);
553+
554+
// The one page held back is still reported as freed this session.
555+
assert_eq!(tree.drain_freed(), vec![500]);
556+
}
557+
558+
/// A page freed before the threshold was set is classified when it is set.
559+
#[tokio::test(flavor = "current_thread")]
560+
async fn threshold_set_after_a_free_repartitions() {
561+
let mut tree = fresh_tree().await;
562+
tree.free_page(500);
563+
tree.free_page(1500);
564+
tree.set_reuse_threshold(1000);
565+
assert_eq!(tree.allocate_page(), 1500, "above threshold stays reusable");
566+
assert_eq!(tree.allocate_page(), 4, "below threshold was held back");
567+
assert_eq!(tree.drain_freed(), vec![500]);
568+
}
569+
570+
/// Allocation must not rescan the freed list. A hang detector, not a
571+
/// benchmark: the bound is ~1000x the fixed cost, and only a return to a
572+
/// per-allocation scan over `freed` x `free_page_consumed` can exceed it.
573+
/// That scan is what pinned a core for 84 minutes inside one flush.
574+
#[tokio::test(flavor = "current_thread")]
575+
async fn allocation_cost_does_not_grow_with_the_freed_list() {
576+
const N: u64 = 20_000;
577+
let mut tree = fresh_tree().await;
578+
let consumed = Arc::new(parking_lot::Mutex::new(Vec::new()));
579+
tree.set_free_page_consumed(Arc::clone(&consumed));
580+
tree.set_reuse_threshold(N * 2);
581+
582+
// Every one of these is ineligible, so each allocation below had to
583+
// scan all of them (and, per entry, all of `consumed`) before the fix.
584+
for id in 4..N {
585+
tree.free_page(id);
586+
}
587+
for id in N..(N + 500) {
588+
consumed.lock().push(id);
589+
}
590+
591+
let start = std::time::Instant::now();
592+
for _ in 0..N {
593+
tree.allocate_page();
594+
}
595+
let elapsed = start.elapsed();
596+
assert!(
597+
elapsed < std::time::Duration::from_secs(10),
598+
"{N} allocations against {N} held-back frees took {elapsed:?} — \
599+
allocation is scanning the freed list again"
600+
);
601+
}
602+
}

src/btree/tree/flush.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,13 @@ impl<V: Vfs> BTree<V> {
100100

101101
/// Drain and return all `page_ids` that were freed during this tree's
102102
/// mutation session, plus any leaf pages scheduled for freeing at the
103-
/// upcoming `flush`. After this call, both `self.freed` and
104-
/// `self.scheduled_frees` are empty.
103+
/// upcoming `flush`. After this call, `self.freed`, `self.freed_reusable`
104+
/// and `self.scheduled_frees` are all empty — a page eligible for
105+
/// in-session reuse that no allocation claimed is still a page this session
106+
/// freed, and the deferred-free queue must hear about it.
105107
pub fn drain_freed(&mut self) -> Vec<u64> {
106108
let mut out = std::mem::take(&mut self.freed);
109+
out.append(&mut self.freed_reusable);
107110
out.append(&mut self.scheduled_frees);
108111
out
109112
}

0 commit comments

Comments
 (0)