Skip to content

Commit e707d87

Browse files
committed
feat(txn)!: return values as Bytes sharing the cached page
BREAKING: `ReadTxn::get` and `WriteTxn::get` return `Option<Bytes>` rather than `Option<Vec<u8>>`. Every lookup copied its value out of the page it had just pinned, so a warm read — no I/O, no decryption, the bytes already in memory — still allocated and memcpy'd. Hold the page buffer as `Bytes` and hand back a slice of it: a refcount bump instead of a copy, and the warm get path now allocates nothing at all. The returned handle keeps that buffer alive by itself, so it outlives the guard and survives the page leaving the cache, and the bytes under it never change because a write installs a new page rather than mutating the old one. That is what makes the slice a stable snapshot of the version that was read rather than a window onto whatever the page becomes. It does hold the whole page for as long as it lives, which is the real cost: code that keeps one small value from each of many pages should copy it out. Both entry points say so. Values that have no committed page to borrow from — an overflow chain, or a write this transaction has not flushed — are still assembled into a buffer of their own, and `Bytes` carries them without a second copy. Chosen now because the return type is the part that cannot change later: the internals can get faster after 0.1, the signature cannot.
1 parent 8f2e483 commit e707d87

11 files changed

Lines changed: 138 additions & 64 deletions

File tree

src/btree/leaf.rs

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -271,11 +271,18 @@ impl Default for Leaf {
271271
}
272272
}
273273

