spg_storage/row_locator.rs
1// `RowLocator` is the v5.1 PB-index value type; it crosses
2// usize ↔ u32/u64 boundaries on serialisation. The casts are
3// bounded by `RowLocator::MAX_HOT_INDEX` / `MAX_SEGMENT_ID` and
4// surface as `RowLocatorError` rather than panicking.
5#![allow(clippy::cast_possible_truncation, clippy::cast_lossless)]
6
7//! v5.1 — two-tier row pointer. The PB secondary index used to
8//! map `IndexKey → Vec<usize>`, where each `usize` was a row
9//! position in `Table::rows: PersistentVec<Row>` (the hot tier).
10//! v5.1 widens that to `Vec<RowLocator>` so a single key can
11//! point to a mix of rows in the in-memory hot tier and rows
12//! that have been frozen to immutable cold-tier segment files
13//! (`spg-storage::segment`).
14//!
15//! ## Why this is its own type, not just an `enum`
16//!
17//! The `RowLocator` carries two pieces of structural truth that
18//! the v5 design pins:
19//!
20//! 1. **`Hot(usize)` is the v4 shape preserved.** Any existing
21//! PB index entry materialises as `Hot(row_index)` after the
22//! v8 → v9 catalog upgrade; readers that don't yet know about
23//! cold tiers can drop the `Cold` arm via `as_hot()` and
24//! behave exactly like v4.
25//!
26//! 2. **`Cold { segment_id, page_offset }` is self-contained.**
27//! The 32-bit `segment_id` indexes `Catalog::cold_segments`;
28//! `page_offset` is the byte offset of the page (already
29//! page-aligned, i.e. a multiple of `SEGMENT_PAGE_BYTES`)
30//! inside the segment file. The locator does **not** carry
31//! the row's within-page offset — that's recoverable via the
32//! segment's own binary search on the page given the lookup
33//! key, so the locator stays compact (8 bytes payload).
34//!
35//! ## Serialisation
36//!
37//! On-disk wire format used by the v5.1 catalog (file format v9):
38//!
39//! ```text
40//! Hot(idx): [u8 0x00][u64 LE idx]
41//! Cold{s,p}: [u8 0x01][u32 LE segment_id][u32 LE page_offset]
42//! ```
43//!
44//! The tag byte is what lets a v9 reader disambiguate the
45//! variants; v8 catalogs (which only ever wrote raw `u64` row
46//! indices) are upgraded by the catalog-level decoder, not by
47//! `read_le` here — keeping the locator's wire format clean.
48
49use alloc::format;
50use alloc::string::String;
51use alloc::vec::Vec;
52use core::fmt;
53
54const TAG_HOT: u8 = 0x00;
55const TAG_COLD: u8 = 0x01;
56
57/// Errors surfaced by `RowLocator::read_le` when the byte slice
58/// doesn't match either tagged variant layout.
59#[derive(Debug, PartialEq, Eq)]
60pub enum RowLocatorError {
61 /// Slice was shorter than the minimum tagged-variant length.
62 TooShort { got: usize, need: usize },
63 /// Tag byte wasn't `TAG_HOT` (0x00) or `TAG_COLD` (0x01).
64 BadTag { got: u8 },
65 /// Caller used `read_le` with a `Cold` payload but the slice
66 /// stopped before the 8 bytes of `(segment_id, page_offset)`.
67 TruncatedCold { got: usize, need: usize },
68 /// Catalog format v8 fallback: caller asked `read_le_legacy_u64`
69 /// to wrap a row index that doesn't fit in `usize` on this
70 /// target (a 32-bit target reading a v8 catalog with > 4 G
71 /// rows per table). Surface explicitly rather than wrapping.
72 LegacyIndexOverflow(String),
73}
74
75impl fmt::Display for RowLocatorError {
76 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 match self {
78 Self::TooShort { got, need } => {
79 write!(f, "row_locator: too short, got {got} bytes, need {need}")
80 }
81 Self::BadTag { got } => write!(
82 f,
83 "row_locator: bad tag 0x{got:02x}, expected 0x00 (Hot) or 0x01 (Cold)"
84 ),
85 Self::TruncatedCold { got, need } => write!(
86 f,
87 "row_locator: cold variant truncated, got {got} bytes, need {need}"
88 ),
89 Self::LegacyIndexOverflow(s) => write!(f, "row_locator: legacy v8 index overflow: {s}"),
90 }
91 }
92}
93
94/// Two-tier row pointer; PB index value type after v5.1.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
96pub enum RowLocator {
97 /// Row lives in `Table::rows` (in-memory hot tier). The
98 /// `usize` is the row's index into that `PersistentVec<Row>`
99 /// — same semantics as the pre-v5 `usize` value the PB used
100 /// to store.
101 Hot(usize),
102 /// Row lives in a cold-tier segment file. `segment_id`
103 /// indexes `Catalog::cold_segments`; `page_offset` is the
104 /// byte offset of the containing page inside the segment
105 /// file (multiple of `SEGMENT_PAGE_BYTES`). The within-page
106 /// row position is recovered by binary-searching the page
107 /// at lookup time using the user's PK as the search key.
108 Cold { segment_id: u32, page_offset: u32 },
109}
110
111impl RowLocator {
112 /// True if this locator points into the hot tier.
113 #[must_use]
114 pub const fn is_hot(&self) -> bool {
115 matches!(self, Self::Hot(_))
116 }
117
118 /// True if this locator points into a cold segment.
119 #[must_use]
120 pub const fn is_cold(&self) -> bool {
121 matches!(self, Self::Cold { .. })
122 }
123
124 /// Extract the hot-tier row index, or `None` if cold.
125 #[must_use]
126 pub const fn as_hot(&self) -> Option<usize> {
127 match self {
128 Self::Hot(i) => Some(*i),
129 Self::Cold { .. } => None,
130 }
131 }
132
133 /// Extract the cold `(segment_id, page_offset)` pair, or
134 /// `None` if hot.
135 #[must_use]
136 pub const fn as_cold(&self) -> Option<(u32, u32)> {
137 match self {
138 Self::Cold {
139 segment_id,
140 page_offset,
141 } => Some((*segment_id, *page_offset)),
142 Self::Hot(_) => None,
143 }
144 }
145
146 /// Wire byte count if serialised by `write_le`. Constant per
147 /// variant: 9 for Hot (tag + u64), 9 for Cold (tag + u32 +
148 /// u32). Used by the catalog encoder to pre-size buffers.
149 #[must_use]
150 pub const fn encoded_len(&self) -> usize {
151 // Both variants encode to exactly 9 bytes. This is
152 // intentional: callers can `Vec::with_capacity(N × 9)`
153 // without branching, and the v9 catalog decoder reads
154 // exactly 9 bytes per locator without a length prefix.
155 9
156 }
157
158 /// Append the wire representation to `out`. See the module
159 /// doc for the layout.
160 pub fn write_le(&self, out: &mut Vec<u8>) {
161 match self {
162 Self::Hot(i) => {
163 out.push(TAG_HOT);
164 out.extend_from_slice(&(*i as u64).to_le_bytes());
165 }
166 Self::Cold {
167 segment_id,
168 page_offset,
169 } => {
170 out.push(TAG_COLD);
171 out.extend_from_slice(&segment_id.to_le_bytes());
172 out.extend_from_slice(&page_offset.to_le_bytes());
173 }
174 }
175 }
176
177 /// Parse one locator from the start of `input`. Returns the
178 /// locator + the number of bytes consumed (always 9 in v1).
179 pub fn read_le(input: &[u8]) -> Result<(Self, usize), RowLocatorError> {
180 if input.is_empty() {
181 return Err(RowLocatorError::TooShort { got: 0, need: 1 });
182 }
183 let tag = input[0];
184 match tag {
185 TAG_HOT => {
186 if input.len() < 9 {
187 return Err(RowLocatorError::TooShort {
188 got: input.len(),
189 need: 9,
190 });
191 }
192 let idx = u64::from_le_bytes([
193 input[1], input[2], input[3], input[4], input[5], input[6], input[7], input[8],
194 ]);
195 // u64 → usize: on 64-bit targets identity; on
196 // 32-bit targets fail rather than truncate.
197 let idx_usize = usize::try_from(idx).map_err(|_| {
198 RowLocatorError::LegacyIndexOverflow(format!(
199 "Hot row index {idx} exceeds usize on this target"
200 ))
201 })?;
202 Ok((Self::Hot(idx_usize), 9))
203 }
204 TAG_COLD => {
205 if input.len() < 9 {
206 return Err(RowLocatorError::TruncatedCold {
207 got: input.len(),
208 need: 9,
209 });
210 }
211 let segment_id = u32::from_le_bytes([input[1], input[2], input[3], input[4]]);
212 let page_offset = u32::from_le_bytes([input[5], input[6], input[7], input[8]]);
213 Ok((
214 Self::Cold {
215 segment_id,
216 page_offset,
217 },
218 9,
219 ))
220 }
221 other => Err(RowLocatorError::BadTag { got: other }),
222 }
223 }
224
225 /// Wrap a raw `u64` row index from a v8 catalog stream as a
226 /// `RowLocator::Hot(_)`. Catalog format v8 stored bare u64
227 /// row indices without a tag byte; the v9 reader uses this
228 /// to upgrade-in-place rather than rejecting v8 input. Fails
229 /// only if the index doesn't fit in `usize` on this target.
230 pub fn from_legacy_v8_u64(idx: u64) -> Result<Self, RowLocatorError> {
231 let idx_usize = usize::try_from(idx).map_err(|_| {
232 RowLocatorError::LegacyIndexOverflow(format!(
233 "Hot row index {idx} exceeds usize on this target"
234 ))
235 })?;
236 Ok(Self::Hot(idx_usize))
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use alloc::vec;
244
245 #[test]
246 fn hot_constructs_and_inspects() {
247 let l = RowLocator::Hot(42);
248 assert!(l.is_hot());
249 assert!(!l.is_cold());
250 assert_eq!(l.as_hot(), Some(42));
251 assert_eq!(l.as_cold(), None);
252 }
253
254 #[test]
255 fn cold_constructs_and_inspects() {
256 let l = RowLocator::Cold {
257 segment_id: 7,
258 page_offset: 4096 * 9,
259 };
260 assert!(l.is_cold());
261 assert!(!l.is_hot());
262 assert_eq!(l.as_hot(), None);
263 assert_eq!(l.as_cold(), Some((7, 36_864)));
264 }
265
266 #[test]
267 fn encoded_len_is_constant() {
268 assert_eq!(RowLocator::Hot(0).encoded_len(), 9);
269 assert_eq!(RowLocator::Hot(usize::MAX).encoded_len(), 9);
270 assert_eq!(
271 RowLocator::Cold {
272 segment_id: u32::MAX,
273 page_offset: u32::MAX,
274 }
275 .encoded_len(),
276 9
277 );
278 }
279
280 #[test]
281 fn roundtrip_hot_via_write_le_read_le() {
282 for &idx in &[0_usize, 1, 42, 1_000_000, usize::MAX] {
283 let l = RowLocator::Hot(idx);
284 let mut buf = Vec::new();
285 l.write_le(&mut buf);
286 assert_eq!(buf.len(), 9);
287 let (parsed, consumed) = RowLocator::read_le(&buf).expect("hot roundtrip parses");
288 assert_eq!(parsed, l);
289 assert_eq!(consumed, 9);
290 }
291 }
292
293 #[test]
294 fn roundtrip_cold_via_write_le_read_le() {
295 for &(s, p) in &[
296 (0_u32, 0_u32),
297 (1, 4096),
298 (42, 4096 * 7),
299 (u32::MAX, u32::MAX),
300 ] {
301 let l = RowLocator::Cold {
302 segment_id: s,
303 page_offset: p,
304 };
305 let mut buf = Vec::new();
306 l.write_le(&mut buf);
307 assert_eq!(buf.len(), 9);
308 let (parsed, consumed) = RowLocator::read_le(&buf).expect("cold roundtrip parses");
309 assert_eq!(parsed, l);
310 assert_eq!(consumed, 9);
311 }
312 }
313
314 #[test]
315 fn mixed_concat_decodes_in_sequence() {
316 let entries = [
317 RowLocator::Hot(7),
318 RowLocator::Cold {
319 segment_id: 2,
320 page_offset: 4096,
321 },
322 RowLocator::Hot(99),
323 ];
324 let mut buf = Vec::new();
325 for e in &entries {
326 e.write_le(&mut buf);
327 }
328 assert_eq!(buf.len(), 27);
329 let mut offset = 0;
330 let mut decoded = Vec::new();
331 while offset < buf.len() {
332 let (l, n) = RowLocator::read_le(&buf[offset..]).expect("decode succeeds");
333 decoded.push(l);
334 offset += n;
335 }
336 assert_eq!(offset, buf.len());
337 assert_eq!(decoded.as_slice(), entries.as_slice());
338 }
339
340 #[test]
341 fn read_le_rejects_empty_input() {
342 match RowLocator::read_le(&[]) {
343 Err(RowLocatorError::TooShort { got: 0, need: 1 }) => {}
344 other => panic!("expected TooShort, got {other:?}"),
345 }
346 }
347
348 #[test]
349 fn read_le_rejects_bad_tag() {
350 // Tag 0xff isn't Hot (0x00) or Cold (0x01).
351 let mut buf = vec![0xff_u8];
352 buf.extend_from_slice(&0_u64.to_le_bytes());
353 match RowLocator::read_le(&buf) {
354 Err(RowLocatorError::BadTag { got: 0xff }) => {}
355 other => panic!("expected BadTag, got {other:?}"),
356 }
357 }
358
359 #[test]
360 fn read_le_rejects_truncated_hot() {
361 // Valid Hot tag but only 4 bytes of payload (need 8).
362 let buf = [TAG_HOT, 0x01, 0x02, 0x03, 0x04];
363 match RowLocator::read_le(&buf) {
364 Err(RowLocatorError::TooShort { got: 5, need: 9 }) => {}
365 other => panic!("expected TooShort, got {other:?}"),
366 }
367 }
368
369 #[test]
370 fn read_le_rejects_truncated_cold() {
371 let buf = [TAG_COLD, 0x01, 0x02, 0x03];
372 match RowLocator::read_le(&buf) {
373 Err(RowLocatorError::TruncatedCold { got: 4, need: 9 }) => {}
374 other => panic!("expected TruncatedCold, got {other:?}"),
375 }
376 }
377
378 #[test]
379 fn from_legacy_v8_u64_wraps_as_hot() {
380 for &idx in &[0_u64, 1, 1_000_000, u64::from(u32::MAX)] {
381 let l = RowLocator::from_legacy_v8_u64(idx).expect("fits usize on 64-bit");
382 assert_eq!(l.as_hot(), Some(idx as usize));
383 }
384 }
385
386 /// Default enum repr is 16 bytes (8-byte discriminant + 8-byte
387 /// max payload). The v5.1 design accepts this and revisits
388 /// only if PB perf gate measurements show regression. Pin the
389 /// size here so a future repr change is caught explicitly.
390 #[test]
391 fn size_is_16_bytes() {
392 assert_eq!(core::mem::size_of::<RowLocator>(), 16);
393 }
394}