Skip to main content

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}