274-
/// Borrowed view of one leaf value (zero-copy).
275-
#[derive(Debug)]
276-
pub enum LeafValueRef<'a> {
277-
Inline(&'a [u8]),
278-
Overflow { total_len: u64, root_page_id: u64 },
274+
/// Where slot `idx`'s value lives, without reading it.
275+
///
276+
/// Inline values report their extent rather than their bytes so the read path
277+
/// can slice the pinned page instead of copying out of it.
278+
#[derive(Debug, Clone, PartialEq, Eq)]
279+
pub enum LeafValueLoc {
280+
/// Body range holding the value.
281+
Inline(std::ops::Range<usize>),
282+
Overflow {
283+
total_len: u64,
284+
root_page_id: u64,
285+
},
279286
}
280287

281288
/// Zero-allocation accessor over an encoded leaf page body. Used on the read
@@ -365,24 +372,22 @@ impl<'a> LeafAccessor<'a> {
365372
None
366373
}
367374

368-
/// Decode the value at slot `idx`. Inline values borrow from the page body;
369-
/// overflow values return only the chain root + total length.
375+
/// Locate the value at slot `idx` without reading it: an inline value's
376+
/// extent within the body, or an overflow chain's root and total length.
370377
#[must_use]
371-
pub fn value_at(&self, idx: usize) -> LeafValueRef<'a> {
378+
pub fn value_loc(&self, idx: usize) -> LeafValueLoc {
372379
let off = slot_offset(self.body, self.prefix_len, idx);
373380
let suffix_len = read_u16_le(self.body, off) as usize;
374381
let after_key = off + 2 + suffix_len;
375382
let value_len_raw = read_u16_le(self.body, after_key);
376383
if value_len_raw == OVERFLOW_SENTINEL {
377-
let total_len = read_u64_le(self.body, after_key + 2);
378-
let root_page_id = read_u64_le(self.body, after_key + 2 + 8);
379-
LeafValueRef::Overflow {
380-
total_len,
381-
root_page_id,
384+
LeafValueLoc::Overflow {
385+
total_len: read_u64_le(self.body, after_key + 2),
386+
root_page_id: read_u64_le(self.body, after_key + 2 + 8),
382387
}
383388
} else {
384-
let vlen = value_len_raw as usize;
385-
LeafValueRef::Inline(&self.body[after_key + 2..after_key + 2 + vlen])
389+
let start = after_key + 2;
390+
LeafValueLoc::Inline(start..start + value_len_raw as usize)
386391
}
387392
}
388393
}

src/btree/tests/tree_ops.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,8 +232,8 @@ async fn random_100k_ops_match_ground_truth() {
232232
let got = tree.get(&key).await.unwrap();
233233
let expected = truth.get(&key).cloned();
234234
assert_eq!(
235-
got,
236-
expected,
235+
got.as_deref(),
236+
expected.as_deref(),
237237
"op {op} key {:?}",
238238
String::from_utf8_lossy(&key)
239239
);

src/btree/tree/read.rs

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ use crate::pager::{PageGuard, Pager};
66
use crate::vfs::Vfs;
77
use crate::{RealmId, Result};
88

9+
use bytes::Bytes;
10+
911
use crate::btree::internal::InternalAccessor;
10-
use crate::btree::leaf::{Leaf, LeafAccessor, LeafValue, LeafValueRef};
12+
use crate::btree::leaf::{Leaf, LeafAccessor, LeafValue, LeafValueLoc};
1113
use crate::btree::node::NodeKind;
1214
use crate::btree::overflow;
1315

@@ -18,10 +20,11 @@ impl<V: Vfs> BTree<V> {
1820
///
1921
/// Zero-allocation tree descent: each level reads the page through a
2022
/// [`PageGuard`], builds a borrowed accessor over the decrypted body, and
21-
/// either descends or extracts the value. The only allocation on the hit
22-
/// path is the final owned `Vec<u8>` copy of the inline value (or the
23-
/// overflow chain reassembly for large values).
24-
pub async fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>> {
23+
/// either descends or extracts the value. An inline value is returned as a
24+
/// slice sharing the cached page's buffer, so the hit path allocates
25+
/// nothing; only an overflow chain, which is reassembled from several
26+
/// pages, needs a buffer of its own.
27+
pub async fn get(&self, key: &[u8]) -> Result<Option<Bytes>> {
2528
if self.root_page_id == 0 {
2629
return Ok(None);
2730
}
@@ -42,16 +45,18 @@ impl<V: Vfs> BTree<V> {
4245
realm_id: RealmId,
4346
leaf: &Leaf,
4447
key: &[u8],
45-
) -> Result<Option<Vec<u8>>> {
48+
) -> Result<Option<Bytes>> {
4649
match leaf.get(key) {
4750
None => Ok(None),
48-
Some(LeafValue::Inline(v)) => Ok(Some(v.clone())),
51+
// Uncommitted, so it lives in the decoded leaf rather than in any
52+
// page a slice could borrow from.
53+
Some(LeafValue::Inline(v)) => Ok(Some(Bytes::copy_from_slice(v))),
4954
Some(LeafValue::Overflow {
5055
total_len,
5156
root_page_id,
5257
}) => {
5358
let v = overflow::read_chain(pager, realm_id, *root_page_id, *total_len).await?;
54-
Ok(Some(v))
59+
Ok(Some(Bytes::from(v)))
5560
}
5661
}
5762
}
@@ -64,7 +69,7 @@ impl<V: Vfs> BTree<V> {
6469
key: &[u8],
6570
root_guard: &PageGuard,
6671
root_kind: NodeKind,
67-
) -> Result<Option<Vec<u8>>> {
72+
) -> Result<Option<Bytes>> {
6873
if self.root_page_id == 0 {
6974
return Ok(None);
7075
}
@@ -82,7 +87,7 @@ impl<V: Vfs> BTree<V> {
8287
start_page_id: u64,
8388
start_guard: &PageGuard,
8489
start_kind: NodeKind,
85-
) -> Result<Option<Vec<u8>>> {
90+
) -> Result<Option<Bytes>> {
8691
// Handle the first level using the borrowed guard.
8792
let next_page_id = match start_kind {
8893
NodeKind::Leaf => {
@@ -120,7 +125,7 @@ impl<V: Vfs> BTree<V> {
120125
key: &[u8],
121126
leaf_page_id: u64,
122127
leaf_guard: &PageGuard,
123-
) -> Result<Option<Vec<u8>>> {
128+
) -> Result<Option<Bytes>> {
124129
// Within a write txn, the dirty + fresh caches shadow the buffer-pool
125130
// bytes. The cached decoded form is the source of truth for any leaf
126131
// they hold.
@@ -131,31 +136,34 @@ impl<V: Vfs> BTree<V> {
131136
{
132137
return match leaf.get(key) {
133138
None => Ok(None),
134-
Some(LeafValue::Inline(v)) => Ok(Some(v.clone())),
139+
// Uncommitted, so it lives in the decoded leaf rather than in
140+
// any page a slice could borrow from.
141+
Some(LeafValue::Inline(v)) => Ok(Some(Bytes::copy_from_slice(v))),
135142
Some(LeafValue::Overflow {
136143
total_len,
137144
root_page_id,
138145
}) => {
139146
let v =
140147
overflow::read_chain(&self.pager, self.realm_id, *root_page_id, *total_len)
141148
.await?;
142-
Ok(Some(v))
149+
Ok(Some(Bytes::from(v)))
143150
}
144151
};
145152
}
146153
let leaf = LeafAccessor::from_guard(leaf_guard)?;
147154
let Some(idx) = leaf.find(key) else {
148155
return Ok(None);
149156
};
150-
match leaf.value_at(idx) {
151-
LeafValueRef::Inline(v) => Ok(Some(v.to_vec())),
152-
LeafValueRef::Overflow {
157+
match leaf.value_loc(idx) {
158+
// Shares the pinned page's buffer; no copy of the value at all.
159+
LeafValueLoc::Inline(range) => Ok(Some(leaf_guard.body_slice(range))),
160+
LeafValueLoc::Overflow {
153161
total_len,
154162
root_page_id,
155163
} => {
156164
let v = overflow::read_chain(&self.pager, self.realm_id, root_page_id, total_len)
157165
.await?;
158-
Ok(Some(v))
166+
Ok(Some(Bytes::from(v)))
159167
}
160168
}
161169
}

src/pager/cache.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use std::collections::BTreeSet;
1010
use std::sync::Arc;
1111
use std::sync::atomic::AtomicBool;
1212

13+
use bytes::Bytes;
1314
use rustc_hash::FxHashMap;
1415

1516
/// File-identity discriminator for the cache key.
@@ -30,8 +31,13 @@ pub enum FileKey {
3031
/// `bytes` is logically immutable for the page's cache lifetime — set once at
3132
/// insert, never mutated. Mutation happens by replacing the whole `Arc<Page>`
3233
/// in the cache map.
34+
///
35+
/// Held as [`Bytes`] rather than `Vec<u8>` so a reader can hand out a slice of
36+
/// a value without copying it: the slice shares this buffer's refcount and
37+
/// keeps it alive on its own. That the buffer never changes is what makes such
38+
/// a slice a stable snapshot even after the page leaves the cache.
3339
pub struct Page {
34-
pub bytes: Vec<u8>,
40+
pub bytes: Bytes,
3541
/// Page kind recorded at write time; used by the Pager flush path to
3642
/// reconstruct the correct AAD for each dirty page.
3743
pub kind_byte: u8,
@@ -58,17 +64,18 @@ impl Page {
5864
#[must_use]
5965
pub fn new(bytes: Vec<u8>) -> Self {
6066
Self {
61-
bytes,
67+
bytes: Bytes::from(bytes),
6268
kind_byte: 0,
6369
realm_id_bytes: None,
6470
extents_validated: AtomicBool::new(false),
6571
}
6672
}
6773

74+
/// `bytes` is moved into a [`Bytes`], not copied.
6875
#[must_use]
6976
pub fn new_with_meta(bytes: Vec<u8>, kind_byte: u8, realm_id_bytes: [u8; 16]) -> Self {
7077
Self {
71-
bytes,
78+
bytes: Bytes::from(bytes),
7279
kind_byte,
7380
realm_id_bytes: Some(realm_id_bytes),
7481
extents_validated: AtomicBool::new(false),

src/pager/core.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,23 @@ impl PageGuard {
114114
body(&self.page.bytes)
115115
}
116116

117+
/// Slice `range` of the body as an owned [`Bytes`] sharing this page's
118+
/// buffer — a refcount bump, not a copy.
119+
///
120+
/// The returned handle keeps the buffer alive by itself, so it stays valid
121+
/// after the guard drops and after the page leaves the cache. The bytes it
122+
/// sees never change: a write installs a new page rather than mutating this
123+
/// one, which makes the slice a snapshot of the version that was read.
124+
///
125+
/// It does pin the whole page for as long as it lives. A caller keeping a
126+
/// small value from each of many pages should copy it out instead.
127+
#[must_use]
128+
pub fn body_slice(&self, range: std::ops::Range<usize>) -> Bytes {
129+
let start = HEADER_LEN + range.start;
130+
let end = HEADER_LEN + range.end;
131+
self.page.bytes.slice(start..end)
132+
}
133+
117134
/// Per-page memo for the structural extent check run by decoders over
118135
/// [`body_ref`](Self::body_ref). Scoped to the pinned cache entry, so it is
119136
/// discarded whenever the page is replaced or evicted.

src/snapshot/tests/basic.rs

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3736,8 +3736,11 @@ async fn chained_incremental_applies_keep_the_follower_able_to_advance() {
37363736
let rtxn = follower.begin_read().await.unwrap();
37373737
for i in 0u32..200 {
37383738
assert_eq!(
3739-
rtxn.get(format!("k{i:05}").as_bytes()).await.unwrap(),
3740-
Some(vec![4u8; 128]),
3739+
rtxn.get(format!("k{i:05}").as_bytes())
3740+
.await
3741+
.unwrap()
3742+
.as_deref(),
3743+
Some(vec![4u8; 128].as_slice()),
37413744
"key k{i:05} should reflect generation 4 after the first delta"
37423745
);
37433746
}
@@ -3767,8 +3770,11 @@ async fn chained_incremental_applies_keep_the_follower_able_to_advance() {
37673770
let rtxn = follower.begin_read().await.unwrap();
37683771
for i in 0u32..200 {
37693772
assert_eq!(
3770-
rtxn.get(format!("k{i:05}").as_bytes()).await.unwrap(),
3771-
Some(vec![9u8; 128]),
3773+
rtxn.get(format!("k{i:05}").as_bytes())
3774+
.await
3775+
.unwrap()
3776+
.as_deref(),
3777+
Some(vec![9u8; 128].as_slice()),
37723778
"key k{i:05} should reflect generation 9 after the second delta"
37733779
);
37743780
}
@@ -3871,15 +3877,21 @@ async fn a_delta_that_recycles_follower_private_page_ids_applies_cleanly() {
38713877
let rtxn = follower.begin_read().await.unwrap();
38723878
for i in 0u32..256 {
38733879
assert_eq!(
3874-
rtxn.get(format!("k{i:05}").as_bytes()).await.unwrap(),
3875-
Some(vec![0xBBu8; 128]),
3880+
rtxn.get(format!("k{i:05}").as_bytes())
3881+
.await
3882+
.unwrap()
3883+
.as_deref(),
3884+
Some(vec![0xBBu8; 128].as_slice()),
38763885
"refilled key k{i:05} must reflect the source's current state"
38773886
);
38783887
}
38793888
for i in 256u32..512 {
38803889
assert_eq!(
3881-
rtxn.get(format!("k{i:05}").as_bytes()).await.unwrap(),
3882-
Some(vec![0xAAu8; 128]),
3890+
rtxn.get(format!("k{i:05}").as_bytes())
3891+
.await
3892+
.unwrap()
3893+
.as_deref(),
3894+
Some(vec![0xAAu8; 128].as_slice()),
38833895
"untouched key k{i:05} must reflect the source's current state"
38843896
);
38853897
}
@@ -4041,8 +4053,11 @@ async fn chained_deltas_survive_many_rounds_of_delete_and_refill_churn() {
40414053
let rtxn = follower.begin_read().await.unwrap();
40424054
for i in 0u32..512 {
40434055
assert_eq!(
4044-
rtxn.get(format!("k{i:05}").as_bytes()).await.unwrap(),
4045-
Some(vec![round as u8; 128]),
4056+
rtxn.get(format!("k{i:05}").as_bytes())
4057+
.await
4058+
.unwrap()
4059+
.as_deref(),
4060+
Some(vec![round as u8; 128].as_slice()),
40464061
"round {round}: key k{i:05} should reflect this round's generation"
40474062
);
40484063
}
@@ -4121,7 +4136,7 @@ async fn a_follower_stays_consistent_across_churn_by_falling_back_to_a_full_snap
41214136
// Pre-round follower state, needed only if this round's delta gets
41224137
// refused -- the refusal must be a pure no-op.
41234138
let pre_round_commit = follower.latest_commit();
4124-
let pre_round_values: Vec<(String, Option<Vec<u8>>)> = {
4139+
let pre_round_values: Vec<(String, Option<bytes::Bytes>)> = {
41254140
let rtxn = follower.begin_read().await.unwrap();
41264141
let mut values = Vec::with_capacity(512);
41274142
for i in 0u32..512 {
@@ -4193,8 +4208,11 @@ async fn a_follower_stays_consistent_across_churn_by_falling_back_to_a_full_snap
41934208
let rtxn = follower.begin_read().await.unwrap();
41944209
for i in 0u32..512 {
41954210
assert_eq!(
4196-
rtxn.get(format!("k{i:05}").as_bytes()).await.unwrap(),
4197-
Some(vec![round as u8; 128]),
4211+
rtxn.get(format!("k{i:05}").as_bytes())
4212+
.await
4213+
.unwrap()
4214+
.as_deref(),
4215+
Some(vec![round as u8; 128].as_slice()),
41984216
"round {round}: key k{i:05} should reflect this round's generation after a successful apply"
41994217
);
42004218
}
@@ -4278,8 +4296,11 @@ async fn a_follower_stays_consistent_across_churn_by_falling_back_to_a_full_snap
42784296
let rtxn = new_follower.begin_read().await.unwrap();
42794297
for i in 0u32..512 {
42804298
assert_eq!(
4281-
rtxn.get(format!("k{i:05}").as_bytes()).await.unwrap(),
4282-
Some(vec![round as u8; 128]),
4299+
rtxn.get(format!("k{i:05}").as_bytes())
4300+
.await
4301+
.unwrap()
4302+
.as_deref(),
4303+
Some(vec![round as u8; 128].as_slice()),
42834304
"round {round}: key k{i:05} should reflect this round's generation on the remedy follower"
42844305
);
42854306
}

src/txn/read/txn.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use bytes::Bytes;
12
use tokio::sync::OnceCell;
23

34
use crate::btree::BTree;
@@ -92,7 +93,15 @@ impl<'db, V: Vfs + Clone> ReadTxn<'db, V> {
9293
Ok(())
9394
}
9495

95-
pub async fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>> {
96+
/// Look up `key` in this snapshot.
97+
///
98+
/// The value is returned as [`Bytes`] sharing the cached page's buffer
99+
/// rather than copied out of it, so a hit allocates nothing. The handle
100+
/// keeps that buffer alive on its own and the bytes never change under it,
101+
/// so it stays valid and consistent after the transaction ends. It does
102+
/// hold the whole page though: code retaining one small value from each of
103+
/// many pages should copy it out with `to_vec`.
104+
pub async fn get(&self, key: &[u8]) -> Result<Option<Bytes>> {
96105
self.check_abort()?;
97106
if self.root_page_id == 0 {
98107
return Ok(None);

0 commit comments

Comments
 (0)