Skip to main content

spg_storage/
segment.rs

1// Segment encoding crosses the u64/usize → f64 boundary in offset
2// arithmetic; the `as` casts on file offsets and page indices are
3// well-defined and bounded by `num_rows` / `num_pages` which the
4// writer caps at `u32::MAX` rows per segment.
5#![allow(
6    clippy::cast_lossless,
7    clippy::cast_possible_truncation,
8    clippy::cast_possible_wrap,
9    clippy::doc_markdown,
10    clippy::items_after_statements,
11    clippy::similar_names,
12    clippy::unreadable_literal
13)]
14
15//! v5.0 — cold-tier segment file codec. A `Segment` is an immutable,
16//! PK-sorted file of `(u64_key, row_bytes)` entries with three
17//! sidecar sections for fast probing: a `BloomFilter` over the
18//! keys, a page index, and the payload pages themselves. The v5
19//! freezer (v5.2 work) writes one segment per "freeze batch"; the
20//! v5.1 two-tier catalog probes the bloom first, then the page
21//! index, then a single 4 KiB page read — so a missed cold-tier
22//! probe costs at most ~`bloom.contains()` time, and a hit costs
23//! one disk seek + page-internal binary search.
24//!
25//! **Byte-only API.** This module is `no_std`-safe and never
26//! touches `std::fs`. The writer produces a `Vec<u8>` that the
27//! caller writes to disk; the reader takes a `&[u8]` slice that
28//! the caller obtained via `std::fs::read` (full-load) or
29//! `mmap`/seek-style page-at-a-time access. Splitting codec from
30//! file I/O lets v5.1 wrap a `SeekableSegmentReader` around the
31//! same byte layout without forcing spg-storage onto `std`.
32//!
33//! ## File format (v1, frozen from v5.0 ship)
34//!
35//! ```text
36//! [8 bytes  b"SPGSEG\x01"]                magic + version 1
37//! [u32 LE   num_rows]                     count, ≤ u32::MAX
38//! [u32 LE   num_pages]                    count, ≤ u32::MAX
39//! [u32 LE   page_size_bytes]              4096 in v5.0 (stored
40//!                                          so future versions can
41//!                                          tune without bumping
42//!                                          magic)
43//! [u64 LE   min_pk]                       smallest PK in segment
44//! [u64 LE   max_pk]                       largest PK in segment
45//! [u32 LE   bloom_len_bytes]              length-prefixed bloom
46//! [bloom bytes ...]                       BloomFilter::to_bytes
47//!                                          output, verbatim
48//! [u32 LE   page_index_len_bytes]         length-prefixed index
49//! [page index bytes ...]                  Vec<(u64 first_pk_of_page,
50//!                                          u32 file_offset)>
51//!                                          serialised LE-packed
52//! [page 0]                                page_size_bytes bytes
53//! [page 1]
54//! ...
55//! [page N-1]
56//! [u32 LE   crc32_body]                   crc32 over everything
57//!                                          from `num_rows` byte
58//!                                          through the last page
59//! ```
60//!
61//! ## Page format (v1)
62//!
63//! Each page is exactly `page_size_bytes` (4096 in v5.0). Inside
64//! a page:
65//!
66//! ```text
67//! [u32 LE   num_rows_in_page]             how many rows pack here
68//! [u32 LE × num_rows_in_page  row_offsets]  byte offset within
69//!                                            this page where the
70//!                                            row payload starts
71//! [row payload bytes ...]                 concatenated, no padding
72//! [zero padding ...]                      to page_size_bytes
73//! ```
74//!
75//! Each row payload is `[u64 LE key][u32 LE payload_len]
76//! [payload_len bytes payload]`. Caller owns payload semantics.
77//!
78//! ## What's frozen vs not
79//!
80//! - **Frozen as v1**: magic bytes, header field order/types, bloom
81//!   layout (already frozen via `BloomFilter` v1), page-index layout,
82//!   page-internal layout, CRC32 algorithm.
83//! - **Not frozen**: `page_size_bytes` value (4096 is the v5.0
84//!   default; future versions may tune via env knob without
85//!   bumping magic — the field is stored in-band).
86
87use alloc::format;
88use alloc::string::String;
89use alloc::vec;
90use alloc::vec::Vec;
91use core::fmt;
92
93use spg_crypto::crc32::crc32;
94
95use crate::bloom::{BloomError, BloomFilter};
96
97/// Segment file magic. Distinct from `SPGDB001` (catalog snapshot,
98/// v3.0) and `SPGBKUP\x01`/`\x02` (backup bundles, v4.25/v4.37) so a
99/// reader can disambiguate a stray slice.
100pub const SEGMENT_MAGIC: [u8; 8] = *b"SPGSEG\x01\x00";
101
102/// v6.6.2 — segment file v2 magic. A v2 file wraps the v1 byte
103/// sequence (magic + body + CRC32 footer) inside a compression
104/// envelope:
105///   [8-byte magic SEGMENT_MAGIC_V2]
106///   [u8 algo: 0=none, 1=LZSS]
107///   [u32 LE inner_uncompressed_len]
108///   [inner bytes — either the raw v1 segment OR LZSS-compressed]
109/// v6.6+ readers detect v2 by magic and transparently unwrap; v1
110/// files (magic `SPGSEG\x01\x00`) still load through the legacy
111/// parser path with zero changes.
112pub const SEGMENT_MAGIC_V2: [u8; 8] = *b"SPGSEG\x02\x00";
113
114/// v7.23 (mailrs round-14) — inner-format v3 magic. Identical to
115/// the v1 layout EXCEPT the dense row bodies use the escaped
116/// short-string codec (`spg-storage`'s `STR_LEN_ESCAPE`): TEXT
117/// cells above 64 KiB encode as `[u16 0xFFFF][u32 real_len]`. v1
118/// inner bytes keep plain-u16 decoding (0xFFFF is a legitimate
119/// length there). The v2 COMPRESSION envelope is orthogonal — it
120/// may wrap either inner format; readers unwrap then dispatch on
121/// the inner magic.
122pub const SEGMENT_MAGIC_V3: [u8; 8] = *b"SPGSEG\x03\x00";
123
124/// v7.27 (mailrs round-21) — inner-format v4 magic: row bodies use
125/// the FULL escaped-length codec (BYTEA cells, TEXT[] elements and
126/// ts lexemes escape too, not just short strings). Maps onto
127/// catalog codec_version 47; V3 maps to 46, V1 to legacy 0.
128pub const SEGMENT_MAGIC_V4: [u8; 8] = *b"SPGSEG\x04\x00";
129
130/// v6.7.1 — BRIN sidecar tag inside the v2 envelope's inner bytes.
131/// Distinguishes "inner is plain v1 bytes" (current) from "inner is
132/// `[BRIN_SIDECAR_MAGIC][u32 brin_section_len][BRIN entries][v1 segment bytes]`".
133/// Distinct prefix so a v1 segment (which starts with `SPGSEG\x01\x00`)
134/// can't be confused with a BRIN-sidecar-wrapped inner.
135pub const BRIN_SIDECAR_MAGIC: [u8; 4] = *b"BRIN";
136
137/// v6.7.1 — one BRIN summary entry: (page_index, min_key, max_key).
138/// 20 bytes on disk: `[u32 page_index][u64 min_key][u64 max_key]`.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub struct BrinSummary {
141    pub page_index: u32,
142    pub min_key: u64,
143    pub max_key: u64,
144}
145pub(crate) const SEGMENT_V2_HEADER_LEN: usize = 8 + 1 + 4;
146pub const SEGMENT_COMPRESS_ALGO_NONE: u8 = 0;
147pub const SEGMENT_COMPRESS_ALGO_LZSS: u8 = 1;
148
149/// Default page byte count. Stored in the segment header so future
150/// versions can tune without a magic bump. 4096 matches APFS / ext4
151/// default page size — a single page read is one disk I/O on every
152/// mainstream filesystem.
153pub const SEGMENT_PAGE_BYTES: u32 = 4096;
154
155/// Header byte count from `magic` through `page_index_len_bytes`
156/// **not** counting the variable-length bloom + page index. Used
157/// by the writer to reserve space; used by the reader to compute
158/// fixed-offset fields.
159const HEADER_FIXED_LEN: usize = 8 + 4 + 4 + 4 + 8 + 8 + 4; // = 40
160
161/// CRC32 footer length.
162const FOOTER_LEN: usize = 4;
163
164/// Errors surfaced by the segment reader. Includes the inner
165/// `BloomError` since the bloom is parsed during `open`.
166#[derive(Debug)]
167pub enum SegmentError {
168    TooShort {
169        got: usize,
170        need: usize,
171    },
172    BadMagic {
173        got: [u8; 8],
174    },
175    BadShape(String),
176    BadCrc {
177        expected: u32,
178        got: u32,
179    },
180    BloomError(BloomError),
181    UnsortedKey {
182        prev: u64,
183        next: u64,
184    },
185    KeyNotInPage {
186        key: u64,
187    },
188    /// Caller asked for a page outside `[0, num_pages)`.
189    PageOutOfRange {
190        got: u32,
191        num_pages: u32,
192    },
193    /// v6.6.2 — v2 envelope's inner LZSS payload failed to
194    /// decompress. The contained string is the underlying
195    /// `LzssError` rendered.
196    CompressionDecodeFailed(String),
197    /// v6.6.2 — v2 envelope declares an unknown compression algo
198    /// byte. Refuse to read forward without knowing how to
199    /// interpret the inner bytes.
200    UnknownCompressionAlgo(u8),
201}
202
203impl fmt::Display for SegmentError {
204    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205        match self {
206            Self::TooShort { got, need } => write!(
207                f,
208                "segment: too short, got {got} bytes, need at least {need}"
209            ),
210            Self::BadMagic { got } => {
211                write!(f, "segment: bad magic {got:?}, expected {SEGMENT_MAGIC:?}")
212            }
213            Self::BadShape(s) => write!(f, "segment: bad shape: {s}"),
214            Self::BadCrc { expected, got } => write!(
215                f,
216                "segment: crc mismatch, expected 0x{expected:08x}, got 0x{got:08x}"
217            ),
218            Self::BloomError(e) => write!(f, "segment: bloom decode failed: {e}"),
219            Self::UnsortedKey { prev, next } => write!(
220                f,
221                "segment: writer received unsorted keys (prev={prev}, next={next}); \
222                 the segment contract requires ascending u64 keys"
223            ),
224            Self::KeyNotInPage { key } => {
225                write!(f, "segment: key {key} not found in target page")
226            }
227            Self::PageOutOfRange { got, num_pages } => write!(
228                f,
229                "segment: page index {got} out of range, num_pages = {num_pages}"
230            ),
231            Self::CompressionDecodeFailed(s) => {
232                write!(f, "segment v2 envelope: LZSS decompress failed: {s}")
233            }
234            Self::UnknownCompressionAlgo(b) => write!(
235                f,
236                "segment v2 envelope: unknown compression algo byte {b:#04x}"
237            ),
238        }
239    }
240}
241
242impl From<BloomError> for SegmentError {
243    fn from(e: BloomError) -> Self {
244        Self::BloomError(e)
245    }
246}
247
248/// Lightweight summary of a finished segment — what the catalog
249/// manifest (v5.3 work) records to find the segment on disk and
250/// what `RowLocator::Cold` (v5.1 work) carries inside the PB
251/// index. Generated by `Segment::encode`.
252#[derive(Debug, Clone)]
253pub struct SegmentMeta {
254    pub num_rows: u64,
255    pub num_pages: u32,
256    pub page_size_bytes: u32,
257    pub min_pk: u64,
258    pub max_pk: u64,
259    /// Length of the full serialised segment in bytes. Useful for
260    /// preallocating the file or sanity-checking after `write_all`.
261    pub total_bytes: usize,
262}
263
264/// One page-index entry: `(first_pk_in_page, file_offset_to_page_start)`.
265/// `Vec<PageIndexEntry>` is sorted by `first_pk`, so a `lookup(key)`
266/// binary-searches this to find the candidate page, then reads /
267/// parses that page only.
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269struct PageIndexEntry {
270    first_pk: u64,
271    file_offset: u32,
272}
273
274/// Build a complete segment as a single `Vec<u8>`. Caller writes
275/// the returned bytes to disk; subsequent reads happen via
276/// `SegmentReader` against either the full in-RAM slice (for the
277/// v5.0 standalone perf gates) or a seekable wrapper that pulls
278/// one page at a time (v5.1 catalog integration).
279///
280/// `bloom_target_fp` sizes the embedded bloom; the standard v5
281/// default is `0.01` (1 % false-positive ceiling). Callers can
282/// trade per-segment bloom size for selectivity (smaller fp_rate
283/// → larger bloom).
284///
285/// `rows` must yield entries with **ascending u64 keys**; any
286/// descending or duplicate-out-of-order entry returns
287/// `SegmentError::UnsortedKey`.
288///
289/// `page_size_bytes` should be 4096 in v5.0 ship; the writer
290/// rejects values smaller than 256 (would force pathological
291/// page-count) and larger than 65536 (would defeat the
292/// page-granularity I/O assumption).
293#[allow(clippy::too_many_lines)]
294pub fn encode_segment<I>(
295    rows: I,
296    bloom_target_fp: f64,
297    page_size_bytes: u32,
298) -> Result<(Vec<u8>, SegmentMeta), SegmentError>
299where
300    I: ExactSizeIterator<Item = (u64, Vec<u8>)>,
301{
302    if !(256..=65_536).contains(&page_size_bytes) {
303        return Err(SegmentError::BadShape(format!(
304            "page_size_bytes {page_size_bytes} must be in [256, 65536]"
305        )));
306    }
307    let num_rows_hint = rows.len();
308    if num_rows_hint == 0 {
309        return Err(SegmentError::BadShape(
310            "encode_segment: at least one row required".into(),
311        ));
312    }
313    // First pass: bucket rows into pages, building the bloom and
314    // recording per-page first-keys for the index. Within each
315    // page we just collect the row payload bytes; the actual
316    // within-page offsets are computed in `serialise_page` once
317    // the final row count for that page is known.
318    let mut bloom = BloomFilter::with_target_fp_rate(num_rows_hint, bloom_target_fp);
319    let mut pages: Vec<Vec<u8>> = Vec::new();
320    // v7.23 — cumulative byte length of `pages` (jumbo pages make
321    // the region variable-width; offsets in the page index are
322    // exact, not page_size multiples).
323    let mut pages_bytes_total: usize = 0;
324    let mut page_index: Vec<PageIndexEntry> = Vec::new();
325    let mut row_bytes_in_page: Vec<Vec<u8>> = Vec::new();
326    let mut first_pk_in_page: Option<u64> = None;
327    let mut last_key: Option<u64> = None;
328    let mut min_pk: Option<u64> = None;
329    let mut max_pk: u64 = 0;
330    let mut total_rows: u64 = 0;
331    for (key, payload) in rows {
332        if let Some(prev) = last_key
333            && key <= prev
334        {
335            return Err(SegmentError::UnsortedKey { prev, next: key });
336        }
337        last_key = Some(key);
338        if min_pk.is_none() {
339            min_pk = Some(key);
340        }
341        max_pk = key;
342        total_rows = total_rows.wrapping_add(1);
343        bloom.insert(&key.to_le_bytes());
344        // Row payload as it lives on the page: [u64 key][u32 plen][plen bytes].
345        let mut row_bytes = Vec::with_capacity(12 + payload.len());
346        row_bytes.extend_from_slice(&key.to_le_bytes());
347        let plen = u32::try_from(payload.len()).map_err(|_| {
348            SegmentError::BadShape(format!(
349                "row payload too large: {} bytes > u32::MAX",
350                payload.len()
351            ))
352        })?;
353        row_bytes.extend_from_slice(&plen.to_le_bytes());
354        row_bytes.extend_from_slice(&payload);
355        // Check if adding this row would overflow the page. The
356        // resulting page is laid out as:
357        //   [u32 num_rows][u32 × num_rows offsets][row bytes...]
358        // so the byte cost of N rows in a page is
359        //   4 + 4*N + sum(row.len()).
360        let proposed_num_rows = row_bytes_in_page.len() + 1;
361        let proposed_offsets_bytes = proposed_num_rows * 4;
362        let proposed_rows_bytes: usize =
363            row_bytes_in_page.iter().map(Vec::len).sum::<usize>() + row_bytes.len();
364        let proposed_size = 4 + proposed_offsets_bytes + proposed_rows_bytes;
365        if proposed_size > page_size_bytes as usize {
366            // Finalise the current page (if any) first.
367            if !row_bytes_in_page.is_empty() {
368                let page_file_offset =
369                    u32::try_from(pages_bytes_total).expect("pages region fits u32");
370                page_index.push(PageIndexEntry {
371                    first_pk: first_pk_in_page.expect("page is non-empty"),
372                    file_offset: page_file_offset,
373                });
374                let finalised = serialise_page(&row_bytes_in_page, page_size_bytes as usize);
375                pages_bytes_total += finalised.len();
376                pages.push(finalised);
377                row_bytes_in_page.clear();
378                first_pk_in_page = None;
379            }
380            // v7.23 (round-14) — a single row larger than the page
381            // becomes its own UNPADDED jumbo page (mail bodies /
382            // document text routinely exceed any sane page size,
383            // and rows are indivisible). Page boundaries are read
384            // from the page index offsets, which jumbo pages keep
385            // exact; v1 fixed-width files satisfy the same offsets,
386            // so the reader has no per-version branch.
387            if 4 + 4 + row_bytes.len() > page_size_bytes as usize {
388                let page_file_offset =
389                    u32::try_from(pages_bytes_total).expect("pages region fits u32");
390                page_index.push(PageIndexEntry {
391                    first_pk: key,
392                    file_offset: page_file_offset,
393                });
394                let natural = 4 + 4 + row_bytes.len();
395                let jumbo = serialise_page(core::slice::from_ref(&row_bytes), natural);
396                pages_bytes_total += jumbo.len();
397                pages.push(jumbo);
398                continue;
399            }
400        }
401        // Now add to the (possibly fresh) current page.
402        if first_pk_in_page.is_none() {
403            first_pk_in_page = Some(key);
404        }
405        row_bytes_in_page.push(row_bytes);
406    }
407    // Finalise the last page (empty when the final row closed as a
408    // jumbo page).
409    if !row_bytes_in_page.is_empty() {
410        let page_file_offset = u32::try_from(pages_bytes_total).expect("pages region fits u32");
411        page_index.push(PageIndexEntry {
412            first_pk: first_pk_in_page.expect("trailing page is non-empty"),
413            file_offset: page_file_offset,
414        });
415        let final_page = serialise_page(&row_bytes_in_page, page_size_bytes as usize);
416        pages_bytes_total += final_page.len();
417        pages.push(final_page);
418    }
419    let num_pages = u32::try_from(pages.len()).map_err(|_| {
420        SegmentError::BadShape(format!(
421            "segment has {} pages, exceeds u32::MAX",
422            pages.len()
423        ))
424    })?;
425    let num_rows = total_rows;
426    let num_rows_u32 = u32::try_from(num_rows)
427        .map_err(|_| SegmentError::BadShape(format!("num_rows {num_rows} exceeds u32::MAX")))?;
428    let min_pk = min_pk.expect("non-empty rows");
429    // Serialise bloom + page index ahead of time so we know their
430    // byte lengths (the header carries them as length prefixes).
431    let bloom_bytes = bloom.to_bytes();
432    let page_index_bytes = encode_page_index(&page_index);
433    // Assemble the file.
434    let mut out = Vec::with_capacity(
435        HEADER_FIXED_LEN
436            + 4
437            + bloom_bytes.len()
438            + 4
439            + page_index_bytes.len()
440            + pages_bytes_total
441            + FOOTER_LEN,
442    );
443    // v7.27 — new segments carry the V4 inner magic: row bodies use
444    // the full escaped-length codec (strings since V3; BYTEA,
445    // TEXT[] elements and ts lexemes since V4). Layout is otherwise
446    // byte-identical to v1.
447    out.extend_from_slice(&SEGMENT_MAGIC_V4);
448    let body_start = out.len();
449    out.extend_from_slice(&num_rows_u32.to_le_bytes());
450    out.extend_from_slice(&num_pages.to_le_bytes());
451    out.extend_from_slice(&page_size_bytes.to_le_bytes());
452    out.extend_from_slice(&min_pk.to_le_bytes());
453    out.extend_from_slice(&max_pk.to_le_bytes());
454    out.extend_from_slice(
455        &u32::try_from(bloom_bytes.len())
456            .expect("bloom < 4 GiB")
457            .to_le_bytes(),
458    );
459    out.extend_from_slice(&bloom_bytes);
460    out.extend_from_slice(
461        &u32::try_from(page_index_bytes.len())
462            .expect("page index < 4 GiB")
463            .to_le_bytes(),
464    );
465    out.extend_from_slice(&page_index_bytes);
466    for page in &pages {
467        // v7.23 — pages are page_size-wide EXCEPT jumbo pages
468        // (single rows larger than the page), which are exactly
469        // their natural size. Offsets in the page index are exact
470        // either way.
471        debug_assert!(
472            page.len() == page_size_bytes as usize || page.len() > page_size_bytes as usize,
473            "page neither fixed-size nor jumbo: {} vs {page_size_bytes}",
474            page.len()
475        );
476        out.extend_from_slice(page);
477    }
478    // CRC32 covers everything from `num_rows` (body_start) through
479    // the last page byte. Magic is excluded; footer is the CRC
480    // itself.
481    let crc = crc32(&out[body_start..]);
482    out.extend_from_slice(&crc.to_le_bytes());
483    let meta = SegmentMeta {
484        num_rows,
485        num_pages,
486        page_size_bytes,
487        min_pk,
488        max_pk,
489        total_bytes: out.len(),
490    };
491    Ok((out, meta))
492}
493
494/// Serialise a single page into exactly `page_size_bytes` bytes.
495/// Layout: `[u32 num_rows][u32 row_offsets[num_rows]][row payloads
496/// concatenated]`, zero-padded to the page size. Offsets are
497/// computed here (not at caller's level) because they depend on
498/// the final row count for the page, which is only known at
499/// serialise time.
500fn serialise_page(row_bytes: &[Vec<u8>], page_size_bytes: usize) -> Vec<u8> {
501    let num_rows = u32::try_from(row_bytes.len()).expect("row count fits u32");
502    let offsets_section_bytes = num_rows as usize * 4;
503    let header_total = 4 + offsets_section_bytes;
504    let mut page = Vec::with_capacity(page_size_bytes);
505    page.extend_from_slice(&num_rows.to_le_bytes());
506    // Reserve the offsets section; we'll backfill once we know
507    // each row's byte position.
508    page.resize(header_total, 0);
509    // Append row bytes, recording the within-page offset of each.
510    let mut offsets = Vec::with_capacity(row_bytes.len());
511    for row in row_bytes {
512        offsets.push(u32::try_from(page.len()).expect("page < 4 GiB"));
513        page.extend_from_slice(row);
514    }
515    // Backfill the offsets section.
516    for (i, off) in offsets.iter().enumerate() {
517        let pos = 4 + i * 4;
518        page[pos..pos + 4].copy_from_slice(&off.to_le_bytes());
519    }
520    debug_assert!(
521        page.len() <= page_size_bytes,
522        "page overflow: {} > {page_size_bytes}",
523        page.len()
524    );
525    page.resize(page_size_bytes, 0);
526    page
527}
528
529/// Pack the page index as `[u32 LE count][(u64 LE first_pk, u32 LE
530/// file_offset)...]`. Decoded by `parse_page_index`.
531fn encode_page_index(index: &[PageIndexEntry]) -> Vec<u8> {
532    let mut out = Vec::with_capacity(4 + index.len() * 12);
533    out.extend_from_slice(
534        &u32::try_from(index.len())
535            .expect("page count fits u32")
536            .to_le_bytes(),
537    );
538    for entry in index {
539        out.extend_from_slice(&entry.first_pk.to_le_bytes());
540        out.extend_from_slice(&entry.file_offset.to_le_bytes());
541    }
542    out
543}
544
545fn parse_page_index(input: &[u8]) -> Result<Vec<PageIndexEntry>, SegmentError> {
546    if input.len() < 4 {
547        return Err(SegmentError::BadShape(
548            "page index: too short for count prefix".into(),
549        ));
550    }
551    let count = u32::from_le_bytes([input[0], input[1], input[2], input[3]]) as usize;
552    let expected = 4 + count * 12;
553    if input.len() != expected {
554        return Err(SegmentError::BadShape(format!(
555            "page index: input is {} bytes, expected {} for count {count}",
556            input.len(),
557            expected
558        )));
559    }
560    let mut out = Vec::with_capacity(count);
561    for i in 0..count {
562        let off = 4 + i * 12;
563        let first_pk = u64::from_le_bytes([
564            input[off],
565            input[off + 1],
566            input[off + 2],
567            input[off + 3],
568            input[off + 4],
569            input[off + 5],
570            input[off + 6],
571            input[off + 7],
572        ]);
573        let file_offset = u32::from_le_bytes([
574            input[off + 8],
575            input[off + 9],
576            input[off + 10],
577            input[off + 11],
578        ]);
579        out.push(PageIndexEntry {
580            first_pk,
581            file_offset,
582        });
583    }
584    Ok(out)
585}
586
587/// Parsed segment state — meta, bloom, page-index, and the file
588/// offset where the page payloads begin. Shared between
589/// [`SegmentReader`] (borrows bytes) and [`OwnedSegment`] (owns
590/// bytes) so both share a single `parse + lookup` implementation.
591///
592/// Module-private: callers should hold a `SegmentReader` or an
593/// `OwnedSegment` instead of constructing this directly.
594#[derive(Debug, Clone)]
595struct SegmentMetadata {
596    meta: SegmentMeta,
597    bloom: BloomFilter,
598    page_index: Vec<PageIndexEntry>,
599    /// v7.23/v7.27 — codec version implied by the inner magic
600    /// (V1 → 0 legacy, V3 → 46, V4 → 47). Threaded into
601    /// `decode_row_body_dense`.
602    codec_version: u8,
603    /// File offset where the first page starts. The metadata hides
604    /// the variable-length bloom + page-index sections behind
605    /// this anchor.
606    pages_start_offset: usize,
607}
608
609/// Parse the segment header + bloom + page-index from `bytes`,
610/// validating magic, CRC32 footer, and structural lengths. The
611/// returned [`SegmentMetadata`] is independent of `bytes`'
612/// lifetime so it can be embedded inside an [`OwnedSegment`] that
613/// owns its own `Vec<u8>`.
614fn parse_segment_metadata(bytes: &[u8]) -> Result<SegmentMetadata, SegmentError> {
615    if bytes.len() < HEADER_FIXED_LEN + FOOTER_LEN {
616        return Err(SegmentError::TooShort {
617            got: bytes.len(),
618            need: HEADER_FIXED_LEN + FOOTER_LEN,
619        });
620    }
621    let mut magic = [0u8; 8];
622    magic.copy_from_slice(&bytes[..8]);
623    let codec_version: u8 = if magic == SEGMENT_MAGIC_V4 {
624        47
625    } else if magic == SEGMENT_MAGIC_V3 {
626        46
627    } else if magic == SEGMENT_MAGIC {
628        0
629    } else {
630        return Err(SegmentError::BadMagic { got: magic });
631    };
632    // Header parse.
633    let num_rows = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
634    let num_pages = u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]);
635    let page_size_bytes = u32::from_le_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
636    let min_pk = u64::from_le_bytes([
637        bytes[20], bytes[21], bytes[22], bytes[23], bytes[24], bytes[25], bytes[26], bytes[27],
638    ]);
639    let max_pk = u64::from_le_bytes([
640        bytes[28], bytes[29], bytes[30], bytes[31], bytes[32], bytes[33], bytes[34], bytes[35],
641    ]);
642    let bloom_len = u32::from_le_bytes([bytes[36], bytes[37], bytes[38], bytes[39]]) as usize;
643    let bloom_offset = HEADER_FIXED_LEN;
644    if bytes.len() < bloom_offset + bloom_len + 4 {
645        return Err(SegmentError::TooShort {
646            got: bytes.len(),
647            need: bloom_offset + bloom_len + 4,
648        });
649    }
650    let bloom = BloomFilter::from_bytes(&bytes[bloom_offset..bloom_offset + bloom_len])?;
651    let page_index_len_off = bloom_offset + bloom_len;
652    let page_index_len = u32::from_le_bytes([
653        bytes[page_index_len_off],
654        bytes[page_index_len_off + 1],
655        bytes[page_index_len_off + 2],
656        bytes[page_index_len_off + 3],
657    ]) as usize;
658    let page_index_off = page_index_len_off + 4;
659    if bytes.len() < page_index_off + page_index_len {
660        return Err(SegmentError::TooShort {
661            got: bytes.len(),
662            need: page_index_off + page_index_len,
663        });
664    }
665    let page_index = parse_page_index(&bytes[page_index_off..page_index_off + page_index_len])?;
666    let pages_start_offset = page_index_off + page_index_len;
667    // v7.23 — jumbo pages make the pages region variable-width, so
668    // the exact-length check from fixed-width days only holds for
669    // v1 inner files. For both formats the structural invariants
670    // are: every indexed page offset lies inside the pages region,
671    // and the region ends exactly at the footer.
672    let pages_total_bytes = num_pages as usize * page_size_bytes as usize;
673    let expected_total = pages_start_offset + pages_total_bytes + FOOTER_LEN;
674    let exact_len_applies = codec_version == 0;
675    if pages_start_offset + FOOTER_LEN > bytes.len()
676        || page_index
677            .iter()
678            .any(|e| pages_start_offset + e.file_offset as usize > bytes.len() - FOOTER_LEN)
679    {
680        return Err(SegmentError::BadShape(format!(
681            "segment: page index points past the {} input bytes",
682            bytes.len()
683        )));
684    }
685    if exact_len_applies && bytes.len() != expected_total {
686        return Err(SegmentError::BadShape(format!(
687            "segment: input is {} bytes, header implies {expected_total}",
688            bytes.len()
689        )));
690    }
691    // CRC footer check (body excludes magic + the CRC itself).
692    // v7.23 — the footer sits at the END of the input; for v1 that
693    // coincides with the fixed-width expected_total, for V3 (jumbo
694    // pages) only the input length is authoritative.
695    let stored_crc_off = bytes.len() - FOOTER_LEN;
696    let stored_crc = u32::from_le_bytes([
697        bytes[stored_crc_off],
698        bytes[stored_crc_off + 1],
699        bytes[stored_crc_off + 2],
700        bytes[stored_crc_off + 3],
701    ]);
702    let computed_crc = crc32(&bytes[8..stored_crc_off]);
703    if computed_crc != stored_crc {
704        return Err(SegmentError::BadCrc {
705            expected: stored_crc,
706            got: computed_crc,
707        });
708    }
709    let meta = SegmentMeta {
710        num_rows: u64::from(num_rows),
711        num_pages,
712        page_size_bytes,
713        min_pk,
714        max_pk,
715        total_bytes: bytes.len(),
716    };
717    Ok(SegmentMetadata {
718        meta,
719        bloom,
720        page_index,
721        codec_version,
722        pages_start_offset,
723    })
724}
725
726/// Out-of-range + bloom check. Shared between [`SegmentReader`]
727/// and [`OwnedSegment`] so a single implementation is on the hot
728/// path.
729fn segment_might_contain(metadata: &SegmentMetadata, key: u64) -> bool {
730    if key < metadata.meta.min_pk || key > metadata.meta.max_pk {
731        return false;
732    }
733    metadata.bloom.contains(&key.to_le_bytes())
734}
735
736/// Page-aware lookup. Shared between [`SegmentReader`] and
737/// [`OwnedSegment`] so the single-page-read budget invariant holds
738/// for both. Returns the raw payload bytes (caller decides how to
739/// decode them — for a v5.1 cold-tier read that's the dense Row
740/// body for the cold table).
741fn segment_lookup(metadata: &SegmentMetadata, bytes: &[u8], key: u64) -> Option<Vec<u8>> {
742    if !segment_might_contain(metadata, key) {
743        return None;
744    }
745    // Binary-search the page index for the largest entry with
746    // `first_pk <= key`.
747    let candidate = match metadata
748        .page_index
749        .binary_search_by(|entry| entry.first_pk.cmp(&key))
750    {
751        Ok(i) => i,
752        Err(0) => return None,
753        Err(i) => i - 1,
754    };
755    let entry = metadata.page_index[candidate];
756    let page_off = metadata.pages_start_offset + entry.file_offset as usize;
757    // v7.23 — page boundaries come from the index offsets (jumbo
758    // pages are wider than page_size_bytes; v1 fixed-width files
759    // satisfy the same arithmetic, no version branch needed).
760    let page_end = match metadata.page_index.get(candidate + 1) {
761        Some(next) => metadata.pages_start_offset + next.file_offset as usize,
762        None => bytes.len() - FOOTER_LEN,
763    };
764    if page_end > bytes.len() - FOOTER_LEN || page_off >= page_end {
765        return None;
766    }
767    let page = &bytes[page_off..page_end];
768    decode_page_lookup(page, key)
769}
770
771/// Sorted-order scan. Shared by both reader flavours.
772fn segment_scan<'a>(
773    metadata: &'a SegmentMetadata,
774    bytes: &'a [u8],
775) -> impl Iterator<Item = (u64, Vec<u8>)> + 'a {
776    // v7.23 — walk pages by their index offsets (see
777    // segment_lookup; jumbo pages are variable-width).
778    let pages_end = bytes.len() - FOOTER_LEN;
779    (0..metadata.page_index.len()).flat_map(move |i| {
780        let off = metadata.pages_start_offset + metadata.page_index[i].file_offset as usize;
781        let end = match metadata.page_index.get(i + 1) {
782            Some(next) => metadata.pages_start_offset + next.file_offset as usize,
783            None => pages_end,
784        };
785        let page = &bytes[off..end.min(pages_end)];
786        decode_page_iter(page)
787    })
788}
789
790/// Read-side handle. Borrows the segment bytes (the catalog or
791/// test owns the buffer), parses header + bloom + page index up
792/// front, and exposes `lookup(key)` / `scan_keys()` over the rest.
793///
794/// For an in-RAM cold-tier segment that the catalog holds across
795/// many lookups, prefer [`OwnedSegment`] — it owns its bytes and
796/// reuses the same parsed metadata across calls without any
797/// lifetime gymnastics.
798#[derive(Debug)]
799pub struct SegmentReader<'a> {
800    bytes: &'a [u8],
801    metadata: SegmentMetadata,
802}
803
804/// v6.7.1 — derive per-page BRIN summaries from an encoded v1
805/// segment. Walks the segment's `scan()` iterator + the page-index
806/// section to bucket each key into its source page; returns one
807/// `BrinSummary { page_index, min_key, max_key }` per page in
808/// page-order. Used by `wrap_v2_envelope_with_brin` to emit the
809/// sidecar at freeze time, and exposed publicly for compaction +
810/// future planner work.
811pub fn derive_brin_summaries(v1_bytes: &[u8]) -> Result<Vec<BrinSummary>, SegmentError> {
812    let reader = SegmentReader::open(v1_bytes)?;
813    let num_pages = reader.meta().num_pages as usize;
814    if num_pages == 0 {
815        return Ok(Vec::new());
816    }
817    // Page-index entries' first_pk values bound the pages. Walk
818    // the scan iterator; group keys by the page whose first_pk is
819    // the greatest one ≤ the current key.
820    let page_starts: Vec<u64> = reader
821        .metadata
822        .page_index
823        .iter()
824        .map(|e| e.first_pk)
825        .collect();
826    let mut min_by_page: Vec<Option<u64>> = alloc::vec![None; num_pages];
827    let mut max_by_page: Vec<Option<u64>> = alloc::vec![None; num_pages];
828    let mut current_page: usize = 0;
829    for (key, _) in reader.scan() {
830        while current_page + 1 < num_pages && key >= page_starts[current_page + 1] {
831            current_page += 1;
832        }
833        if min_by_page[current_page].is_none() {
834            min_by_page[current_page] = Some(key);
835        }
836        max_by_page[current_page] = Some(key);
837    }
838    let mut out = Vec::with_capacity(num_pages);
839    for p in 0..num_pages {
840        let (Some(min_key), Some(max_key)) = (min_by_page[p], max_by_page[p]) else {
841            continue;
842        };
843        out.push(BrinSummary {
844            page_index: u32::try_from(p).expect("page count fits u32"),
845            min_key,
846            max_key,
847        });
848    }
849    Ok(out)
850}
851
852/// v6.7.1 — wrap v1 segment bytes in a v2 LZSS envelope with a
853/// BRIN sidecar prefixed inside the inner bytes. Layout of inner
854/// before compression:
855///   [4-byte magic "BRIN"]
856///   [u32 LE num_summaries]
857///   [per summary: u32 LE page_index, u64 LE min_key, u64 LE max_key]
858///   [v1 segment bytes]
859/// Reader detects the BRIN magic at the start of inner and parses
860/// the sidecar, then continues to parse the v1 segment.
861/// Falls back to the standard `wrap_v2_envelope` (no sidecar) when
862/// `summaries.is_empty()`.
863#[must_use]
864pub fn wrap_v2_envelope_with_brin(
865    v1_bytes: Vec<u8>,
866    summaries: &[BrinSummary],
867    compress: bool,
868) -> Vec<u8> {
869    if summaries.is_empty() {
870        return wrap_v2_envelope(v1_bytes, compress);
871    }
872    // Build the BRIN-prefixed inner.
873    let brin_section_len = 4 + summaries.len() * 20;
874    let mut inner = Vec::with_capacity(4 + 4 + brin_section_len + v1_bytes.len());
875    inner.extend_from_slice(&BRIN_SIDECAR_MAGIC);
876    let n = u32::try_from(summaries.len()).expect("BRIN summary count fits u32");
877    inner.extend_from_slice(&n.to_le_bytes());
878    for s in summaries {
879        inner.extend_from_slice(&s.page_index.to_le_bytes());
880        inner.extend_from_slice(&s.min_key.to_le_bytes());
881        inner.extend_from_slice(&s.max_key.to_le_bytes());
882    }
883    inner.extend_from_slice(&v1_bytes);
884    // Now wrap the BRIN-prefixed inner into the v2 envelope. The
885    // wrap_v2_envelope helper compresses + emits the envelope
886    // header.
887    wrap_v2_envelope(inner, compress)
888}
889
890/// v6.6.2 — wrap v1 segment bytes in a v2 LZSS envelope when
891/// `compress=true` and the compressed form is strictly smaller.
892/// Returns the v1 bytes unchanged otherwise (the caller's "ship
893/// the smaller form" policy lives at the catalog layer; this
894/// helper only commits to NOT making files bigger).
895#[must_use]
896pub fn wrap_v2_envelope(v1_bytes: Vec<u8>, compress: bool) -> Vec<u8> {
897    if !compress {
898        return v1_bytes;
899    }
900    let compressed = spg_crypto::lzss::compress(&v1_bytes);
901    if compressed.len() + SEGMENT_V2_HEADER_LEN >= v1_bytes.len() {
902        return v1_bytes;
903    }
904    let inner_len = u32::try_from(v1_bytes.len()).expect("v1 segment < 4 GiB");
905    let mut out = Vec::with_capacity(SEGMENT_V2_HEADER_LEN + compressed.len());
906    out.extend_from_slice(&SEGMENT_MAGIC_V2);
907    out.push(SEGMENT_COMPRESS_ALGO_LZSS);
908    out.extend_from_slice(&inner_len.to_le_bytes());
909    out.extend_from_slice(&compressed);
910    out
911}
912
913/// v6.6.2 — unwrap a v2 envelope to v1 bytes. v1-magic input
914/// passes through unchanged. v6.7.1 — also extracts any BRIN
915/// sidecar prefix; returns it alongside the v1 bytes.
916pub(crate) fn unwrap_v2_envelope(
917    bytes: Vec<u8>,
918) -> Result<(Vec<u8>, Vec<BrinSummary>), SegmentError> {
919    if bytes.len() < 8 || bytes[..8] != SEGMENT_MAGIC_V2 {
920        return Ok((bytes, Vec::new()));
921    }
922    if bytes.len() < SEGMENT_V2_HEADER_LEN {
923        return Err(SegmentError::TooShort {
924            got: bytes.len(),
925            need: SEGMENT_V2_HEADER_LEN,
926        });
927    }
928    let algo = bytes[8];
929    let inner_len = u32::from_le_bytes([bytes[9], bytes[10], bytes[11], bytes[12]]) as usize;
930    let inner = &bytes[SEGMENT_V2_HEADER_LEN..];
931    let decoded = match algo {
932        SEGMENT_COMPRESS_ALGO_NONE => {
933            if inner.len() != inner_len {
934                return Err(SegmentError::BadShape(alloc::format!(
935                    "v2 envelope algo=none: declared inner_len {inner_len} \
936                     differs from body {}",
937                    inner.len()
938                )));
939            }
940            inner.to_vec()
941        }
942        SEGMENT_COMPRESS_ALGO_LZSS => {
943            let decompressed = spg_crypto::lzss::decompress(inner)
944                .map_err(|e| SegmentError::CompressionDecodeFailed(alloc::format!("{e:?}")))?;
945            if decompressed.len() != inner_len {
946                return Err(SegmentError::BadShape(alloc::format!(
947                    "v2 envelope LZSS: decompressed {} bytes, declared {inner_len}",
948                    decompressed.len()
949                )));
950            }
951            decompressed
952        }
953        other => return Err(SegmentError::UnknownCompressionAlgo(other)),
954    };
955    // v6.7.1 — peek for BRIN sidecar magic.
956    if decoded.len() >= 4 && decoded[..4] == BRIN_SIDECAR_MAGIC {
957        return parse_brin_sidecar_then_v1(decoded);
958    }
959    Ok((decoded, Vec::new()))
960}
961
962/// v6.7.1 — parse a BRIN-prefixed inner buffer into (v1_bytes,
963/// summaries). Called by `unwrap_v2_envelope` after the magic
964/// peek confirms BRIN is present.
965fn parse_brin_sidecar_then_v1(
966    decoded: Vec<u8>,
967) -> Result<(Vec<u8>, Vec<BrinSummary>), SegmentError> {
968    if decoded.len() < 8 {
969        return Err(SegmentError::BadShape(alloc::format!(
970            "BRIN sidecar: truncated header ({}B < 8)",
971            decoded.len()
972        )));
973    }
974    let n_summaries = u32::from_le_bytes([decoded[4], decoded[5], decoded[6], decoded[7]]) as usize;
975    let summaries_end = 8 + n_summaries * 20;
976    if decoded.len() < summaries_end {
977        return Err(SegmentError::BadShape(alloc::format!(
978            "BRIN sidecar: truncated body (need {summaries_end}B, have {}B)",
979            decoded.len()
980        )));
981    }
982    let mut summaries = Vec::with_capacity(n_summaries);
983    for i in 0..n_summaries {
984        let off = 8 + i * 20;
985        let page_index = u32::from_le_bytes([
986            decoded[off],
987            decoded[off + 1],
988            decoded[off + 2],
989            decoded[off + 3],
990        ]);
991        let mut k = [0u8; 8];
992        k.copy_from_slice(&decoded[off + 4..off + 12]);
993        let min_key = u64::from_le_bytes(k);
994        k.copy_from_slice(&decoded[off + 12..off + 20]);
995        let max_key = u64::from_le_bytes(k);
996        summaries.push(BrinSummary {
997            page_index,
998            min_key,
999            max_key,
1000        });
1001    }
1002    // The v1 segment bytes follow the sidecar section.
1003    let v1_bytes = decoded[summaries_end..].to_vec();
1004    Ok((v1_bytes, summaries))
1005}
1006
1007impl<'a> SegmentReader<'a> {
1008    /// Parse a segment from a contiguous byte slice. Validates
1009    /// magic, CRC32 footer, and structural lengths. v6.6.2: a
1010    /// v2-magic envelope is rejected by the borrowed-slice reader
1011    /// because decompression would need to allocate a fresh Vec —
1012    /// callers with a v2 file must go through
1013    /// [`OwnedSegment::from_bytes`] which can own the
1014    /// decompressed bytes.
1015    pub fn open(bytes: &'a [u8]) -> Result<Self, SegmentError> {
1016        if bytes.len() >= 8 && bytes[..8] == SEGMENT_MAGIC_V2 {
1017            return Err(SegmentError::BadShape(
1018                "v2 envelope: SegmentReader requires the caller to first \
1019                 unwrap to v1 bytes via OwnedSegment::from_bytes; the \
1020                 borrowed-slice reader does not allocate."
1021                    .into(),
1022            ));
1023        }
1024        let metadata = parse_segment_metadata(bytes)?;
1025        Ok(Self { bytes, metadata })
1026    }
1027
1028    #[must_use]
1029    pub fn meta(&self) -> &SegmentMeta {
1030        &self.metadata.meta
1031    }
1032
1033    /// v7.23/v7.27 — the codec version implied by this segment's
1034    /// inner magic (0 legacy / 46 / 47). Callers thread this into
1035    /// `decode_row_body_dense`.
1036    #[must_use]
1037    pub fn codec_version(&self) -> u8 {
1038        self.metadata.codec_version
1039    }
1040
1041    /// Bloom-only check — `false` means the key is definitely not
1042    /// in this segment (no false negatives); `true` means it
1043    /// *might* be (false-positive rate per the embedded bloom's
1044    /// target).
1045    #[must_use]
1046    pub fn might_contain(&self, key: u64) -> bool {
1047        segment_might_contain(&self.metadata, key)
1048    }
1049
1050    /// Look up `key`. Returns `Some(payload)` if found, `None` if
1051    /// the bloom rejects or the page-internal search misses.
1052    /// Always reads at most one page worth of bytes (4 KiB by
1053    /// default), which is the I/O budget the v5.1 catalog
1054    /// integration relies on.
1055    pub fn lookup(&self, key: u64) -> Option<Vec<u8>> {
1056        segment_lookup(&self.metadata, self.bytes, key)
1057    }
1058
1059    /// Iterate all (key, payload) pairs in sorted order. Used by
1060    /// `scan`-shaped queries and by compaction.
1061    pub fn scan(&self) -> impl Iterator<Item = (u64, Vec<u8>)> + '_ {
1062        segment_scan(&self.metadata, self.bytes)
1063    }
1064}
1065
1066/// Owned segment — bytes + parsed metadata in a single struct, no
1067/// borrow lifetimes. The catalog (v5.1+) holds a
1068/// `Vec<OwnedSegment>` for its cold tier so each lookup parses
1069/// nothing fresh; the `lookup` / `might_contain` / `scan` calls
1070/// here share the same module-private implementation as
1071/// [`SegmentReader`].
1072///
1073/// File I/O lives outside this struct — `spg-storage` is `no_std`,
1074/// so callers (e.g. `spg-server`) load the segment via
1075/// `std::fs::read` and hand the resulting `Vec<u8>` to
1076/// [`OwnedSegment::from_bytes`].
1077#[derive(Debug, Clone)]
1078pub struct OwnedSegment {
1079    bytes: Vec<u8>,
1080    metadata: SegmentMetadata,
1081    /// v6.7.1 — BRIN per-page summaries when the v2 envelope
1082    /// included a BRIN sidecar. Empty when the segment was v1 or
1083    /// v2-without-sidecar. Exposed via `brin_summaries()`.
1084    brin_summaries: Vec<BrinSummary>,
1085}
1086
1087impl OwnedSegment {
1088    /// Parse and validate a segment from owned bytes. The bytes
1089    /// stay resident inside the returned `OwnedSegment` for the
1090    /// life of that value. Validation cost is paid once; per-
1091    /// lookup cost is identical to [`SegmentReader::lookup`].
1092    ///
1093    /// v6.6.2 — accepts both v1 (`SPGSEG\x01\x00`) and v2
1094    /// (`SPGSEG\x02\x00`) magics. A v2 file's body is transparently
1095    /// unwrapped before the v1 parser runs; the unwrapped v1 bytes
1096    /// become the `bytes` field, so all downstream readers see a
1097    /// canonical v1 layout. v6.7.1 — also extracts any BRIN
1098    /// sidecar.
1099    pub fn from_bytes(bytes: Vec<u8>) -> Result<Self, SegmentError> {
1100        let (bytes, brin_summaries) = unwrap_v2_envelope(bytes)?;
1101        let metadata = parse_segment_metadata(&bytes)?;
1102        Ok(Self {
1103            bytes,
1104            metadata,
1105            brin_summaries,
1106        })
1107    }
1108
1109    /// v6.7.1 — borrow the BRIN per-page summaries when the
1110    /// segment was written with a BRIN sidecar. Empty for v1
1111    /// segments or v2 without a sidecar.
1112    #[must_use]
1113    pub fn brin_summaries(&self) -> &[BrinSummary] {
1114        &self.brin_summaries
1115    }
1116
1117    #[must_use]
1118    pub fn meta(&self) -> &SegmentMeta {
1119        &self.metadata.meta
1120    }
1121
1122    /// v7.23/v7.27 — the codec version implied by this segment's
1123    /// inner magic (0 legacy / 46 / 47). Callers thread this into
1124    /// `decode_row_body_dense`.
1125    #[must_use]
1126    pub fn codec_version(&self) -> u8 {
1127        self.metadata.codec_version
1128    }
1129
1130    #[must_use]
1131    pub fn might_contain(&self, key: u64) -> bool {
1132        segment_might_contain(&self.metadata, key)
1133    }
1134
1135    pub fn lookup(&self, key: u64) -> Option<Vec<u8>> {
1136        segment_lookup(&self.metadata, &self.bytes, key)
1137    }
1138
1139    pub fn scan(&self) -> impl Iterator<Item = (u64, Vec<u8>)> + '_ {
1140        segment_scan(&self.metadata, &self.bytes)
1141    }
1142
1143    /// Raw segment bytes — exposed for callers that want to write
1144    /// the segment back to disk or hand it to a checksum tool.
1145    /// Read-only.
1146    #[must_use]
1147    pub fn bytes(&self) -> &[u8] {
1148        &self.bytes
1149    }
1150}
1151
1152/// Page-internal lookup: parse the header, run binary search over
1153/// `row_offsets` keyed by the first 8 bytes of each row payload
1154/// (the u64 key). Returns the row payload (`payload_len bytes`),
1155/// not including the key/length prefix.
1156fn decode_page_lookup(page: &[u8], key: u64) -> Option<Vec<u8>> {
1157    if page.len() < 4 {
1158        return None;
1159    }
1160    let num_rows = u32::from_le_bytes([page[0], page[1], page[2], page[3]]) as usize;
1161    if num_rows == 0 {
1162        return None;
1163    }
1164    let offsets_start = 4;
1165    let offsets_end = offsets_start + num_rows * 4;
1166    if page.len() < offsets_end {
1167        return None;
1168    }
1169    let offsets: Vec<u32> = (0..num_rows)
1170        .map(|i| {
1171            let o = offsets_start + i * 4;
1172            u32::from_le_bytes([page[o], page[o + 1], page[o + 2], page[o + 3]])
1173        })
1174        .collect();
1175    // Binary search by reading the leading u64 key of each row.
1176    let mut lo = 0usize;
1177    let mut hi = num_rows;
1178    while lo < hi {
1179        let mid = usize::midpoint(lo, hi);
1180        let row_off = offsets[mid] as usize;
1181        if row_off + 8 > page.len() {
1182            return None;
1183        }
1184        let row_key = u64::from_le_bytes([
1185            page[row_off],
1186            page[row_off + 1],
1187            page[row_off + 2],
1188            page[row_off + 3],
1189            page[row_off + 4],
1190            page[row_off + 5],
1191            page[row_off + 6],
1192            page[row_off + 7],
1193        ]);
1194        match row_key.cmp(&key) {
1195            core::cmp::Ordering::Less => lo = mid + 1,
1196            core::cmp::Ordering::Greater => hi = mid,
1197            core::cmp::Ordering::Equal => {
1198                // Found — extract payload.
1199                let plen_off = row_off + 8;
1200                if plen_off + 4 > page.len() {
1201                    return None;
1202                }
1203                let plen = u32::from_le_bytes([
1204                    page[plen_off],
1205                    page[plen_off + 1],
1206                    page[plen_off + 2],
1207                    page[plen_off + 3],
1208                ]) as usize;
1209                let payload_start = plen_off + 4;
1210                let payload_end = payload_start + plen;
1211                if payload_end > page.len() {
1212                    return None;
1213                }
1214                return Some(page[payload_start..payload_end].to_vec());
1215            }
1216        }
1217    }
1218    None
1219}
1220
1221fn decode_page_iter(page: &[u8]) -> Vec<(u64, Vec<u8>)> {
1222    if page.len() < 4 {
1223        return vec![];
1224    }
1225    let num_rows = u32::from_le_bytes([page[0], page[1], page[2], page[3]]) as usize;
1226    if num_rows == 0 {
1227        return vec![];
1228    }
1229    let offsets_end = 4 + num_rows * 4;
1230    if page.len() < offsets_end {
1231        return vec![];
1232    }
1233    let offsets: Vec<u32> = (0..num_rows)
1234        .map(|i| {
1235            let o = 4 + i * 4;
1236            u32::from_le_bytes([page[o], page[o + 1], page[o + 2], page[o + 3]])
1237        })
1238        .collect();
1239    let mut out = Vec::with_capacity(num_rows);
1240    for off in offsets {
1241        let row_off = off as usize;
1242        if row_off + 12 > page.len() {
1243            break;
1244        }
1245        let key = u64::from_le_bytes([
1246            page[row_off],
1247            page[row_off + 1],
1248            page[row_off + 2],
1249            page[row_off + 3],
1250            page[row_off + 4],
1251            page[row_off + 5],
1252            page[row_off + 6],
1253            page[row_off + 7],
1254        ]);
1255        let plen = u32::from_le_bytes([
1256            page[row_off + 8],
1257            page[row_off + 9],
1258            page[row_off + 10],
1259            page[row_off + 11],
1260        ]) as usize;
1261        let payload_start = row_off + 12;
1262        let payload_end = payload_start + plen;
1263        if payload_end > page.len() {
1264            break;
1265        }
1266        out.push((key, page[payload_start..payload_end].to_vec()));
1267    }
1268    out
1269}
1270
1271#[cfg(test)]
1272mod tests {
1273    use super::*;
1274
1275    fn build_rows(n: u64) -> Vec<(u64, Vec<u8>)> {
1276        (0..n)
1277            .map(|i| {
1278                let payload = format!("row-{i}").into_bytes();
1279                (i * 2 + 1, payload) // sparse keys to exercise binary search
1280            })
1281            .collect()
1282    }
1283
1284    #[test]
1285    fn brin_summaries_derive_matches_per_page_pk_ranges() {
1286        // Encode 200 rows over a few pages; derive BRIN summaries
1287        // and assert each page's [min_key, max_key] envelopes
1288        // every key in that page.
1289        let rows = build_rows(200);
1290        let expected: Vec<u64> = rows.iter().map(|(k, _)| *k).collect();
1291        let (v1_bytes, meta) =
1292            encode_segment(rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES).expect("encode");
1293        let summaries = derive_brin_summaries(&v1_bytes).expect("derive");
1294        assert_eq!(summaries.len(), meta.num_pages as usize);
1295        // Every key must fall in exactly one summary's range.
1296        for k in expected {
1297            let hits = summaries
1298                .iter()
1299                .filter(|s| k >= s.min_key && k <= s.max_key)
1300                .count();
1301            assert!(hits >= 1, "key {k} not covered by any BRIN summary");
1302        }
1303        // Summaries are monotone increasing — page N's max < page
1304        // N+1's min.
1305        for w in summaries.windows(2) {
1306            assert!(
1307                w[0].max_key < w[1].min_key,
1308                "summary ranges overlap: page {} max {} >= page {} min {}",
1309                w[0].page_index,
1310                w[0].max_key,
1311                w[1].page_index,
1312                w[1].min_key
1313            );
1314        }
1315    }
1316
1317    #[test]
1318    fn brin_sidecar_round_trips_through_v2_envelope() {
1319        let rows = build_rows(150);
1320        let (v1_bytes, _) =
1321            encode_segment(rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES).expect("encode");
1322        let summaries = derive_brin_summaries(&v1_bytes).expect("derive");
1323        assert!(!summaries.is_empty());
1324        let wrapped = wrap_v2_envelope_with_brin(v1_bytes, &summaries, true);
1325        // Parse it back via OwnedSegment.
1326        let seg = OwnedSegment::from_bytes(wrapped).expect("v2+brin parses");
1327        // Lookup still works — the v1 bytes are intact inside.
1328        assert!(seg.lookup(1).is_some(), "lookup hits a known key");
1329        assert!(seg.lookup(299).is_some(), "lookup hits another known key");
1330        // BRIN summaries are recoverable.
1331        let recovered = seg.brin_summaries();
1332        assert_eq!(recovered.len(), summaries.len());
1333        for (a, b) in summaries.iter().zip(recovered) {
1334            assert_eq!(a, b);
1335        }
1336    }
1337
1338    #[test]
1339    fn segment_without_brin_sidecar_returns_empty_summaries() {
1340        let rows = build_rows(50);
1341        let (v1_bytes, _) =
1342            encode_segment(rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES).expect("encode");
1343        // v1 segment (no v2 wrap).
1344        let seg1 = OwnedSegment::from_bytes(v1_bytes.clone()).expect("v1 parses");
1345        assert!(seg1.brin_summaries().is_empty());
1346        // v2 envelope without BRIN sidecar.
1347        let wrapped = wrap_v2_envelope(v1_bytes, true);
1348        let seg2 = OwnedSegment::from_bytes(wrapped).expect("v2 parses");
1349        assert!(seg2.brin_summaries().is_empty());
1350    }
1351
1352    #[test]
1353    fn v2_envelope_round_trips_byte_equal() {
1354        // Encode v1, wrap into v2 with compression, unwrap, parse.
1355        // Result must equal the original v1 bytes byte-for-byte.
1356        let rows = build_rows(1000);
1357        let (v1_bytes, _) =
1358            encode_segment(rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES).expect("encode");
1359        let wrapped = wrap_v2_envelope(v1_bytes.clone(), true);
1360        // Compression should produce a smaller envelope on the
1361        // repetitive segment payload.
1362        assert!(
1363            wrapped.len() < v1_bytes.len(),
1364            "v2 envelope should be smaller: {} vs v1 {}",
1365            wrapped.len(),
1366            v1_bytes.len()
1367        );
1368        let seg = OwnedSegment::from_bytes(wrapped).expect("v2 unwrap + parse");
1369        assert_eq!(seg.meta().num_rows, 1000);
1370        // Lookup still works — the unwrapped bytes match the
1371        // original v1 segment structure.
1372        assert!(seg.lookup(1).is_some());
1373        assert!(seg.lookup(1999).is_some());
1374    }
1375
1376    #[test]
1377    fn v2_envelope_with_compress_false_is_v1_passthrough() {
1378        let rows = build_rows(64);
1379        let (v1_bytes, _) =
1380            encode_segment(rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES).expect("encode");
1381        let wrapped = wrap_v2_envelope(v1_bytes.clone(), false);
1382        assert_eq!(wrapped, v1_bytes);
1383    }
1384
1385    #[test]
1386    fn legacy_v1_segments_still_load_via_from_bytes() {
1387        // A v7.23 binary must still read v1-magic files written by a
1388        // pre-v7.23 binary. Since v7.23 the encoder emits V3, so the
1389        // v1 fixture is built by patching the magic back — legal
1390        // because (a) the CRC footer excludes the magic and (b) a
1391        // short-row segment's byte layout is identical between v1
1392        // and V3 (the escape codec only changes payloads >= 64 KiB,
1393        // and page offsets stay page_size multiples without jumbo
1394        // pages).
1395        let rows = build_rows(100);
1396        let (mut v1_bytes, _) =
1397            encode_segment(rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES).expect("encode");
1398        assert_eq!(&v1_bytes[..8], &SEGMENT_MAGIC_V4, "encoder emits V4");
1399        v1_bytes[..8].copy_from_slice(&SEGMENT_MAGIC);
1400        // OwnedSegment::from_bytes should handle these unchanged —
1401        // and report the old string codec.
1402        let seg = OwnedSegment::from_bytes(v1_bytes).expect("v1 still parses");
1403        assert_eq!(seg.meta().num_rows, 100);
1404        assert_eq!(seg.codec_version(), 0, "v1 magic = legacy plain-u16 rules");
1405    }
1406
1407    #[test]
1408    fn v2_envelope_invalid_algo_byte_errors_loudly() {
1409        // Craft a v2-magic file with an unknown algo byte. Reader
1410        // must refuse rather than silent-corrupt.
1411        let mut bogus = Vec::new();
1412        bogus.extend_from_slice(&SEGMENT_MAGIC_V2);
1413        bogus.push(0x42); // unknown algo
1414        bogus.extend_from_slice(&0u32.to_le_bytes());
1415        let err = OwnedSegment::from_bytes(bogus).unwrap_err();
1416        assert!(matches!(err, SegmentError::UnknownCompressionAlgo(0x42)));
1417    }
1418
1419    #[test]
1420    fn encode_then_open_roundtrips_meta() {
1421        let rows = build_rows(1000);
1422        let (bytes, meta) =
1423            encode_segment(rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES).expect("encode succeeds");
1424        let reader = SegmentReader::open(&bytes).expect("open succeeds");
1425        assert_eq!(reader.meta().num_rows, meta.num_rows);
1426        assert_eq!(reader.meta().num_pages, meta.num_pages);
1427        assert_eq!(reader.meta().min_pk, 1);
1428        assert_eq!(reader.meta().max_pk, 1999);
1429        assert_eq!(reader.meta().total_bytes, bytes.len());
1430    }
1431
1432    #[test]
1433    fn lookup_finds_every_inserted_key() {
1434        let rows = build_rows(1000);
1435        let expected: Vec<_> = rows.clone();
1436        let (bytes, _) =
1437            encode_segment(rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES).expect("encode succeeds");
1438        let reader = SegmentReader::open(&bytes).expect("open succeeds");
1439        for (key, payload) in expected {
1440            assert_eq!(
1441                reader.lookup(key),
1442                Some(payload),
1443                "lookup({key}) returned wrong payload"
1444            );
1445        }
1446    }
1447
1448    #[test]
1449    fn lookup_returns_none_for_unknown_key() {
1450        let rows = build_rows(1000);
1451        let (bytes, _) =
1452            encode_segment(rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES).expect("encode succeeds");
1453        let reader = SegmentReader::open(&bytes).expect("open succeeds");
1454        // Even-numbered keys are gaps in our rows (we used 2i+1).
1455        for k in (0..2000u64).step_by(2) {
1456            assert!(reader.lookup(k).is_none(), "expected None for gap key {k}");
1457        }
1458        // Out-of-range keys.
1459        assert!(reader.lookup(99_999).is_none());
1460        assert!(reader.lookup(0).is_none());
1461    }
1462
1463    #[test]
1464    fn might_contain_short_circuits_out_of_range() {
1465        let rows = build_rows(100);
1466        let (bytes, _) = encode_segment(rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES).unwrap();
1467        let reader = SegmentReader::open(&bytes).unwrap();
1468        // min_pk=1, max_pk=199. Anything outside MUST be rejected.
1469        assert!(!reader.might_contain(0));
1470        assert!(!reader.might_contain(200));
1471        // Inside range, inserted key MUST pass bloom.
1472        assert!(reader.might_contain(1));
1473        assert!(reader.might_contain(199));
1474    }
1475
1476    #[test]
1477    fn scan_yields_rows_in_key_order() {
1478        let rows = build_rows(500);
1479        let expected: Vec<_> = rows.clone();
1480        let (bytes, _) = encode_segment(rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES).unwrap();
1481        let reader = SegmentReader::open(&bytes).unwrap();
1482        let scanned: Vec<_> = reader.scan().collect();
1483        assert_eq!(scanned.len(), 500);
1484        // Order check.
1485        for w in scanned.windows(2) {
1486            assert!(
1487                w[0].0 < w[1].0,
1488                "scan out of order: {} >= {}",
1489                w[0].0,
1490                w[1].0
1491            );
1492        }
1493        // Content check.
1494        assert_eq!(scanned, expected);
1495    }
1496
1497    #[test]
1498    fn open_rejects_bad_magic() {
1499        let rows = build_rows(10);
1500        let (mut bytes, _) = encode_segment(rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES).unwrap();
1501        bytes[0] ^= 0xff;
1502        match SegmentReader::open(&bytes) {
1503            Err(SegmentError::BadMagic { .. }) => {}
1504            other => panic!("expected BadMagic, got {other:?}"),
1505        }
1506    }
1507
1508    #[test]
1509    fn open_rejects_bad_crc() {
1510        let rows = build_rows(10);
1511        let (mut bytes, _) = encode_segment(rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES).unwrap();
1512        // Flip a byte past the header (in the first page payload).
1513        let off = bytes.len() / 2;
1514        bytes[off] ^= 0x01;
1515        match SegmentReader::open(&bytes) {
1516            Err(SegmentError::BadCrc { .. }) => {}
1517            other => panic!("expected BadCrc, got {other:?}"),
1518        }
1519    }
1520
1521    #[test]
1522    fn encode_rejects_unsorted_keys() {
1523        let rows = vec![(10u64, vec![1]), (5u64, vec![2])];
1524        match encode_segment(rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES) {
1525            Err(SegmentError::UnsortedKey { prev: 10, next: 5 }) => {}
1526            other => panic!("expected UnsortedKey, got {other:?}"),
1527        }
1528    }
1529
1530    #[test]
1531    fn encode_rejects_empty_input() {
1532        let rows: Vec<(u64, Vec<u8>)> = vec![];
1533        match encode_segment(rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES) {
1534            Err(SegmentError::BadShape(_)) => {}
1535            other => panic!("expected BadShape for empty input, got {other:?}"),
1536        }
1537    }
1538
1539    #[test]
1540    fn encode_rejects_bad_page_size() {
1541        let rows = build_rows(1);
1542        match encode_segment(rows.clone().into_iter(), 0.01, 128) {
1543            Err(SegmentError::BadShape(_)) => {}
1544            other => panic!("expected BadShape for tiny page, got {other:?}"),
1545        }
1546        match encode_segment(rows.into_iter(), 0.01, 1_000_000) {
1547            Err(SegmentError::BadShape(_)) => {}
1548            other => panic!("expected BadShape for huge page, got {other:?}"),
1549        }
1550    }
1551
1552    #[test]
1553    fn large_payload_becomes_a_jumbo_page_and_reads_back() {
1554        // v7.23 (round-14) — a row larger than the page used to be
1555        // REJECTED, which meant the freezer could never move a big
1556        // mail body to the cold tier. It now lands in its own
1557        // unpadded jumbo page; lookup and scan read it back exactly.
1558        let rows = vec![
1559            (1u64, vec![0xABu8; 8192]),
1560            (2u64, vec![7u8; 16]),
1561            (3u64, vec![0xCDu8; 70_000]),
1562        ];
1563        let (bytes, meta) =
1564            encode_segment(rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES).expect("jumbo encode");
1565        assert_eq!(meta.num_rows, 3);
1566        let seg = OwnedSegment::from_bytes(bytes).expect("parses");
1567        assert_eq!(seg.lookup(1).expect("pk 1").len(), 8192);
1568        assert_eq!(seg.lookup(2).expect("pk 2").len(), 16);
1569        let big = seg.lookup(3).expect("pk 3");
1570        assert_eq!(big.len(), 70_000);
1571        assert!(big.iter().all(|b| *b == 0xCD));
1572        // Scan order + payload integrity across the mixed layout.
1573        let scanned: Vec<(u64, usize)> = seg.scan().map(|(k, p)| (k, p.len())).collect();
1574        assert_eq!(scanned, vec![(1, 8192), (2, 16), (3, 70_000)]);
1575    }
1576
1577    // --- OwnedSegment (v5.1 catalog cold-tier wrapper) -----------
1578
1579    #[test]
1580    fn owned_segment_lookup_matches_reader_for_every_key() {
1581        let rows = build_rows(500);
1582        let expected: Vec<_> = rows.clone();
1583        let (bytes, _) = encode_segment(rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES).unwrap();
1584        let bytes_len = bytes.len();
1585        // Reader sees a borrowed view; collect its outputs before
1586        // moving the buffer into the owned variant.
1587        let (r_meta_num_rows, r_meta_min_pk, r_meta_max_pk, r_lookups, r_scan) = {
1588            let reader = SegmentReader::open(&bytes).unwrap();
1589            let lookups: Vec<_> = expected.iter().map(|(k, _)| reader.lookup(*k)).collect();
1590            let scan: Vec<_> = reader.scan().collect();
1591            (
1592                reader.meta().num_rows,
1593                reader.meta().min_pk,
1594                reader.meta().max_pk,
1595                lookups,
1596                scan,
1597            )
1598        };
1599        let owned = OwnedSegment::from_bytes(bytes).unwrap();
1600        for ((key, expected_payload), reader_payload) in expected.iter().zip(r_lookups.iter()) {
1601            assert_eq!(reader_payload.as_ref(), Some(expected_payload));
1602            assert_eq!(owned.lookup(*key).as_ref(), Some(expected_payload));
1603        }
1604        // Reader and owned report identical meta + cover identical scan output.
1605        assert_eq!(r_meta_num_rows, owned.meta().num_rows);
1606        assert_eq!(r_meta_min_pk, owned.meta().min_pk);
1607        assert_eq!(r_meta_max_pk, owned.meta().max_pk);
1608        let o_scan: Vec<_> = owned.scan().collect();
1609        assert_eq!(r_scan, o_scan);
1610        assert_eq!(owned.bytes().len(), bytes_len);
1611    }
1612
1613    #[test]
1614    fn owned_segment_might_contain_matches_reader() {
1615        let rows = build_rows(64);
1616        let (bytes, _) = encode_segment(rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES).unwrap();
1617        let probes = [0u64, 1, 50, 127, 128, 200];
1618        let reader_results: Vec<bool> = {
1619            let reader = SegmentReader::open(&bytes).unwrap();
1620            probes.iter().map(|k| reader.might_contain(*k)).collect()
1621        };
1622        let owned = OwnedSegment::from_bytes(bytes).unwrap();
1623        for (key, r_hit) in probes.iter().zip(reader_results.iter()) {
1624            assert_eq!(*r_hit, owned.might_contain(*key));
1625        }
1626    }
1627
1628    #[test]
1629    fn owned_segment_rejects_bad_bytes_at_construction() {
1630        // Construct + flip header byte → from_bytes should refuse.
1631        let rows = build_rows(8);
1632        let (mut bytes, _) = encode_segment(rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES).unwrap();
1633        bytes[0] ^= 0xff; // smash magic
1634        match OwnedSegment::from_bytes(bytes) {
1635            Err(SegmentError::BadMagic { .. }) => {}
1636            other => panic!("expected BadMagic, got {other:?}"),
1637        }
1638    }
1639
1640    #[test]
1641    fn owned_segment_lookup_returns_none_for_missing_key() {
1642        let rows = build_rows(100); // keys = 2i+1 → 1..199
1643        let (bytes, _) = encode_segment(rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES).unwrap();
1644        let owned = OwnedSegment::from_bytes(bytes).unwrap();
1645        // Gap (even) keys + out-of-range keys.
1646        for key in [0u64, 2, 50, 198, 200, 9999] {
1647            assert!(
1648                owned.lookup(key).is_none(),
1649                "expected None for non-inserted key {key}, got Some"
1650            );
1651        }
1652    }
1653}