reddb_engine/storage/btree/value_layout.rs
1//! Large-value layout for B-tree leaf cells (slice E of PRD #662).
2//!
3//! Wires the standalone slice modules together at the write/read boundary:
4//!
5//! - [`value_codec`] (slice A, #698) decides whether the bytes are worth
6//! compressing.
7//! - [`OverflowChain`] (slice B, #699) owns the chain of dedicated overflow
8//! pages.
9//! - `reddb-file` pins the persisted two-bit `(pointer, compressed)` cell
10//! shape and pointer payload encoding.
11//!
12//! The function entry points are [`encode`] and [`decode`]. They persist
13//! opaque cell bytes whose on-disk shape is owned by `reddb-file`.
14//!
15//! Decision ladder applied by [`encode`]:
16//!
17//! 1. `value.len() > MAX_VALUE_SIZE` → [`ValueLayoutError::ValueTooLarge`]
18//! (no allocation, no LZ4 work).
19//! 2. `value.len() <= OVERFLOW_THRESHOLD` → inline raw.
20//! 3. Else try LZ4. If the compressed bytes fit inline → inline compressed.
21//! 4. Else spill via [`OverflowChain`], storing only the pointer in the
22//! leaf cell.
23//!
24//! Per ADR 0025 (overflow chain MVCC) the chain identity is anchored at
25//! the leaf cell, so this slice does not add any WAL records of its own
26//! — the overflow page writes go through the existing pager path.
27
28use crate::storage::overflow::{OverflowChain, OverflowError};
29use crate::storage::page_store::PageStore;
30use reddb_file::vector_value_codec as value_codec;
31use reddb_file::BTreeValueCell;
32
33/// At or below this length a value stores inline in the leaf, raw or
34/// compressed. Above it, the value spills via [`OverflowChain`]. Set per
35/// ADR 0023 — preserves leaf fanout regardless of page size.
36pub const OVERFLOW_THRESHOLD: usize = reddb_file::BTREE_VALUE_OVERFLOW_THRESHOLD;
37
38/// Hard upper bound on logical value size. Values above this are
39/// rejected before any LZ4 or overflow work runs.
40/// 2^28 = 256 MiB per ADR 0023.
41pub const MAX_VALUE_SIZE: usize = reddb_file::BTREE_VALUE_MAX_SIZE;
42
43/// Total stored bytes for a pointer cell — flag byte + payload.
44pub const POINTER_CELL_LEN: usize = reddb_file::BTREE_VALUE_POINTER_CELL_LEN;
45
46/// Errors returned by [`encode`] and [`decode`].
47#[derive(Debug)]
48pub enum ValueLayoutError {
49 /// Logical value length exceeds [`MAX_VALUE_SIZE`].
50 ValueTooLarge(usize),
51 /// Stored cell flag byte sets a reserved bit.
52 UnknownFlag(u8),
53 /// Stored bytes ended before the expected pointer payload.
54 TruncatedPointer { got: usize },
55 /// LZ4 decode failed.
56 Codec(value_codec::ValueCodecError),
57 /// Overflow chain operation failed.
58 Overflow(OverflowError),
59}
60
61impl std::fmt::Display for ValueLayoutError {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 match self {
64 Self::ValueTooLarge(n) => {
65 write!(f, "value too large: {} bytes (max {})", n, MAX_VALUE_SIZE)
66 }
67 Self::UnknownFlag(b) => write!(f, "unknown leaf-cell flag byte: 0b{:08b}", b),
68 Self::TruncatedPointer { got } => {
69 write!(
70 f,
71 "pointer cell truncated: need {} bytes after flag, got {}",
72 POINTER_CELL_LEN - 1,
73 got
74 )
75 }
76 Self::Codec(e) => write!(f, "value codec: {}", e),
77 Self::Overflow(e) => write!(f, "overflow chain: {}", e),
78 }
79 }
80}
81
82impl std::error::Error for ValueLayoutError {}
83
84impl From<value_codec::ValueCodecError> for ValueLayoutError {
85 fn from(e: value_codec::ValueCodecError) -> Self {
86 Self::Codec(e)
87 }
88}
89
90impl From<reddb_file::BTreeValueCellError> for ValueLayoutError {
91 fn from(e: reddb_file::BTreeValueCellError) -> Self {
92 match e {
93 reddb_file::BTreeValueCellError::UnknownFlag(flag) => Self::UnknownFlag(flag),
94 reddb_file::BTreeValueCellError::TruncatedPointer { got } => {
95 Self::TruncatedPointer { got }
96 }
97 }
98 }
99}
100
101impl From<OverflowError> for ValueLayoutError {
102 fn from(e: OverflowError) -> Self {
103 Self::Overflow(e)
104 }
105}
106
107/// Apply the decision ladder and return the bytes the B-tree should
108/// persist in the leaf cell value slot. May allocate overflow pages
109/// through `pager`.
110pub fn encode<P: PageStore>(pager: &P, value: &[u8]) -> Result<Vec<u8>, ValueLayoutError> {
111 if value.len() > MAX_VALUE_SIZE {
112 return Err(ValueLayoutError::ValueTooLarge(value.len()));
113 }
114
115 // Step 1: small values short-circuit to inline raw. No LZ4 work, no
116 // overflow allocation — preserves the legacy hot path for tiny
117 // values like entity IDs or short JSON.
118 if value.len() <= OVERFLOW_THRESHOLD {
119 return Ok(reddb_file::encode_btree_inline_raw(value));
120 }
121
122 // Step 2: above the threshold, try LZ4. The codec returns Raw when
123 // compression would not shrink the input.
124 let (codec_flag, codec_bytes) = value_codec::encode(value);
125
126 // Step 3: if compressed bytes (including their length header) fit
127 // inline, keep them in the leaf. Note this only fires when codec
128 // actually produced Lz4 bytes — Raw bytes ≥ original length > threshold
129 // by construction, so they cannot inline.
130 if codec_flag == value_codec::ValueFlag::Lz4 && codec_bytes.len() <= OVERFLOW_THRESHOLD {
131 return Ok(reddb_file::encode_btree_inline_compressed(&codec_bytes));
132 }
133
134 // Step 4: spill. We always store the bytes the codec produced —
135 // either Lz4 (with its 4-byte length header) so the reader can
136 // round-trip via the same codec, or Raw bytes when compression
137 // failed to shrink.
138 let is_compressed = codec_flag == value_codec::ValueFlag::Lz4;
139 let chain = OverflowChain::new(pager);
140 let (head, total_len) = chain.store(&codec_bytes)?;
141
142 Ok(reddb_file::encode_btree_pointer(
143 head,
144 total_len,
145 is_compressed,
146 ))
147}
148
149/// Inspect leaf-cell flags, follow the pointer if any, concatenate the
150/// chain, decode if compressed, return the materialised value.
151pub fn decode<P: PageStore>(pager: &P, stored: &[u8]) -> Result<Vec<u8>, ValueLayoutError> {
152 match reddb_file::decode_btree_value_cell(stored)? {
153 BTreeValueCell::Pointer {
154 is_compressed,
155 head_page_id,
156 total_len,
157 } => {
158 let chain = OverflowChain::new(pager);
159 let chain_bytes = chain.read(head_page_id, total_len)?;
160 if is_compressed {
161 Ok(value_codec::decode(
162 value_codec::ValueFlag::Lz4,
163 &chain_bytes,
164 )?)
165 } else {
166 Ok(chain_bytes)
167 }
168 }
169 BTreeValueCell::Inline {
170 is_compressed,
171 payload,
172 } => Ok(reddb_file::decode_btree_inline_payload(
173 is_compressed,
174 payload,
175 )?),
176 }
177}
178
179/// Return the head overflow page id when `stored` represents a pointer
180/// cell, else `None`. Returns `None` for inline (raw or compressed)
181/// cells and for empty/malformed cells. Callers use this from the
182/// B-tree delete and shrinking-update paths (slice F of PRD #662) to
183/// free the chain before the leaf cell goes away.
184pub fn pointer_head(stored: &[u8]) -> Option<u32> {
185 reddb_file::btree_value_pointer_head(stored)
186}
187
188/// `true` when [`encode`] would emit a spill pointer for a value of
189/// this length (without actually spilling). Used by the leaf-fit check
190/// in `bulk_insert_sorted` to size cells before allocation.
191#[inline]
192#[allow(dead_code)]
193pub fn projected_cell_len(input: &[u8]) -> usize {
194 let codec_len = value_codec::would_encode_to(input);
195 reddb_file::btree_projected_cell_len(input.len(), codec_len)
196}