Skip to main content

mongreldb_core/
sorted_run.rs

1//! Sorted Run — the immutable columnar unit (`.sr`).
2//!
3//! On-disk layout: a 256-byte header, a columnar page region (PAX), a column
4//! directory, an index trailer, and a checksummed footer. [`RunWriter`] flushes
5//! drained memtable rows into encoded columns
6//! (system columns `_row_id` / `_epoch` / `_deleted` plus user columns), and
7//! [`RunReader`] decodes them back, answering MVCC point lookups and scans.
8
9use crate::columnar;
10use crate::encryption::{setup_run_encryption, Cipher, Kek, RunEncryption};
11use crate::epoch::Epoch;
12use crate::error::{MongrelError, Result};
13use crate::index::pgm::PgmIndex;
14use crate::memtable::{Row, Value};
15use crate::page::{Encoding, PageStat};
16use crate::row_id_set::RowIdSet;
17use crate::rowid::RowId;
18use crate::schema::{Schema, TypeId};
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21use std::collections::HashMap;
22use std::fs::{File, OpenOptions};
23use std::io::{Read, Seek, SeekFrom, Write};
24use std::path::{Path, PathBuf};
25use std::sync::Arc;
26
27pub const RUN_MAGIC: [u8; 8] = *b"MONGRRUN";
28pub const RUN_FORMAT_VERSION: u16 = 1;
29/// v2 appends `encrypted_stats_offset`/`encrypted_stats_len` to [`RunHeader`].
30/// The fields are trailing and the header region is zero-padded, so a run
31/// written before v2 deserializes with both fields = 0 ("no encrypted stats
32/// section"). Pre-v2 *encrypted* runs are not readable (their MAC covers the
33/// shorter v1 header serialization) — recreate them; no released data exists.
34pub const RUN_HEADER_VERSION: u16 = 2;
35pub const RUN_HEADER_PAD: usize = 256;
36const MAX_RUN_PAGE_BYTES: u64 = 64 * 1024 * 1024 + 32;
37
38/// Reserved `(column_id, page_seq)` nonce coordinates for the encrypted
39/// page-stats envelope (see [`RunHeader::encrypted_stats_offset`]). Pages per
40/// column are capped strictly below `u16::MAX` at encode, so no page nonce can
41/// ever collide with this pair under the run's DEK.
42const ENC_STATS_NONCE_COLUMN: u16 = u16::MAX;
43const ENC_STATS_NONCE_SEQ: u32 = u16::MAX as u32;
44
45/// One page's `(min, max)` bounds as stored in the encrypted stats envelope.
46type PageMinMax = (Option<Vec<u8>>, Option<Vec<u8>>);
47
48/// Per-page `(min, max)` bounds of one encrypted column, keyed by column id —
49/// the plaintext shape of the encrypted stats envelope. The cleartext column
50/// directory must not carry these (they would leak values to anyone holding
51/// the file without the key), so they travel AES-256-GCM-encrypted under the
52/// run DEK and are overlaid onto the in-memory [`PageStat`]s at open, which
53/// restores zone-map page pruning for encrypted columns.
54type EncryptedColumnStats = Vec<(u16, Vec<PageMinMax>)>;
55
56/// AES-256-GCM authentication-tag length appended to every ciphertext. Used to
57/// precompute on-disk page sizes so the direct-to-mmap writer can lay the whole
58/// run out before encrypting (Phase 14.6). Validated against the cipher's real
59/// output in [`write_run_mmap`].
60const GCM_TAG_LEN: usize = 16;
61
62/// On-disk length of a page payload: ciphertext = plaintext + GCM tag when
63/// encrypted, else the plaintext length itself.
64fn on_disk_len(page_len: usize, encrypted: bool) -> usize {
65    if encrypted {
66        page_len + GCM_TAG_LEN
67    } else {
68        page_len
69    }
70}
71
72pub const RUN_FLAG_ENCRYPTED: u8 = 1 << 0;
73pub const RUN_FLAG_TOMBSTONE_ONLY: u8 = 1 << 1;
74/// Run is "clean": exactly one version per `RowId`, no tombstones, and row_ids
75/// are strictly ascending. Bulk-loaded and compacted runs are clean by
76/// construction. For a clean run, MVCC visibility is trivially *every* position
77/// (the newest visible version of a rid is the only version, and it is not
78/// deleted), so the visibility pass can skip decoding the epoch/deleted system
79/// columns and the group-collapse loop — only the row_id column is needed (for
80/// survivor↔position mapping). Old runs lack this bit and default to not-clean
81/// (safe fallback to the full pass).
82pub const RUN_FLAG_CLEAN: u8 = 1 << 2;
83/// Run is "uniform-epoch": every row's commit epoch is the run's commit epoch,
84/// which is **not** baked into the file (it is assigned at commit/link time and
85/// recorded in the manifest `RunRef.epoch_created`). Spill runs from large
86/// transactions are written before the commit epoch is known, so their stored
87/// `_epoch` column is a placeholder; the reader must overlay the real epoch from
88/// the `RunRef`. The engine calls [`RunReader::set_uniform_epoch`] with that
89/// value after opening such a run.
90pub const RUN_FLAG_UNIFORM_EPOCH: u8 = 1 << 3;
91pub const SORT_KEY_ROW_ID: u16 = 0xFFFF;
92
93/// Reserved column ids for the MVCC system columns, stored in every run.
94pub const SYS_ROW_ID: u16 = 0xFFFE;
95pub const SYS_EPOCH: u16 = 0xFFFD;
96pub const SYS_DELETED: u16 = 0xFFFC;
97
98/// Guaranteed positional error of the stored PGM model (the predicted offset is
99/// within `± LEARNED_EPSILON` of the true position; a tiny final scan corrects).
100const LEARNED_EPSILON: usize = 64;
101
102/// Build the learned-index trailer as a compressed PGM-index over
103/// `(row_id, array_index)`. Near-linear row-id sequences collapse to a single
104/// segment, so the trailer is typically a few dozen bytes regardless of run size.
105fn build_learned_trailer(row_ids: &[Value]) -> Vec<u8> {
106    let points: Vec<(u64, usize)> = row_ids
107        .iter()
108        .enumerate()
109        .filter_map(|(i, v)| match v {
110            Value::Int64(r) => Some((*r as u64, i)),
111            _ => None,
112        })
113        .collect();
114    let pgm = PgmIndex::build(&points, LEARNED_EPSILON);
115    bincode::serialize(&pgm).expect("pgm serialize")
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct RunHeader {
120    pub magic: [u8; 8],
121    pub format_version: u16,
122    pub header_layout_version: u16,
123    pub run_id: u128,
124    pub content_hash: [u8; 32],
125    pub schema_id: u64,
126    pub epoch_created: u64,
127    pub level: u8,
128    pub flags: u8,
129    pub sort_key_column_id: u16,
130    pub row_count: u64,
131    pub min_row_id: u64,
132    pub max_row_id: u64,
133    pub column_count: u64,
134    pub column_dir_offset: u64,
135    pub index_trailer_offset: u64,
136    pub encryption_descriptor_offset: u64,
137    pub footer_offset: u64,
138    /// Offset/length of the AES-256-GCM-encrypted per-page min/max envelope
139    /// for encrypted columns (0/0 = absent; always 0 for plaintext runs and
140    /// pre-v2 files, whose zero header padding deserializes to 0 here).
141    pub encrypted_stats_offset: u64,
142    pub encrypted_stats_len: u64,
143}
144
145impl RunHeader {
146    pub fn is_encrypted(&self) -> bool {
147        self.flags & RUN_FLAG_ENCRYPTED != 0
148    }
149    pub fn is_clean(&self) -> bool {
150        self.flags & RUN_FLAG_CLEAN != 0
151    }
152    pub fn is_uniform_epoch(&self) -> bool {
153        self.flags & RUN_FLAG_UNIFORM_EPOCH != 0
154    }
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct ColumnPageHeader {
159    pub column_id: u16,
160    pub type_id_tag: u16,
161    pub encoding: u8,
162    pub flags: u8,
163    pub page_count: u32,
164    pub page_region_offset: u64,
165    pub page_region_len: u64,
166    pub page_stats: Vec<PageStat>,
167}
168
169impl ColumnPageHeader {
170    const PAGE_ENCRYPTED: u8 = 1 << 0;
171}
172
173/// Length of the run-metadata HMAC tag appended after the footer of an encrypted
174/// run (HMAC-SHA256, see [`crate::encryption::run_metadata_mac`]).
175const RUN_MAC_LEN: usize = 32;
176
177/// Compute the run-metadata MAC tag for an encrypted run, or `None` for a
178/// plaintext run (or when the `encryption` feature is off, where `enc` is always
179/// `None`). MACs `header ‖ dir ‖ descriptor` under the run's KEK-derived key.
180fn compute_run_mac(
181    enc: Option<&RunEncryption>,
182    header_bytes: &[u8],
183    dir_bytes: &[u8],
184) -> Option<[u8; RUN_MAC_LEN]> {
185    #[cfg(feature = "encryption")]
186    {
187        if let Some(e) = enc {
188            if let Some(mac_key) = &e.mac_key {
189                return Some(crate::encryption::run_metadata_mac(
190                    mac_key,
191                    header_bytes,
192                    dir_bytes,
193                    &e.descriptor_bytes,
194                ));
195            }
196        }
197    }
198    #[cfg(not(feature = "encryption"))]
199    {
200        let _ = (enc, header_bytes, dir_bytes);
201    }
202    None
203}
204
205/// A column's pages handed to the low-level writer.
206pub struct ColumnPayload {
207    pub column_id: u16,
208    pub type_id_tag: u16,
209    pub encoding: Encoding,
210    pub pages: Vec<Vec<u8>>,
211    /// Optional value-derived stats per page (parallel to [`Self::pages`]). When
212    /// present, [`write_run_with`] fills only the offset/length slots; when a
213    /// slot is missing it falls back to an empty stat.
214    pub page_stats: Vec<PageStat>,
215}
216
217/// Specification handed to [`write_run`] / [`write_run_with`].
218pub struct RunSpec<'a> {
219    pub run_id: u128,
220    pub schema_id: u64,
221    pub epoch_created: u64,
222    pub level: u8,
223    pub flags: u8,
224    pub sort_key_column_id: u16,
225    pub row_count: u64,
226    pub min_row_id: u64,
227    pub max_row_id: u64,
228    pub columns: &'a [ColumnPayload],
229}
230
231/// Write a run with no encryption and no index trailer (back-compat entry point).
232pub fn write_run(path: impl AsRef<Path>, spec: &RunSpec) -> Result<RunHeader> {
233    write_run_with(path, spec, None, &[], None)
234}
235
236/// Write a run, optionally encrypting page payloads with a per-file DEK
237/// (wrapped by the table `kek`, see §7) and appending an `index_trailer` blob
238/// (used by [`RunWriter`] for the learned index).
239///
240/// Tries the direct-to-mmap writer (Phase 14.6 — pages are encrypted + placed
241/// straight into a memory mapping of the output file, in parallel, with no
242/// intermediate whole-file `Vec<u8>`), and falls back to the in-buffer writer
243/// only when the mapping itself can't be created (some filesystems/environments
244/// reject `mmap`). The two writers produce a byte-identical run.
245pub fn write_run_with(
246    path: impl AsRef<Path>,
247    spec: &RunSpec,
248    kek: Option<&Kek>,
249    indexable_columns: &[(u16, u8)],
250    index_trailer: Option<&[u8]>,
251) -> Result<RunHeader> {
252    // Assemble per-run encryption material (fresh DEK + nonce prefix + wrapped
253    // descriptor) when a KEK is supplied. The page cipher lives only for this
254    // write; the wrapped DEK is embedded in the run below.
255    let enc: Option<RunEncryption> = match kek {
256        Some(k) => Some(setup_run_encryption(k, indexable_columns)?),
257        None => None,
258    };
259    match write_run_mmap(path.as_ref(), spec, enc.as_ref(), index_trailer) {
260        Ok(h) => Ok(h),
261        Err(e) if is_mmap_unavailable(&e) => write_run_vec(path, spec, enc, index_trailer),
262        Err(e) => Err(e),
263    }
264}
265
266fn write_run_with_file(
267    mut file: File,
268    spec: &RunSpec,
269    kek: Option<&Kek>,
270    indexable_columns: &[(u16, u8)],
271    index_trailer: Option<&[u8]>,
272) -> Result<RunHeader> {
273    let enc = match kek {
274        Some(key) => Some(setup_run_encryption(key, indexable_columns)?),
275        None => None,
276    };
277    match write_run_mmap_file(&file, spec, enc.as_ref(), index_trailer) {
278        Ok(header) => Ok(header),
279        Err(error) if is_mmap_unavailable(&error) => {
280            file.set_len(0)?;
281            file.seek(SeekFrom::Start(0))?;
282            let (bytes, header) = encode_run_vec(spec, enc, index_trailer)?;
283            file.write_all(&bytes)?;
284            file.sync_all()?;
285            Ok(header)
286        }
287        Err(error) => Err(error),
288    }
289}
290
291/// `true` when `write_run_mmap` could not create the mapping (the only case
292/// where we fall back to the in-buffer writer). Any other error — encryption,
293/// serialization, a genuine write failure — must surface.
294fn is_mmap_unavailable(e: &MongrelError) -> bool {
295    matches!(e, MongrelError::InvalidArgument(m) if m.starts_with("__mmap_unavailable__:"))
296}
297
298/// Direct-to-mmap run writer (Phases 14.5 + 14.6).
299///
300/// Lays the whole run out up front (computing each page's on-disk offset/length
301/// without encrypting), sizes the file, memory-maps it, then encrypts + copies
302/// every page **straight into the mapping** on the rayon pool. Because pages
303/// land in the kernel page cache (the mapping) instead of a heap `Vec<u8>`,
304/// the kernel can begin writeback of earlier pages while later ones are still
305/// being encrypted — CPU encryption overlaps with disk I/O (the double-buffer
306/// intent of 14.5). There is no whole-run buffer; peak extra memory is a few
307/// transient ciphertext `Vec`s (one per worker thread), not the full run.
308/// Direct-to-mmap run writer (Phases 14.5 + 14.6).
309///
310/// Lays the whole run out ([`plan_run`]), sizes the file, memory-maps it, then
311/// delegates to [`place_run`] which encrypts + copies every page **straight into
312/// the mapping** on the rayon pool. Because pages land in the kernel page cache
313/// (the mapping) instead of a heap `Vec<u8>`, the kernel can begin writeback of
314/// earlier pages while later ones are still being encrypted — CPU encryption
315/// overlaps with disk I/O (the double-buffer intent of 14.5). There is no
316/// whole-run buffer; peak extra memory is a few transient ciphertext `Vec`s
317/// (one per worker thread), not the full run.
318fn write_run_mmap(
319    path: &Path,
320    spec: &RunSpec,
321    enc: Option<&RunEncryption>,
322    index_trailer: Option<&[u8]>,
323) -> Result<RunHeader> {
324    let file = OpenOptions::new().create_new(true).write(true).open(path)?;
325    let result = write_run_mmap_file(&file, spec, enc, index_trailer);
326    if result.as_ref().is_err_and(is_mmap_unavailable) {
327        drop(file);
328        let _ = std::fs::remove_file(path);
329    }
330    result
331}
332
333fn write_run_mmap_file(
334    file: &File,
335    spec: &RunSpec,
336    enc: Option<&RunEncryption>,
337    index_trailer: Option<&[u8]>,
338) -> Result<RunHeader> {
339    let plan = plan_run(spec, enc, index_trailer)?;
340    file.set_len(plan.total as u64)?;
341    let mut mmap = match unsafe { memmap2::MmapMut::map_mut(file) } {
342        Ok(m) => m,
343        Err(e) => {
344            return Err(MongrelError::InvalidArgument(format!(
345                "__mmap_unavailable__: {e}"
346            )));
347        }
348    };
349    let header = place_run(spec, enc, index_trailer, &plan, &mut mmap[..])?;
350    mmap.flush()?;
351    file.sync_all()?;
352    Ok(header)
353}
354
355/// A precomputed run layout: the page-placement jobs, the serialized column
356/// directory (stats already filled), and every on-disk offset/length. The
357/// layout is independent of the backing buffer, so [`place_run`] can target a
358/// mmap'd file or a plain `Vec` interchangeably — which is what lets the
359/// placement path be unit-tested even where file mmap is unavailable.
360struct RunPlan {
361    jobs: Vec<(usize, usize, u64, usize)>, // (col_idx, page_seq, offset, on_disk_len)
362    dir_bytes: Vec<u8>,
363    encrypted: bool,
364    column_dir_offset: u64,
365    index_trailer_offset: u64,
366    encryption_descriptor_offset: u64,
367    footer_offset: u64,
368    total: usize,
369    /// Serialized (plaintext) [`EncryptedColumnStats`]; encrypted into the
370    /// stats section by the writer. `None` when the run is plaintext or no
371    /// encrypted column carries min/max.
372    encrypted_stats_plain: Option<Vec<u8>>,
373    encrypted_stats_offset: u64,
374    encrypted_stats_len: u64,
375}
376
377/// Compute the run layout: per-page offset + on-disk length, the filled column
378/// directory (serialized), and the dir / trailer / descriptor / footer offsets.
379/// Validates the encrypted-page-seq nonce-exhaustion bound.
380fn plan_run(
381    spec: &RunSpec,
382    enc: Option<&RunEncryption>,
383    index_trailer: Option<&[u8]>,
384) -> Result<RunPlan> {
385    let encrypted = enc.is_some();
386    let columns = spec.columns;
387    let mut jobs: Vec<(usize, usize, u64, usize)> = Vec::new();
388    let mut dir: Vec<ColumnPageHeader> = Vec::with_capacity(columns.len());
389    let mut enc_stats: EncryptedColumnStats = Vec::new();
390    let mut cursor: u64 = RUN_HEADER_PAD as u64;
391    for (ci, col) in columns.iter().enumerate() {
392        let region_offset = cursor;
393        let mut region_len = 0u64;
394        let mut stats = Vec::with_capacity(col.pages.len());
395        let mut col_minmax: Vec<PageMinMax> = Vec::new();
396        for (ps, page) in col.pages.iter().enumerate() {
397            // The per-page GCM nonce encodes page_seq in 2 bytes; refuse to
398            // silently truncate at 65 535 pages/column (4.29e9 rows), which
399            // would otherwise reuse a nonce under the run's DEK. Sequence
400            // 0xFFFF itself is reserved for the encrypted-stats envelope.
401            if encrypted && ps >= u16::MAX as usize {
402                return Err(MongrelError::Full(format!(
403                    "column {:#x} exceeds 65534 pages; encrypted-run page-seq nonce space exhausted",
404                    col.column_id
405                )));
406            }
407            let odl = on_disk_len(page.len(), encrypted);
408            jobs.push((ci, ps, cursor, odl));
409            let mut stat = col.page_stats.get(ps).cloned().unwrap_or(PageStat {
410                first_row_id: 0,
411                last_row_id: 0,
412                null_count: 0,
413                row_count: 0,
414                min: None,
415                max: None,
416                offset: 0,
417                compressed_len: 0,
418                uncompressed_len: 0,
419            });
420            stat.offset = cursor;
421            stat.compressed_len = odl as u32;
422            stat.uncompressed_len = page.len() as u32;
423            // The column directory is serialized in cleartext, so per-page
424            // min/max would leak raw plaintext values (literal bytes for `Bytes`
425            // columns) of every encrypted page to anyone reading the file
426            // without the key. Move them out of the directory and into the
427            // DEK-encrypted stats envelope, which the reader overlays back at
428            // open — zone-map page pruning works identically to plaintext runs.
429            if encrypted {
430                col_minmax.push((stat.min.take(), stat.max.take()));
431            }
432            stats.push(stat);
433            cursor += odl as u64;
434            region_len += odl as u64;
435        }
436        if col_minmax
437            .iter()
438            .any(|(mn, mx)| mn.is_some() || mx.is_some())
439        {
440            enc_stats.push((col.column_id, col_minmax));
441        }
442        let page_flags = if encrypted {
443            ColumnPageHeader::PAGE_ENCRYPTED
444        } else {
445            0
446        };
447        dir.push(ColumnPageHeader {
448            column_id: col.column_id,
449            type_id_tag: col.type_id_tag,
450            encoding: col.encoding as u8,
451            flags: page_flags,
452            page_count: col.pages.len() as u32,
453            page_region_offset: region_offset,
454            page_region_len: region_len,
455            page_stats: stats,
456        });
457    }
458    let dir_bytes = bincode::serialize(&dir)?;
459    let column_dir_offset = cursor;
460    cursor += dir_bytes.len() as u64;
461    let index_trailer_offset = match index_trailer {
462        Some(t) => {
463            let off = cursor;
464            cursor += t.len() as u64;
465            off
466        }
467        None => 0,
468    };
469    let encryption_descriptor_offset = match enc {
470        Some(e) => {
471            let off = cursor;
472            cursor += 4 + e.descriptor_bytes.len() as u64;
473            off
474        }
475        None => 0,
476    };
477    let (encrypted_stats_plain, encrypted_stats_offset, encrypted_stats_len) =
478        if encrypted && !enc_stats.is_empty() {
479            let plain = bincode::serialize(&enc_stats)?;
480            let ct_len = on_disk_len(plain.len(), true) as u64;
481            let off = cursor;
482            cursor += ct_len;
483            (Some(plain), off, ct_len)
484        } else {
485            (None, 0, 0)
486        };
487    let footer_offset = cursor;
488    // footer = MAGIC(8) + footer_offset(8) + checksum(32); encrypted runs append
489    // a 32-byte HMAC tag (RUN_MAC_LEN) authenticating header+dir+descriptor.
490    let total = footer_offset as usize + 8 + 8 + 32 + if encrypted { RUN_MAC_LEN } else { 0 };
491    Ok(RunPlan {
492        jobs,
493        dir_bytes,
494        encrypted,
495        column_dir_offset,
496        index_trailer_offset,
497        encryption_descriptor_offset,
498        footer_offset,
499        total,
500        encrypted_stats_plain,
501        encrypted_stats_offset,
502        encrypted_stats_len,
503    })
504}
505
506/// Place a run into `buf` (which must be exactly [`RunPlan::total`] bytes):
507/// encrypt + copy every page into its precomputed slot on the rayon pool, then
508/// write the column directory, index trailer, encryption descriptor, header,
509/// and checksummed footer. Backed by a mmap'd file in production and by a
510/// `Vec<u8>` in tests — the placement logic is identical either way, which is
511/// what makes the Phase 14.6 path unit-testable.
512fn place_run(
513    spec: &RunSpec,
514    enc: Option<&RunEncryption>,
515    index_trailer: Option<&[u8]>,
516    plan: &RunPlan,
517    buf: &mut [u8],
518) -> Result<RunHeader> {
519    use rayon::prelude::*;
520    use std::borrow::Cow;
521    debug_assert_eq!(
522        buf.len(),
523        plan.total,
524        "place_run: buffer must be exactly plan.total bytes"
525    );
526
527    let cipher: Option<&dyn Cipher> = enc.map(|e| e.cipher.as_ref());
528    let nonce_prefix = enc.map(|e| e.nonce_prefix);
529    let columns = spec.columns;
530
531    // ---- parallel placement: encrypt + copy each page into its slot ----
532    // Disjointness is by construction: every job targets [offset, offset+len)
533    // and `plan_run` made those ranges non-overlapping. The `SyncPtr` newtype
534    // lets the base pointer cross thread boundaries for that purpose.
535    struct SyncPtr(*mut u8);
536    unsafe impl Send for SyncPtr {}
537    unsafe impl Sync for SyncPtr {}
538    impl SyncPtr {
539        fn get(&self) -> *mut u8 {
540            self.0
541        }
542    }
543    let base = SyncPtr(buf.as_mut_ptr());
544    plan.jobs
545        .par_iter()
546        .map(
547            |&(ci, ps, offset, odl): &(usize, usize, u64, usize)| -> Result<()> {
548                let page = &columns[ci].pages[ps];
549                let dst =
550                    unsafe { std::slice::from_raw_parts_mut(base.get().add(offset as usize), odl) };
551                let bytes: Cow<[u8]> = match cipher {
552                    Some(c) => {
553                        let nonce =
554                            page_nonce(nonce_prefix.unwrap(), columns[ci].column_id, ps as u32);
555                        let ct = c.encrypt_page(&nonce, page)?;
556                        // Guards the GCM_TAG_LEN assumption: a future cipher with a
557                        // different tag size must change `on_disk_len` too.
558                        assert_eq!(
559                        ct.len(), odl,
560                        "ciphertext length {} != predicted {}; GCM tag size assumption is stale",
561                        ct.len(), odl
562                    );
563                        Cow::Owned(ct)
564                    }
565                    None => {
566                        debug_assert_eq!(page.len(), odl);
567                        Cow::Borrowed(page.as_slice())
568                    }
569                };
570                dst.copy_from_slice(&bytes);
571                Ok(())
572            },
573        )
574        .collect::<Result<()>>()?;
575
576    // ---- content hash over the on-disk page region (column-major, page order) ----
577    let content_hash = {
578        let mut h = Sha256::new();
579        h.update(&buf[RUN_HEADER_PAD..plan.column_dir_offset as usize]);
580        h.finalize()
581    };
582
583    // ---- dir / trailer / descriptor ----
584    let doff = plan.column_dir_offset as usize;
585    buf[doff..doff + plan.dir_bytes.len()].copy_from_slice(&plan.dir_bytes);
586    if let Some(t) = index_trailer {
587        let off = plan.index_trailer_offset as usize;
588        buf[off..off + t.len()].copy_from_slice(t);
589    }
590    if let Some(e) = enc {
591        let off = plan.encryption_descriptor_offset as usize;
592        buf[off..off + 4].copy_from_slice(&(e.descriptor_bytes.len() as u32).to_le_bytes());
593        buf[off + 4..off + 4 + e.descriptor_bytes.len()].copy_from_slice(&e.descriptor_bytes);
594    }
595    if let (Some(e), Some(plain)) = (enc, plan.encrypted_stats_plain.as_ref()) {
596        let nonce = page_nonce(e.nonce_prefix, ENC_STATS_NONCE_COLUMN, ENC_STATS_NONCE_SEQ);
597        let ct = e.cipher.encrypt_page(&nonce, plain)?;
598        debug_assert_eq!(ct.len() as u64, plan.encrypted_stats_len);
599        let off = plan.encrypted_stats_offset as usize;
600        buf[off..off + ct.len()].copy_from_slice(&ct);
601    }
602
603    // ---- header + footer ----
604    let header_flags = if plan.encrypted {
605        spec.flags | RUN_FLAG_ENCRYPTED
606    } else {
607        spec.flags
608    };
609    let header = RunHeader {
610        magic: RUN_MAGIC,
611        format_version: RUN_FORMAT_VERSION,
612        header_layout_version: RUN_HEADER_VERSION,
613        run_id: spec.run_id,
614        content_hash: content_hash.into(),
615        schema_id: spec.schema_id,
616        epoch_created: spec.epoch_created,
617        level: spec.level,
618        flags: header_flags,
619        sort_key_column_id: spec.sort_key_column_id,
620        row_count: spec.row_count,
621        min_row_id: spec.min_row_id,
622        max_row_id: spec.max_row_id,
623        column_count: columns.len() as u64,
624        column_dir_offset: plan.column_dir_offset,
625        index_trailer_offset: plan.index_trailer_offset,
626        encryption_descriptor_offset: plan.encryption_descriptor_offset,
627        footer_offset: plan.footer_offset,
628        encrypted_stats_offset: plan.encrypted_stats_offset,
629        encrypted_stats_len: plan.encrypted_stats_len,
630    };
631    let header_bytes = bincode::serialize(&header)?;
632    if header_bytes.len() > RUN_HEADER_PAD {
633        return Err(MongrelError::InvalidArgument(format!(
634            "run header too large: {} > {RUN_HEADER_PAD}",
635            header_bytes.len()
636        )));
637    }
638    buf[..header_bytes.len()].copy_from_slice(&header_bytes);
639
640    let checksum = Sha256::digest(&buf[..plan.footer_offset as usize]);
641    let foot = plan.footer_offset as usize;
642    buf[foot..foot + 8].copy_from_slice(&RUN_MAGIC);
643    buf[foot + 8..foot + 16].copy_from_slice(&plan.footer_offset.to_le_bytes());
644    buf[foot + 16..foot + 48].copy_from_slice(&checksum);
645    // Encrypted runs: append the keyed metadata MAC over header‖dir‖descriptor.
646    if let Some(tag) = compute_run_mac(enc, &header_bytes, &plan.dir_bytes) {
647        buf[foot + 48..foot + 48 + RUN_MAC_LEN].copy_from_slice(&tag);
648    }
649    Ok(header)
650}
651
652/// Fallback in-buffer writer (used when the output file can't be mmap'd).
653/// Produces a byte-identical run to [`write_run_mmap`].
654fn write_run_vec(
655    path: impl AsRef<Path>,
656    spec: &RunSpec,
657    enc: Option<RunEncryption>,
658    index_trailer: Option<&[u8]>,
659) -> Result<RunHeader> {
660    let (buf, header) = encode_run_vec(spec, enc, index_trailer)?;
661    let mut file = OpenOptions::new().create_new(true).write(true).open(path)?;
662    file.write_all(&buf)?;
663    file.sync_all()?;
664    Ok(header)
665}
666
667fn encode_run_vec(
668    spec: &RunSpec,
669    enc: Option<RunEncryption>,
670    index_trailer: Option<&[u8]>,
671) -> Result<(Vec<u8>, RunHeader)> {
672    let mut buf: Vec<u8> = vec![0; RUN_HEADER_PAD]; // reserve header region
673    let mut content_hasher = Sha256::new();
674    let mut dir: Vec<ColumnPageHeader> = Vec::with_capacity(spec.columns.len());
675    let mut enc_stats: EncryptedColumnStats = Vec::new();
676
677    for col in spec.columns {
678        let region_offset = buf.len() as u64;
679        let mut region_len = 0u64;
680        let mut stats = Vec::with_capacity(col.pages.len());
681        let mut col_minmax: Vec<PageMinMax> = Vec::new();
682        for (page_seq, page) in col.pages.iter().enumerate() {
683            if enc.is_some() && page_seq >= u16::MAX as usize {
684                return Err(MongrelError::Full(format!(
685                    "column {:#x} exceeds 65534 pages; encrypted-run page-seq nonce space exhausted",
686                    col.column_id
687                )));
688            }
689            let on_disk: Vec<u8> = match &enc {
690                Some(e) => e.cipher.encrypt_page(
691                    &page_nonce(e.nonce_prefix, col.column_id, page_seq as u32),
692                    page,
693                )?,
694                None => page.clone(),
695            };
696            let offset = buf.len() as u64;
697            buf.write_all(&on_disk)?;
698            content_hasher.update(&on_disk);
699            region_len += on_disk.len() as u64;
700            let mut stat = if let Some(s) = col.page_stats.get(page_seq) {
701                s.clone()
702            } else {
703                PageStat {
704                    first_row_id: 0,
705                    last_row_id: 0,
706                    null_count: 0,
707                    row_count: 0,
708                    min: None,
709                    max: None,
710                    offset: 0,
711                    compressed_len: 0,
712                    uncompressed_len: 0,
713                }
714            };
715            stat.offset = offset;
716            stat.compressed_len = on_disk.len() as u32;
717            stat.uncompressed_len = page.len() as u32;
718            // See plan_run: the cleartext directory must not carry plaintext
719            // min/max for encrypted columns — they move into the encrypted
720            // stats envelope. Keep this byte-identical to plan_run so both
721            // writers emit the same run.
722            if enc.is_some() {
723                col_minmax.push((stat.min.take(), stat.max.take()));
724            }
725            stats.push(stat);
726        }
727        if col_minmax
728            .iter()
729            .any(|(mn, mx)| mn.is_some() || mx.is_some())
730        {
731            enc_stats.push((col.column_id, col_minmax));
732        }
733        let page_flags = if enc.is_some() {
734            ColumnPageHeader::PAGE_ENCRYPTED
735        } else {
736            0
737        };
738        dir.push(ColumnPageHeader {
739            column_id: col.column_id,
740            type_id_tag: col.type_id_tag,
741            encoding: col.encoding as u8,
742            flags: page_flags,
743            page_count: col.pages.len() as u32,
744            page_region_offset: region_offset,
745            page_region_len: region_len,
746            page_stats: stats,
747        });
748    }
749
750    let column_dir_offset = buf.len() as u64;
751    let dir_bytes = bincode::serialize(&dir)?;
752    buf.write_all(&dir_bytes)?;
753
754    let index_trailer_offset = match index_trailer {
755        Some(trailer) => {
756            let off = buf.len() as u64;
757            buf.write_all(trailer)?;
758            off
759        }
760        None => 0,
761    };
762    let encryption_descriptor_offset = match &enc {
763        Some(e) => {
764            let off = buf.len() as u64;
765            buf.write_all(&(e.descriptor_bytes.len() as u32).to_le_bytes())?;
766            buf.write_all(&e.descriptor_bytes)?;
767            off
768        }
769        None => 0,
770    };
771    let (encrypted_stats_offset, encrypted_stats_len) = match &enc {
772        Some(e) if !enc_stats.is_empty() => {
773            let plain = bincode::serialize(&enc_stats)?;
774            let nonce = page_nonce(e.nonce_prefix, ENC_STATS_NONCE_COLUMN, ENC_STATS_NONCE_SEQ);
775            let ct = e.cipher.encrypt_page(&nonce, &plain)?;
776            let off = buf.len() as u64;
777            let len = ct.len() as u64;
778            buf.write_all(&ct)?;
779            (off, len)
780        }
781        _ => (0, 0),
782    };
783    let footer_offset = buf.len() as u64;
784
785    let header_flags = if enc.is_some() {
786        spec.flags | RUN_FLAG_ENCRYPTED
787    } else {
788        spec.flags
789    };
790    let header = RunHeader {
791        magic: RUN_MAGIC,
792        format_version: RUN_FORMAT_VERSION,
793        header_layout_version: RUN_HEADER_VERSION,
794        run_id: spec.run_id,
795        content_hash: content_hasher.finalize().into(),
796        schema_id: spec.schema_id,
797        epoch_created: spec.epoch_created,
798        level: spec.level,
799        flags: header_flags,
800        sort_key_column_id: spec.sort_key_column_id,
801        row_count: spec.row_count,
802        min_row_id: spec.min_row_id,
803        max_row_id: spec.max_row_id,
804        column_count: spec.columns.len() as u64,
805        column_dir_offset,
806        index_trailer_offset,
807        encryption_descriptor_offset,
808        footer_offset,
809        encrypted_stats_offset,
810        encrypted_stats_len,
811    };
812    let header_bytes = bincode::serialize(&header)?;
813    if header_bytes.len() > RUN_HEADER_PAD {
814        return Err(MongrelError::InvalidArgument(format!(
815            "run header too large: {} > {RUN_HEADER_PAD}",
816            header_bytes.len()
817        )));
818    }
819    buf[..header_bytes.len()].copy_from_slice(&header_bytes);
820
821    let checksum = Sha256::digest(&buf[..footer_offset as usize]);
822    buf.write_all(&RUN_MAGIC)?;
823    buf.write_all(&footer_offset.to_le_bytes())?;
824    buf.write_all(&checksum)?;
825    // Encrypted runs: append the keyed metadata MAC (byte-identical to the mmap
826    // writer). `dir_bytes`/`header_bytes` here are the exact serialized forms.
827    if let Some(tag) = compute_run_mac(enc.as_ref(), &header_bytes, &dir_bytes) {
828        buf.write_all(&tag)?;
829    }
830
831    Ok((buf, header))
832}
833
834fn page_nonce(nonce_prefix: [u8; 12], column_id: u16, page_seq: u32) -> [u8; 12] {
835    let mut n = nonce_prefix;
836    n[8..10].copy_from_slice(&column_id.to_le_bytes());
837    n[10..12].copy_from_slice(&(page_seq as u16).to_le_bytes());
838    n
839}
840
841/// Decrypt the run's per-page min/max envelope ([`EncryptedColumnStats`]) and
842/// overlay the bounds onto the in-memory column directory, restoring zone-map
843/// page pruning for encrypted columns. Caller must have verified the run
844/// metadata MAC first (the envelope's offset/len live in the header); the
845/// envelope itself is AES-256-GCM-authenticated, so tampering fails loudly —
846/// the same posture as a tampered page payload.
847fn overlay_encrypted_stats(
848    file: &mut File,
849    header: &RunHeader,
850    cipher: &dyn Cipher,
851    nonce_prefix: [u8; 12],
852    dir: &mut [ColumnPageHeader],
853) -> Result<()> {
854    file.seek(SeekFrom::Start(header.encrypted_stats_offset))?;
855    const MAX_ENCRYPTED_STATS_BYTES: u64 = 64 * 1024 * 1024;
856    if header.encrypted_stats_len > MAX_ENCRYPTED_STATS_BYTES {
857        return Err(MongrelError::InvalidArgument(format!(
858            "encrypted run stats length {} exceeds {MAX_ENCRYPTED_STATS_BYTES}",
859            header.encrypted_stats_len
860        )));
861    }
862    let mut ct = vec![0u8; header.encrypted_stats_len as usize];
863    file.read_exact(&mut ct)?;
864    let nonce = page_nonce(nonce_prefix, ENC_STATS_NONCE_COLUMN, ENC_STATS_NONCE_SEQ);
865    let plain = cipher.decrypt_page(&nonce, &ct)?;
866    let stats: EncryptedColumnStats = bincode::deserialize(&plain)
867        .map_err(|e| MongrelError::Encryption(format!("bad encrypted page-stats envelope: {e}")))?;
868    for (cid, minmax) in stats {
869        let Some(col) = dir.iter_mut().find(|c| c.column_id == cid) else {
870            continue;
871        };
872        for (stat, (mn, mx)) in col.page_stats.iter_mut().zip(minmax) {
873            stat.min = mn;
874            stat.max = mx;
875        }
876    }
877    Ok(())
878}
879
880/// Stable content-address of an immutable run page (the cache key): SHA-256 of
881/// `(table_id, run_id, column_id, page_seq)`. Runs are immutable, so this
882/// identity is also the page's content address — a rewritten page lives in a
883/// different run (different id) and so gets a different key without any
884/// invalidation sweep. `table_id` namespaces the shared cache across tables in
885/// a `Database` so two tables' identically-numbered runs never collide.
886pub(crate) fn page_cache_key(
887    table_id: u64,
888    run_id: u128,
889    column_id: u16,
890    page_seq: usize,
891) -> [u8; 32] {
892    let mut h = Sha256::new();
893    h.update(table_id.to_be_bytes());
894    h.update(run_id.to_be_bytes());
895    h.update(column_id.to_be_bytes());
896    h.update((page_seq as u64).to_be_bytes());
897    let out = h.finalize();
898    let mut k = [0u8; 32];
899    k.copy_from_slice(&out);
900    k
901}
902
903/// Decrypt a raw (on-disk) page when the column is encrypted, else pass it
904/// through. The shared page cache stores the raw bytes (ciphertext when
905/// encrypted), so this runs after every cache hit or disk read.
906fn decrypt_or_passthrough(
907    cipher: Option<&dyn Cipher>,
908    nonce_prefix: [u8; 12],
909    column_id: u16,
910    page_seq: usize,
911    encrypted: bool,
912    buf: &[u8],
913) -> Result<Vec<u8>> {
914    if encrypted {
915        match cipher {
916            Some(c) => {
917                Ok(c.decrypt_page(&page_nonce(nonce_prefix, column_id, page_seq as u32), buf)?)
918            }
919            None => Err(MongrelError::Decryption(
920                "encrypted page but no cipher".into(),
921            )),
922        }
923    } else {
924        Ok(buf.to_vec())
925    }
926}
927
928/// Read just the header and confirm the footer magic, without hashing the
929/// body. Cheap: two small fixed-size reads, no allocation proportional to
930/// run size. Only safe as a substitute for [`read_header`] when the caller
931/// has independent evidence this exact `run_id`'s checksum already verified
932/// in this process (see `open_with_cache`'s `verified_runs` cache) — `.sr`
933/// runs are immutable once written, so a checksum verified once cannot
934/// regress later in the same process lifetime. Still catches gross
935/// corruption (truncation, garbled header) via the magic checks and the
936/// bincode deserialize.
937fn read_header_fast_from_file(file: &mut File) -> Result<RunHeader> {
938    file.seek(SeekFrom::Start(0))?;
939    let mut header_buf = [0u8; RUN_HEADER_PAD];
940    file.read_exact(&mut header_buf)?;
941    let header: RunHeader = bincode::deserialize(&header_buf)
942        .map_err(|e| MongrelError::InvalidArgument(format!("bad run header: {e}")))?;
943    validate_run_header_bytes(&header, &header_buf)?;
944    validate_run_layout(&header, file.metadata()?.len())?;
945    file.seek(SeekFrom::Start(header.footer_offset))?;
946    let mut footer_magic = [0u8; 8];
947    file.read_exact(&mut footer_magic)?;
948    if footer_magic != RUN_MAGIC {
949        return Err(MongrelError::MagicMismatch {
950            what: "sorted run footer",
951            expected: RUN_MAGIC,
952            got: footer_magic,
953        });
954    }
955    Ok(header)
956}
957
958/// Read and validate a run header (magic + footer checksum).
959pub fn read_header(path: impl AsRef<Path>) -> Result<RunHeader> {
960    let mut file = crate::durable_file::open_regular_nofollow(path.as_ref())?;
961    read_header_from_file(&mut file)
962}
963
964fn validate_run_layout(header: &RunHeader, file_len: u64) -> Result<()> {
965    let footer_len = 8u64
966        + 8
967        + 32
968        + if header.is_encrypted() {
969            RUN_MAC_LEN as u64
970        } else {
971            0
972        };
973    let expected_len = header
974        .footer_offset
975        .checked_add(footer_len)
976        .ok_or_else(|| MongrelError::InvalidArgument("sorted run length overflow".into()))?;
977    if header.footer_offset < RUN_HEADER_PAD as u64 || expected_len != file_len {
978        return Err(MongrelError::InvalidArgument(format!(
979            "invalid sorted run layout: footer={} file={file_len}",
980            header.footer_offset
981        )));
982    }
983    let in_body = |offset: u64| {
984        offset == 0 || (offset >= RUN_HEADER_PAD as u64 && offset <= header.footer_offset)
985    };
986    if header.column_dir_offset < RUN_HEADER_PAD as u64
987        || header.column_dir_offset > header.footer_offset
988        || !in_body(header.index_trailer_offset)
989        || !in_body(header.encryption_descriptor_offset)
990        || !in_body(header.encrypted_stats_offset)
991        || header
992            .encrypted_stats_offset
993            .checked_add(header.encrypted_stats_len)
994            .is_none_or(|end| end > header.footer_offset)
995    {
996        return Err(MongrelError::InvalidArgument(
997            "sorted run metadata offsets are outside the file".into(),
998        ));
999    }
1000    Ok(())
1001}
1002
1003fn validate_run_header_bytes(header: &RunHeader, bytes: &[u8; RUN_HEADER_PAD]) -> Result<()> {
1004    if header.magic != RUN_MAGIC {
1005        return Err(MongrelError::MagicMismatch {
1006            what: "sorted run",
1007            expected: RUN_MAGIC,
1008            got: header.magic,
1009        });
1010    }
1011    const KNOWN_FLAGS: u8 =
1012        RUN_FLAG_ENCRYPTED | RUN_FLAG_TOMBSTONE_ONLY | RUN_FLAG_CLEAN | RUN_FLAG_UNIFORM_EPOCH;
1013    if header.format_version != RUN_FORMAT_VERSION
1014        || header.header_layout_version != RUN_HEADER_VERSION
1015        || header.flags & !KNOWN_FLAGS != 0
1016        || (header.row_count == 0 && (header.min_row_id != 0 || header.max_row_id != 0))
1017        || (header.row_count != 0 && header.min_row_id > header.max_row_id)
1018    {
1019        return Err(MongrelError::InvalidArgument(
1020            "unsupported or invalid sorted run header".into(),
1021        ));
1022    }
1023    let canonical = bincode::serialize(header)?;
1024    if canonical.len() > RUN_HEADER_PAD
1025        || bytes[..canonical.len()] != canonical
1026        || bytes[canonical.len()..].iter().any(|byte| *byte != 0)
1027    {
1028        return Err(MongrelError::InvalidArgument(
1029            "sorted run header has noncanonical bytes or nonzero padding".into(),
1030        ));
1031    }
1032    Ok(())
1033}
1034
1035fn read_header_from_file(file: &mut File) -> Result<RunHeader> {
1036    file.seek(SeekFrom::Start(0))?;
1037    let mut header_buf = [0u8; RUN_HEADER_PAD];
1038    file.read_exact(&mut header_buf)?;
1039    let header: RunHeader = bincode::deserialize(&header_buf)
1040        .map_err(|e| MongrelError::InvalidArgument(format!("bad run header: {e}")))?;
1041    validate_run_header_bytes(&header, &header_buf)?;
1042
1043    validate_run_layout(&header, file.metadata()?.len())?;
1044    file.seek(SeekFrom::Start(header.footer_offset))?;
1045    let mut footer = [0u8; 8 + 8 + 32];
1046    file.read_exact(&mut footer)?;
1047    if footer[..8] != RUN_MAGIC {
1048        return Err(MongrelError::MagicMismatch {
1049            what: "sorted run footer",
1050            expected: RUN_MAGIC,
1051            got: footer[..8].try_into().unwrap(),
1052        });
1053    }
1054    let mut hasher = Sha256::new();
1055    file.seek(SeekFrom::Start(0))?;
1056    let mut remaining = header.footer_offset;
1057    let mut buffer = [0u8; 64 * 1024];
1058    while remaining != 0 {
1059        let length = usize::try_from(remaining.min(buffer.len() as u64)).unwrap();
1060        file.read_exact(&mut buffer[..length])?;
1061        hasher.update(&buffer[..length]);
1062        remaining -= length as u64;
1063    }
1064    let computed: [u8; 32] = hasher.finalize().into();
1065    let stored: [u8; 32] = footer[16..].try_into().unwrap();
1066    if computed != stored {
1067        return Err(MongrelError::ChecksumMismatch {
1068            expected: u64::from_be_bytes(stored[..8].try_into().unwrap()),
1069            actual: u64::from_be_bytes(computed[..8].try_into().unwrap()),
1070            context: "sorted run footer".into(),
1071        });
1072    }
1073    file.seek(SeekFrom::Start(RUN_HEADER_PAD as u64))?;
1074    let mut remaining = header.column_dir_offset - RUN_HEADER_PAD as u64;
1075    let mut content = Sha256::new();
1076    while remaining != 0 {
1077        let length = usize::try_from(remaining.min(buffer.len() as u64)).unwrap();
1078        file.read_exact(&mut buffer[..length])?;
1079        content.update(&buffer[..length]);
1080        remaining -= length as u64;
1081    }
1082    let content_hash: [u8; 32] = content.finalize().into();
1083    if content_hash != header.content_hash {
1084        return Err(MongrelError::ChecksumMismatch {
1085            expected: u64::from_be_bytes(header.content_hash[..8].try_into().unwrap()),
1086            actual: u64::from_be_bytes(content_hash[..8].try_into().unwrap()),
1087            context: "sorted run content hash".into(),
1088        });
1089    }
1090    Ok(header)
1091}
1092
1093/// Read the column directory.
1094pub fn read_column_dir(
1095    path: impl AsRef<Path>,
1096    header: &RunHeader,
1097) -> Result<Vec<ColumnPageHeader>> {
1098    let mut file = crate::durable_file::open_regular_nofollow(path.as_ref())?;
1099    read_column_dir_from_file(&mut file, header)
1100}
1101
1102fn read_column_dir_from_file(file: &mut File, header: &RunHeader) -> Result<Vec<ColumnPageHeader>> {
1103    file.seek(SeekFrom::Start(header.column_dir_offset))?;
1104    let end = [
1105        header.index_trailer_offset,
1106        header.encryption_descriptor_offset,
1107        header.encrypted_stats_offset,
1108        header.footer_offset,
1109    ]
1110    .into_iter()
1111    .filter(|offset| *offset > header.column_dir_offset)
1112    .min()
1113    .ok_or_else(|| {
1114        MongrelError::InvalidArgument("sorted run column directory has no end".into())
1115    })?;
1116    let len = end.checked_sub(header.column_dir_offset).ok_or_else(|| {
1117        MongrelError::InvalidArgument("sorted run column directory offsets are reversed".into())
1118    })?;
1119    const MAX_COLUMN_DIR_BYTES: u64 = 64 * 1024 * 1024;
1120    if len > MAX_COLUMN_DIR_BYTES {
1121        return Err(MongrelError::InvalidArgument(format!(
1122            "sorted run column directory length {len} exceeds {MAX_COLUMN_DIR_BYTES}"
1123        )));
1124    }
1125    let mut buf = vec![0u8; len as usize];
1126    file.read_exact(&mut buf)?;
1127    let dir: Vec<ColumnPageHeader> = bincode::deserialize(&buf)
1128        .map_err(|e| MongrelError::InvalidArgument(format!("bad column dir: {e}")))?;
1129    Ok(dir)
1130}
1131
1132fn validate_column_directory_layout(header: &RunHeader, dir: &[ColumnPageHeader]) -> Result<()> {
1133    if header.column_count != dir.len() as u64 || dir.len() > u16::MAX as usize {
1134        return Err(MongrelError::InvalidArgument(
1135            "sorted run column count is invalid".into(),
1136        ));
1137    }
1138    let mut regions = Vec::with_capacity(dir.len());
1139    let mut ids = std::collections::HashSet::new();
1140    for column in dir {
1141        if !ids.insert(column.column_id)
1142            || column.flags & !ColumnPageHeader::PAGE_ENCRYPTED != 0
1143            || column.page_count as usize != column.page_stats.len()
1144        {
1145            return Err(MongrelError::InvalidArgument(
1146                "sorted run column directory identity is invalid".into(),
1147            ));
1148        }
1149        let region_end = column
1150            .page_region_offset
1151            .checked_add(column.page_region_len)
1152            .ok_or_else(|| MongrelError::InvalidArgument("run page region overflows".into()))?;
1153        if column.page_region_offset < RUN_HEADER_PAD as u64
1154            || region_end > header.column_dir_offset
1155        {
1156            return Err(MongrelError::InvalidArgument(
1157                "sorted run page region is outside its body".into(),
1158            ));
1159        }
1160        let mut cursor = column.page_region_offset;
1161        let mut rows = 0_u64;
1162        for stat in &column.page_stats {
1163            let length = stat.compressed_len as u64;
1164            let end = stat
1165                .offset
1166                .checked_add(length)
1167                .ok_or_else(|| MongrelError::InvalidArgument("run page offset overflows".into()))?;
1168            if stat.offset != cursor
1169                || length == 0
1170                || length > MAX_RUN_PAGE_BYTES
1171                || stat.uncompressed_len as u64 > MAX_RUN_PAGE_BYTES
1172                || stat.row_count as usize > PAGE_ROWS
1173                || end > region_end
1174            {
1175                return Err(MongrelError::InvalidArgument(
1176                    "sorted run page metadata is outside its region".into(),
1177                ));
1178            }
1179            rows = rows.checked_add(stat.row_count as u64).ok_or_else(|| {
1180                MongrelError::InvalidArgument("sorted run row count overflows".into())
1181            })?;
1182            cursor = end;
1183        }
1184        if cursor != region_end || rows != header.row_count {
1185            return Err(MongrelError::InvalidArgument(
1186                "sorted run page region length or row count is inconsistent".into(),
1187            ));
1188        }
1189        regions.push((column.page_region_offset, region_end));
1190    }
1191    regions.sort_unstable();
1192    if regions.windows(2).any(|pair| pair[0].1 > pair[1].0) {
1193        return Err(MongrelError::InvalidArgument(
1194            "sorted run page regions overlap".into(),
1195        ));
1196    }
1197    Ok(())
1198}
1199
1200fn read_encryption_descriptor_bytes_from_file(
1201    file: &mut File,
1202    header: &RunHeader,
1203) -> Result<Vec<u8>> {
1204    file.seek(SeekFrom::Start(header.encryption_descriptor_offset))?;
1205    let mut len_buf = [0u8; 4];
1206    file.read_exact(&mut len_buf)?;
1207    let len = u32::from_le_bytes(len_buf) as usize;
1208    // The descriptor is tiny (~60 bytes + a few column entries); clamp to a
1209    // sane ceiling so a corrupt/malicious header can't trigger a multi-GiB
1210    // allocation. (The footer checksum already guards integrity; this is a
1211    // defense-in-depth bound on the pre-checksum read.)
1212    const MAX_DESCRIPTOR_BYTES: usize = 65_536;
1213    if len > MAX_DESCRIPTOR_BYTES {
1214        return Err(MongrelError::InvalidArgument(format!(
1215            "encryption descriptor length {len} exceeds {MAX_DESCRIPTOR_BYTES}"
1216        )));
1217    }
1218    let mut buf = vec![0u8; len];
1219    file.read_exact(&mut buf)?;
1220    Ok(buf)
1221}
1222
1223/// Authenticate an encrypted run's cleartext metadata (`header ‖ dir ‖
1224/// descriptor`) against the keyed MAC tag stored after the footer. Run BEFORE
1225/// any offset/stat from the directory is trusted to drive a read. Errors if the
1226/// tag is missing (a run written before run-metadata MACs existed) or does not
1227/// match (tampering, or the wrong key). The on-disk page payloads are AEAD-
1228/// authenticated separately, so they are not covered here.
1229#[cfg(feature = "encryption")]
1230fn verify_run_mac(
1231    file: &mut File,
1232    header: &RunHeader,
1233    dir: &[ColumnPageHeader],
1234    kek: &Kek,
1235    desc_bytes: &[u8],
1236) -> Result<()> {
1237    let header_bytes = bincode::serialize(header)?;
1238    let dir_bytes = bincode::serialize(dir)?;
1239    let mac_key = kek.derive_run_mac_key();
1240    let expected =
1241        crate::encryption::run_metadata_mac(&mac_key, &header_bytes, &dir_bytes, desc_bytes);
1242    file.seek(SeekFrom::Start(header.footer_offset + 8 + 8 + 32))?;
1243    let mut tag = [0u8; RUN_MAC_LEN];
1244    file.read_exact(&mut tag).map_err(|_| {
1245        MongrelError::Decryption(
1246            "encrypted run is missing or truncated its metadata MAC; cannot \
1247             authenticate metadata"
1248                .into(),
1249        )
1250    })?;
1251    // Constant-time comparison (no early-exit timing oracle on the tag).
1252    let mut diff = 0u8;
1253    for (x, y) in tag.iter().zip(expected.iter()) {
1254        diff |= x ^ y;
1255    }
1256    if diff != 0 {
1257        return Err(MongrelError::Decryption(
1258            "run metadata authentication failed — tampered run or wrong key".into(),
1259        ));
1260    }
1261    Ok(())
1262}
1263
1264#[cfg(not(feature = "encryption"))]
1265fn verify_run_mac(
1266    _file: &mut File,
1267    _header: &RunHeader,
1268    _dir: &[ColumnPageHeader],
1269    _kek: &Kek,
1270    _desc_bytes: &[u8],
1271) -> Result<()> {
1272    Ok(())
1273}
1274
1275// ============================ high-level writer ============================
1276
1277/// Builds and writes a sorted run from drained memtable rows.
1278///
1279/// `rows` must be sorted ascending by `(row_id, epoch)` (the memtable's natural
1280/// drain order). System columns `_row_id`, `_epoch`, `_deleted` are always
1281/// emitted; each user column in `schema` is emitted, with `Null` for rows that
1282/// don't set it.
1283pub struct RunWriter<'a> {
1284    schema: &'a Schema,
1285    run_id: u128,
1286    epoch_created: Epoch,
1287    level: u8,
1288    kek: Option<&'a Kek>,
1289    /// `(column_id, scheme)` for each ENCRYPTED_INDEXABLE column — wrapped into
1290    /// the run's Encryption Descriptor (Phase 10.2).
1291    indexable_columns: Vec<(u16, u8)>,
1292    /// Per-page compression policy (Phase 14.4 / 15.3). Default `Zstd(3)`
1293    /// (compaction); the bulk path uses `Zstd(1)` or `Lz4` (hot, scan-heavy
1294    /// runs), and `Plain` skips compression entirely (`bulk_load_fast`).
1295    compress: columnar::Compress,
1296    /// Whether this run is "clean" (one version per RowId, no tombstones,
1297    /// ascending row_ids) — written into [`RUN_FLAG_CLEAN`] so readers can skip
1298    /// the MVCC visibility pass. Set true only by paths that construct clean
1299    /// system columns by construction (typed bulk load, compaction output).
1300    clean: bool,
1301    /// Whether this run's stored `_epoch` column is a placeholder and its real
1302    /// commit epoch lives in the manifest `RunRef.epoch_created` (set only by the
1303    /// large-transaction spill path, which writes before the epoch is assigned).
1304    /// Stamps [`RUN_FLAG_UNIFORM_EPOCH`] so the reader overlays the real epoch.
1305    uniform_epoch: bool,
1306    /// Write fixed-width page payloads in little-endian (Phase 15.7) so the
1307    /// decode path is a memcpy on real (x86/ARM) hardware instead of a
1308    /// per-element `swap_bytes`. Default **true** for the typed write paths
1309    /// (`write_native`); the legacy `write` (Value) path keeps big-endian for
1310    /// back-compat with pre-15.7 runs. The flag is stored per-page (bit 3 of the
1311    /// algo byte), so a run may freely mix LE and BE pages.
1312    le: bool,
1313}
1314
1315impl<'a> RunWriter<'a> {
1316    pub fn new(schema: &'a Schema, run_id: u128, epoch_created: Epoch, level: u8) -> Self {
1317        Self {
1318            schema,
1319            run_id,
1320            epoch_created,
1321            level,
1322            kek: None,
1323            indexable_columns: Vec::new(),
1324            compress: columnar::Compress::Zstd(3),
1325            clean: false,
1326            uniform_epoch: false,
1327            le: false,
1328        }
1329    }
1330
1331    /// Mark this run as uniform-epoch: its stored `_epoch` column is a
1332    /// placeholder and the real commit epoch is supplied at read time from the
1333    /// manifest `RunRef.epoch_created` (see [`RUN_FLAG_UNIFORM_EPOCH`]). Used by
1334    /// the large-transaction spill path, which writes the run before the commit
1335    /// epoch is assigned.
1336    pub fn uniform_epoch(mut self, uniform: bool) -> Self {
1337        self.uniform_epoch = uniform;
1338        self
1339    }
1340
1341    /// Encrypt this run's pages with a fresh per-file DEK wrapped by `kek`.
1342    /// `indexable_columns` are the ENCRYPTED_INDEXABLE `(column_id, scheme)`
1343    /// pairs whose column keys are derived+wrapped into the descriptor.
1344    pub fn with_encryption(mut self, kek: &'a Kek, indexable_columns: Vec<(u16, u8)>) -> Self {
1345        self.kek = Some(kek);
1346        self.indexable_columns = indexable_columns;
1347        self
1348    }
1349
1350    /// Override the zstd level for this run's pages (Phase 14.4). The bulk
1351    /// ingest path uses level 1; background compaction upgrades cold runs to 3.
1352    pub fn with_zstd_level(mut self, level: i32) -> Self {
1353        self.compress = columnar::Compress::Zstd(level);
1354        self
1355    }
1356
1357    /// Compress hot/mutable-run pages with LZ4 (Phase 15.3): 3–5× faster decode
1358    /// than zstd with ~10% worse ratio. The right default for runs that get
1359    /// scanned (bulk-loaded analytical runs).
1360    pub fn with_lz4(mut self) -> Self {
1361        self.compress = columnar::Compress::Lz4;
1362        self
1363    }
1364
1365    /// Emit raw `ALGO_PLAIN` pages with no compression (Phase 14.4
1366    /// `bulk_load_fast`): maximal encode throughput at the cost of ~3–4× size.
1367    pub fn with_plain(mut self) -> Self {
1368        self.compress = columnar::Compress::Plain;
1369        self
1370    }
1371
1372    /// Mark this run as "clean" (one version per RowId, no tombstones, ascending
1373    /// row_ids). Only set when the caller constructs the system columns so by
1374    /// construction (typed bulk load of fresh, contiguous row_ids; compaction
1375    /// output that has collapsed versions and dropped tombstones). Stamps
1376    /// [`RUN_FLAG_CLEAN`] into the header so readers skip the MVCC pass.
1377    pub fn clean(mut self, clean: bool) -> Self {
1378        self.clean = clean;
1379        self
1380    }
1381
1382    /// Write fixed-width page payloads in little-endian (Phase 15.7): the decode
1383    /// path becomes a memcpy on little-endian targets. Only effective on the
1384    /// typed `write_native` path; no-op on big-endian writers (they keep the
1385    /// portable BE layout, since "native" would not be LE there).
1386    pub fn with_native_endian(mut self) -> Self {
1387        if cfg!(target_endian = "little") {
1388            self.le = true;
1389        }
1390        self
1391    }
1392
1393    /// Write a run straight from typed columns (no `Value`). `user_columns` are
1394    /// the schema's user columns as [`NativeColumn`]s; the system columns
1395    /// (`_row_id`/`_epoch`/`_deleted`) are built from `first_row_id..+n` /
1396    /// `epoch_created` / all-false. Sorted Int64 columns (always the system
1397    /// `_row_id`, plus any sorted user Int64) use delta encoding unless
1398    /// [`RunWriter::with_plain`] forces raw `ALGO_PLAIN` everywhere.
1399    pub fn write_native(
1400        self,
1401        path: impl AsRef<Path>,
1402        user_columns: &[(u16, columnar::NativeColumn)],
1403        n: usize,
1404        first_row_id: u64,
1405    ) -> Result<RunHeader> {
1406        self.write_native_target(Some(path.as_ref()), None, user_columns, n, first_row_id)
1407    }
1408
1409    pub(crate) fn write_native_file(
1410        self,
1411        file: File,
1412        user_columns: &[(u16, columnar::NativeColumn)],
1413        n: usize,
1414        first_row_id: u64,
1415    ) -> Result<RunHeader> {
1416        self.write_native_target(None, Some(file), user_columns, n, first_row_id)
1417    }
1418
1419    fn write_native_target(
1420        self,
1421        path: Option<&Path>,
1422        file: Option<File>,
1423        user_columns: &[(u16, columnar::NativeColumn)],
1424        n: usize,
1425        first_row_id: u64,
1426    ) -> Result<RunHeader> {
1427        use columnar::NativeColumn;
1428        let row_id_col = NativeColumn::int64_sequence(first_row_id as i64, n);
1429        let epoch_col = NativeColumn::int64_constant(self.epoch_created.0 as i64, n);
1430        let deleted_col = NativeColumn::bool_constant(false, n);
1431
1432        let learned_trailer = build_learned_trailer_native(&row_id_col);
1433
1434        // All columns split on the same row-position boundaries so a reader can
1435        // skip whole pages of any column via its [min,max] stat.
1436        let row_ids = match &row_id_col {
1437            NativeColumn::Int64 { data, .. } => data.as_slice(),
1438            _ => &[],
1439        };
1440        let bounds = page_bounds(row_ids);
1441        let compress = self.compress;
1442        let le = self.le;
1443        let plain = matches!(compress, columnar::Compress::Plain);
1444        // In plain mode every column is `Encoding::Plain` (raw); otherwise keep
1445        // the chosen encoding (Delta for the sorted _row_id, Zstd for the rest).
1446        let row_id_enc = if plain {
1447            Encoding::Plain
1448        } else {
1449            Encoding::Delta
1450        };
1451        let sys_enc = if plain {
1452            Encoding::Plain
1453        } else {
1454            Encoding::Zstd
1455        };
1456
1457        let mut columns: Vec<ColumnPayload> = Vec::with_capacity(3 + self.schema.columns.len());
1458        let (pages, stats) = native_column_pages(
1459            TypeId::Int64,
1460            &row_id_col,
1461            row_id_enc,
1462            compress,
1463            le,
1464            &bounds,
1465        )?;
1466        columns.push(ColumnPayload {
1467            column_id: SYS_ROW_ID,
1468            type_id_tag: type_tag(&TypeId::Int64),
1469            encoding: row_id_enc,
1470            pages,
1471            page_stats: stats,
1472        });
1473        let (pages, stats) =
1474            native_column_pages(TypeId::Int64, &epoch_col, sys_enc, compress, le, &bounds)?;
1475        columns.push(ColumnPayload {
1476            column_id: SYS_EPOCH,
1477            type_id_tag: type_tag(&TypeId::Int64),
1478            encoding: sys_enc,
1479            pages,
1480            page_stats: stats,
1481        });
1482        let (pages, stats) =
1483            native_column_pages(TypeId::Bool, &deleted_col, sys_enc, compress, le, &bounds)?;
1484        columns.push(ColumnPayload {
1485            column_id: SYS_DELETED,
1486            type_id_tag: type_tag(&TypeId::Bool),
1487            encoding: sys_enc,
1488            pages,
1489            page_stats: stats,
1490        });
1491        // Encode all user columns in parallel (Phase 14.3): each column's pages
1492        // are independent, and the page-level work is itself parallel, so a
1493        // wide table saturates the pool without oversubscribing (rayon
1494        // work-steals across the nested parallelism). Order is preserved so the
1495        // column directory matches `schema.columns` order.
1496        use rayon::prelude::*;
1497        let user_cols: Vec<ColumnPayload> = self
1498            .schema
1499            .columns
1500            .par_iter()
1501            .map(|cdef| -> Result<ColumnPayload> {
1502                let col = user_columns
1503                    .iter()
1504                    .find(|(id, _)| *id == cdef.id)
1505                    .map(|(_, c)| c)
1506                    .ok_or_else(|| {
1507                        MongrelError::ColumnNotFound(format!("user column {}", cdef.id))
1508                    })?;
1509                let encoding = if plain {
1510                    Encoding::Plain
1511                } else {
1512                    choose_encoding_native(&cdef.ty, col)
1513                };
1514                let (pages, stats) =
1515                    native_column_pages(cdef.ty.clone(), col, encoding, compress, le, &bounds)?;
1516                Ok(ColumnPayload {
1517                    column_id: cdef.id,
1518                    type_id_tag: type_tag(&cdef.ty),
1519                    encoding,
1520                    pages,
1521                    page_stats: stats,
1522                })
1523            })
1524            .collect::<Result<Vec<_>>>()?;
1525        columns.extend(user_cols);
1526
1527        // A uniform-epoch run's `_epoch` column is a placeholder (the real commit
1528        // epoch is overlaid from the RunRef at read time), so it must NOT be
1529        // marked clean — the clean fast path skips snapshot gating — and must
1530        // carry the overlay flag. write_native is not the spill path today, but
1531        // keep it sound if it ever is.
1532        let flags = if self.uniform_epoch {
1533            RUN_FLAG_UNIFORM_EPOCH
1534        } else {
1535            RUN_FLAG_CLEAN
1536        };
1537        let spec = RunSpec {
1538            run_id: self.run_id,
1539            schema_id: self.schema.schema_id,
1540            epoch_created: self.epoch_created.0,
1541            level: self.level,
1542            flags,
1543            sort_key_column_id: SYS_ROW_ID,
1544            row_count: n as u64,
1545            min_row_id: first_row_id,
1546            max_row_id: first_row_id + n as u64 - 1,
1547            columns: &columns,
1548        };
1549        match file {
1550            Some(file) => write_run_with_file(
1551                file,
1552                &spec,
1553                self.kek,
1554                &self.indexable_columns,
1555                Some(&learned_trailer),
1556            ),
1557            None => write_run_with(
1558                path.ok_or_else(|| {
1559                    MongrelError::InvalidArgument("sorted run output is missing".into())
1560                })?,
1561                &spec,
1562                self.kek,
1563                &self.indexable_columns,
1564                Some(&learned_trailer),
1565            ),
1566        }
1567    }
1568
1569    pub fn write(self, path: impl AsRef<Path>, rows: &[Row]) -> Result<RunHeader> {
1570        self.write_target(Some(path.as_ref()), None, rows)
1571    }
1572
1573    pub(crate) fn write_file(self, file: File, rows: &[Row]) -> Result<RunHeader> {
1574        self.write_target(None, Some(file), rows)
1575    }
1576
1577    fn write_target(
1578        self,
1579        path: Option<&Path>,
1580        file: Option<File>,
1581        rows: &[Row],
1582    ) -> Result<RunHeader> {
1583        let n = rows.len();
1584        // System columns.
1585        let mut row_ids = Vec::with_capacity(n);
1586        let mut epochs = Vec::with_capacity(n);
1587        let mut deleted = Vec::with_capacity(n);
1588        for r in rows {
1589            row_ids.push(Value::Int64(r.row_id.0 as i64));
1590            epochs.push(Value::Int64(r.committed_epoch.0 as i64));
1591            deleted.push(Value::Bool(r.deleted));
1592        }
1593        let learned_trailer = build_learned_trailer(&row_ids);
1594        let (min_rid, max_rid) = row_id_bounds(rows);
1595        let row_id_i64: Vec<i64> = rows.iter().map(|r| r.row_id.0 as i64).collect();
1596        let bounds = page_bounds(&row_id_i64);
1597
1598        let mut columns: Vec<ColumnPayload> = Vec::with_capacity(3 + self.schema.columns.len());
1599        let (pages, stats, enc) = value_column_pages(TypeId::Int64, &row_ids, &bounds)?;
1600        columns.push(ColumnPayload {
1601            column_id: SYS_ROW_ID,
1602            type_id_tag: type_tag(&TypeId::Int64),
1603            encoding: enc,
1604            pages,
1605            page_stats: stats,
1606        });
1607        let (pages, stats, enc) = value_column_pages(TypeId::Int64, &epochs, &bounds)?;
1608        columns.push(ColumnPayload {
1609            column_id: SYS_EPOCH,
1610            type_id_tag: type_tag(&TypeId::Int64),
1611            encoding: enc,
1612            pages,
1613            page_stats: stats,
1614        });
1615        let (pages, stats, enc) = value_column_pages(TypeId::Bool, &deleted, &bounds)?;
1616        columns.push(ColumnPayload {
1617            column_id: SYS_DELETED,
1618            type_id_tag: type_tag(&TypeId::Bool),
1619            encoding: enc,
1620            pages,
1621            page_stats: stats,
1622        });
1623        // User columns — choose an encoding per column from run-time stats.
1624        for cdef in &self.schema.columns {
1625            let vals: Vec<Value> = rows
1626                .iter()
1627                .map(|r| r.columns.get(&cdef.id).cloned().unwrap_or(Value::Null))
1628                .collect();
1629            let (pages, stats, encoding) = value_column_pages(cdef.ty.clone(), &vals, &bounds)?;
1630            columns.push(ColumnPayload {
1631                column_id: cdef.id,
1632                type_id_tag: type_tag(&cdef.ty),
1633                encoding,
1634                pages,
1635                page_stats: stats,
1636            });
1637        }
1638
1639        let spec = RunSpec {
1640            run_id: self.run_id,
1641            schema_id: self.schema.schema_id,
1642            epoch_created: self.epoch_created.0,
1643            level: self.level,
1644            flags: {
1645                let mut f = if self.clean { RUN_FLAG_CLEAN } else { 0 };
1646                if self.uniform_epoch {
1647                    f |= RUN_FLAG_UNIFORM_EPOCH;
1648                }
1649                f
1650            },
1651            sort_key_column_id: SYS_ROW_ID,
1652            row_count: n as u64,
1653            min_row_id: min_rid,
1654            max_row_id: max_rid,
1655            columns: &columns,
1656        };
1657        match file {
1658            Some(file) => write_run_with_file(
1659                file,
1660                &spec,
1661                self.kek,
1662                &self.indexable_columns,
1663                Some(&learned_trailer),
1664            ),
1665            None => write_run_with(
1666                path.ok_or_else(|| {
1667                    MongrelError::InvalidArgument("sorted run output is missing".into())
1668                })?,
1669                &spec,
1670                self.kek,
1671                &self.indexable_columns,
1672                Some(&learned_trailer),
1673            ),
1674        }
1675    }
1676}
1677
1678fn type_tag(ty: &TypeId) -> u16 {
1679    // Informational only; the reader resolves the full type from the schema.
1680    match ty {
1681        TypeId::Bool => 1,
1682        TypeId::Int64 => 8,
1683        TypeId::Float64 => 9,
1684        TypeId::Bytes => 12,
1685        TypeId::Embedding { .. } => 13,
1686        _ => 0,
1687    }
1688}
1689
1690/// Decode a big-endian i64 from a `PageStat` min/max slot (None if absent or
1691/// truncated — treated as "all-null page" by the caller).
1692pub(crate) fn be_i64(b: Option<&[u8]>) -> Option<i64> {
1693    let b = b?;
1694    (b.len() == 8).then(|| i64::from_be_bytes(b.try_into().unwrap()))
1695}
1696
1697/// Decode a big-endian f64 (stored as `to_bits`) from a stat slot.
1698pub(crate) fn be_f64(b: Option<&[u8]>) -> Option<f64> {
1699    let b = b?;
1700    (b.len() == 8).then(|| f64::from_bits(u64::from_be_bytes(b.try_into().unwrap())))
1701}
1702
1703/// Rows per columnar page. Small enough to prune effectively, large enough to
1704/// keep per-page overhead negligible. ~16 pages per 1M-row column.
1705const PAGE_ROWS: usize = 65_536;
1706
1707/// `(start, end, first_row_id, last_row_id)` for each page, derived from the
1708/// actual row-id column so flush paths with non-contiguous ids stay correct.
1709fn page_bounds(row_ids: &[i64]) -> Vec<(usize, usize, u64, u64)> {
1710    let n = row_ids.len();
1711    if n == 0 {
1712        return vec![(0, 0, 0, 0)];
1713    }
1714    let mut out = Vec::new();
1715    let mut start = 0;
1716    while start < n {
1717        let end = (start + PAGE_ROWS).min(n);
1718        out.push((start, end, row_ids[start] as u64, row_ids[end - 1] as u64));
1719        start = end;
1720    }
1721    out
1722}
1723
1724/// Split a typed column into per-page encoded bytes + value-derived stats.
1725/// Pages are encoded in parallel across the rayon pool when a column spans more
1726/// than one page (Phase 14.3) — a 1M-row column has 16 independent encode tasks.
1727/// `compress` selects the page algorithm (Phase 14.4 / 15.3). Order is preserved
1728/// so the column directory stays sequential by page_seq.
1729fn native_column_pages(
1730    ty: TypeId,
1731    col: &columnar::NativeColumn,
1732    encoding: Encoding,
1733    compress: columnar::Compress,
1734    le: bool,
1735    bounds: &[(usize, usize, u64, u64)],
1736) -> Result<(Vec<Vec<u8>>, Vec<PageStat>)> {
1737    use rayon::prelude::*;
1738    let encode_one =
1739        |&(s, e, frid, lrid): &(usize, usize, u64, u64)| -> Result<(Vec<u8>, PageStat)> {
1740            let chunk = col.slice_range(s, e);
1741            let stat = columnar::page_stat_for(ty.clone(), &chunk, frid, lrid);
1742            let page = columnar::encode_page_native(ty.clone(), &chunk, encoding, compress, le)?;
1743            Ok((page, stat))
1744        };
1745    // Single-page columns skip the thread-pool handshake.
1746    let pairs: Vec<(Vec<u8>, PageStat)> = if bounds.len() > 1 {
1747        bounds
1748            .par_iter()
1749            .map(encode_one)
1750            .collect::<Result<Vec<_>>>()?
1751    } else {
1752        bounds.iter().map(encode_one).collect::<Result<Vec<_>>>()?
1753    };
1754    let (pages, stats) = pairs.into_iter().unzip();
1755    Ok((pages, stats))
1756}
1757
1758/// Split a `Value` column into per-page encoded bytes + stats (flush path).
1759fn value_column_pages(
1760    ty: TypeId,
1761    vals: &[Value],
1762    bounds: &[(usize, usize, u64, u64)],
1763) -> Result<(Vec<Vec<u8>>, Vec<PageStat>, Encoding)> {
1764    let encoding = choose_encoding(&ty, vals);
1765    let mut pages = Vec::with_capacity(bounds.len());
1766    let mut stats = Vec::with_capacity(bounds.len());
1767    for &(s, e, frid, lrid) in bounds {
1768        let chunk = &vals[s..e];
1769        pages.push(columnar::encode_page(ty.clone(), chunk, encoding)?);
1770        let native = columnar::values_to_native(ty.clone(), chunk);
1771        stats.push(columnar::page_stat_for(ty.clone(), &native, frid, lrid));
1772    }
1773    Ok((pages, stats, encoding))
1774}
1775
1776/// Pick a page encoding from run-time stats: dictionary for low-cardinality
1777/// strings, zstd-plain otherwise.
1778fn choose_encoding(ty: &TypeId, values: &[Value]) -> Encoding {
1779    use std::collections::HashSet;
1780    if matches!(ty, TypeId::Bytes) {
1781        let n = values.len();
1782        if n > 0 {
1783            let distinct = values
1784                .iter()
1785                .filter(|v| !matches!(v, Value::Null))
1786                .map(|v| v.encode_key())
1787                .collect::<HashSet<_>>()
1788                .len();
1789            if (distinct as f64 / n as f64) < 0.5 {
1790                return Encoding::Dictionary;
1791            }
1792        }
1793    }
1794    Encoding::Zstd
1795}
1796
1797/// Encoding choice for the typed (native) path: delta for sorted Int64,
1798/// dictionary for low-cardinality Bytes, zstd otherwise.
1799fn choose_encoding_native(ty: &TypeId, col: &columnar::NativeColumn) -> Encoding {
1800    use std::collections::HashSet;
1801    match (ty, col) {
1802        (TypeId::Int64 | TypeId::TimestampNanos, columnar::NativeColumn::Int64 { data, .. }) => {
1803            if data.windows(2).all(|w| w[0] <= w[1]) {
1804                Encoding::Delta
1805            } else {
1806                Encoding::Zstd
1807            }
1808        }
1809        (
1810            TypeId::Bytes,
1811            columnar::NativeColumn::Bytes {
1812                offsets, values, ..
1813            },
1814        ) => {
1815            let n = offsets.len().saturating_sub(1);
1816            if n > 0 {
1817                let distinct: HashSet<&[u8]> = (0..n)
1818                    .map(|i| &values[offsets[i] as usize..offsets[i + 1] as usize])
1819                    .collect();
1820                if (distinct.len() as f64 / n as f64) < 0.5 {
1821                    return Encoding::Dictionary;
1822                }
1823            }
1824            Encoding::Zstd
1825        }
1826        _ => Encoding::Zstd,
1827    }
1828}
1829
1830/// PGM-index trailer built straight from the typed row-id column.
1831fn build_learned_trailer_native(col: &columnar::NativeColumn) -> Vec<u8> {
1832    let points: Vec<(u64, usize)> = match col {
1833        columnar::NativeColumn::Int64 { data, .. } => data
1834            .iter()
1835            .enumerate()
1836            .map(|(i, v)| (*v as u64, i))
1837            .collect(),
1838        _ => Vec::new(),
1839    };
1840    let pgm = PgmIndex::build(&points, LEARNED_EPSILON);
1841    bincode::serialize(&pgm).expect("pgm serialize")
1842}
1843
1844fn row_id_bounds(rows: &[Row]) -> (u64, u64) {
1845    match (rows.first(), rows.last()) {
1846        (Some(f), Some(l)) => (f.row_id.0, l.row_id.0),
1847        _ => (0, 0),
1848    }
1849}
1850
1851// ============================ high-level reader ============================
1852
1853/// Reads a sorted run: decodes columns lazily (cached), answers MVCC point
1854/// lookups via page-pruned `SYS_ROW_ID` bounds, and materializes visible rows
1855/// for scans.
1856pub struct RunReader {
1857    file: File,
1858    mmap: Option<memmap2::Mmap>,
1859    header: RunHeader,
1860    dir: Vec<ColumnPageHeader>,
1861    schema: Schema,
1862    /// Owning table id — namespaces the shared page cache across tables.
1863    table_id: u64,
1864    /// Per-run page cipher, built from the unwrapped DEK (None when plaintext).
1865    cipher: Option<Box<dyn Cipher>>,
1866    /// Per-run nonce prefix (overlaid per page with column_id + page_seq).
1867    nonce_prefix: [u8; 12],
1868    col_cache: HashMap<u16, Vec<Value>>,
1869    /// Shared, MVCC content-addressed page cache (Phase 9.2). Caches raw page
1870    /// bytes (ciphertext when encrypted) so all readers share decoded/decrypted
1871    /// pages. `None` only in standalone tests.
1872    page_cache: Option<Arc<crate::cache::Sharded<crate::cache::PageCache>>>,
1873    /// Shared decoded-page cache (Phase 15.4): the post-decompress/decrypt typed
1874    /// page, so a repeat scan skips decode. Keyed by `(run_id, column_id,
1875    /// page_seq)` identity; `None` in standalone tests.
1876    decoded_cache: Option<Arc<crate::cache::Sharded<crate::cache::DecodedPageCache>>>,
1877    /// Uniform-epoch overlay (see [`RUN_FLAG_UNIFORM_EPOCH`]). When `Some`, every
1878    /// row's commit epoch is taken to be this value instead of the placeholder
1879    /// stored in the `_epoch` column. Set by [`Self::set_uniform_epoch`].
1880    epoch_override: Option<Epoch>,
1881}
1882
1883#[derive(Debug, Clone, Copy)]
1884pub(crate) struct RunVisibleVersion {
1885    pub(crate) row_id: RowId,
1886    pub(crate) committed_epoch: Epoch,
1887    pub(crate) deleted: bool,
1888    page_seq: usize,
1889    within_page: usize,
1890}
1891
1892/// Page-bounded cursor over one run's newest snapshot-visible version per row.
1893/// Only the three compact system columns and one user-data page are decoded at
1894/// a time. Full `Vec<Row>` materialization is deliberately avoided.
1895pub(crate) struct RunVisibleVersionCursor {
1896    reader: RunReader,
1897    snapshot: Epoch,
1898    page_row_counts: Vec<usize>,
1899    page_seq: usize,
1900    within_page: usize,
1901    row_ids: Vec<i64>,
1902    epochs: Vec<i64>,
1903    deleted: Vec<u8>,
1904    lookahead: Option<RunVisibleVersion>,
1905    materialized_page: Option<usize>,
1906    materialized_columns: Vec<(u16, columnar::NativeColumn)>,
1907}
1908
1909impl RunVisibleVersionCursor {
1910    fn new(reader: RunReader, snapshot: Epoch) -> Result<Self> {
1911        let page_row_counts = reader.page_row_counts(SYS_ROW_ID)?;
1912        Ok(Self {
1913            reader,
1914            snapshot,
1915            page_row_counts,
1916            page_seq: 0,
1917            within_page: 0,
1918            row_ids: Vec::new(),
1919            epochs: Vec::new(),
1920            deleted: Vec::new(),
1921            lookahead: None,
1922            materialized_page: None,
1923            materialized_columns: Vec::new(),
1924        })
1925    }
1926
1927    fn load_system_page(&mut self, control: &crate::ExecutionControl) -> Result<bool> {
1928        while self.page_seq < self.page_row_counts.len() {
1929            control.checkpoint()?;
1930            let rows = self.page_row_counts[self.page_seq];
1931            if rows == 0 {
1932                self.page_seq += 1;
1933                continue;
1934            }
1935            self.row_ids = match columnar::decode_page_native(
1936                TypeId::Int64,
1937                &self.reader.read_page(SYS_ROW_ID, self.page_seq)?,
1938                rows,
1939            )? {
1940                columnar::NativeColumn::Int64 { data, .. } => data,
1941                _ => return Err(MongrelError::InvalidArgument("sys row_id not int64".into())),
1942            };
1943            self.epochs = if let Some(epoch) = self.reader.epoch_override {
1944                vec![epoch.0 as i64; rows]
1945            } else {
1946                match columnar::decode_page_native(
1947                    TypeId::Int64,
1948                    &self.reader.read_page(SYS_EPOCH, self.page_seq)?,
1949                    rows,
1950                )? {
1951                    columnar::NativeColumn::Int64 { data, .. } => data,
1952                    _ => return Err(MongrelError::InvalidArgument("sys epoch not int64".into())),
1953                }
1954            };
1955            self.deleted = match columnar::decode_page_native(
1956                TypeId::Bool,
1957                &self.reader.read_page(SYS_DELETED, self.page_seq)?,
1958                rows,
1959            )? {
1960                columnar::NativeColumn::Bool { data, .. } => data,
1961                _ => return Err(MongrelError::InvalidArgument("sys deleted not bool".into())),
1962            };
1963            self.within_page = 0;
1964            return Ok(true);
1965        }
1966        Ok(false)
1967    }
1968
1969    fn next_raw(&mut self, control: &crate::ExecutionControl) -> Result<Option<RunVisibleVersion>> {
1970        if self.within_page >= self.row_ids.len() {
1971            if !self.row_ids.is_empty() {
1972                self.page_seq += 1;
1973                self.row_ids.clear();
1974                self.epochs.clear();
1975                self.deleted.clear();
1976            }
1977            if !self.load_system_page(control)? {
1978                return Ok(None);
1979            }
1980        }
1981        if self.within_page.is_multiple_of(256) {
1982            control.checkpoint()?;
1983        }
1984        let position = self.within_page;
1985        self.within_page += 1;
1986        Ok(Some(RunVisibleVersion {
1987            row_id: RowId(self.row_ids[position] as u64),
1988            committed_epoch: Epoch(self.epochs[position] as u64),
1989            deleted: self.deleted[position] != 0,
1990            page_seq: self.page_seq,
1991            within_page: position,
1992        }))
1993    }
1994
1995    pub(crate) fn next_visible_version(
1996        &mut self,
1997        control: &crate::ExecutionControl,
1998    ) -> Result<Option<RunVisibleVersion>> {
1999        loop {
2000            let first = match self.lookahead.take() {
2001                Some(version) => version,
2002                None => match self.next_raw(control)? {
2003                    Some(version) => version,
2004                    None => return Ok(None),
2005                },
2006            };
2007            let row_id = first.row_id;
2008            let mut best = (first.committed_epoch <= self.snapshot).then_some(first);
2009            while let Some(candidate) = self.next_raw(control)? {
2010                if candidate.row_id != row_id {
2011                    self.lookahead = Some(candidate);
2012                    break;
2013                }
2014                if candidate.committed_epoch <= self.snapshot
2015                    && best
2016                        .is_none_or(|current| candidate.committed_epoch > current.committed_epoch)
2017                {
2018                    best = Some(candidate);
2019                }
2020            }
2021            if best.is_some() {
2022                return Ok(best);
2023            }
2024        }
2025    }
2026
2027    pub(crate) fn materialize(
2028        &mut self,
2029        version: RunVisibleVersion,
2030        control: &crate::ExecutionControl,
2031    ) -> Result<Row> {
2032        if self.materialized_page != Some(version.page_seq) {
2033            let rows = self.page_row_counts[version.page_seq];
2034            let columns = self.reader.schema.columns.clone();
2035            let mut materialized = Vec::with_capacity(columns.len());
2036            for (index, column) in columns.into_iter().enumerate() {
2037                if index % 16 == 0 {
2038                    control.checkpoint()?;
2039                }
2040                let native = if self.reader.has_column(column.id) {
2041                    columnar::decode_page_native(
2042                        column.ty,
2043                        &self.reader.read_page(column.id, version.page_seq)?,
2044                        rows,
2045                    )?
2046                } else {
2047                    columnar::null_native(column.ty, rows)
2048                };
2049                materialized.push((column.id, native));
2050            }
2051            self.materialized_columns = materialized;
2052            self.materialized_page = Some(version.page_seq);
2053        }
2054        let columns = self
2055            .materialized_columns
2056            .iter()
2057            .map(|(column_id, column)| {
2058                (
2059                    *column_id,
2060                    column.value_at(version.within_page).unwrap_or(Value::Null),
2061                )
2062            })
2063            .collect();
2064        Ok(Row {
2065            row_id: version.row_id,
2066            committed_epoch: version.committed_epoch,
2067            columns,
2068            deleted: version.deleted,
2069        })
2070    }
2071}
2072
2073impl RunReader {
2074    pub fn open(path: impl AsRef<Path>, schema: Schema, kek: Option<Arc<Kek>>) -> Result<Self> {
2075        Self::open_with_cache(path, schema, kek, None, None, 0, None)
2076    }
2077
2078    pub(crate) fn open_file(file: File, schema: Schema, kek: Option<Arc<Kek>>) -> Result<Self> {
2079        Self::open_file_with_cache(file, schema, kek, None, None, 0, None, None)
2080    }
2081
2082    #[allow(clippy::too_many_arguments)]
2083    pub(crate) fn open_with_cache(
2084        path: impl AsRef<Path>,
2085        schema: Schema,
2086        kek: Option<Arc<Kek>>,
2087        page_cache: Option<Arc<crate::cache::Sharded<crate::cache::PageCache>>>,
2088        decoded_cache: Option<Arc<crate::cache::Sharded<crate::cache::DecodedPageCache>>>,
2089        table_id: u64,
2090        verified_runs: Option<&parking_lot::Mutex<std::collections::HashSet<u128>>>,
2091    ) -> Result<Self> {
2092        let path = path.as_ref().to_path_buf();
2093        let file = crate::durable_file::open_regular_nofollow(&path)?;
2094        Self::open_file_with_cache(
2095            file,
2096            schema,
2097            kek,
2098            page_cache,
2099            decoded_cache,
2100            table_id,
2101            verified_runs,
2102            Some(path),
2103        )
2104    }
2105
2106    #[allow(clippy::too_many_arguments)]
2107    pub(crate) fn open_file_with_cache(
2108        mut file: File,
2109        schema: Schema,
2110        kek: Option<Arc<Kek>>,
2111        page_cache: Option<Arc<crate::cache::Sharded<crate::cache::PageCache>>>,
2112        decoded_cache: Option<Arc<crate::cache::Sharded<crate::cache::DecodedPageCache>>>,
2113        table_id: u64,
2114        verified_runs: Option<&parking_lot::Mutex<std::collections::HashSet<u128>>>,
2115        path: Option<PathBuf>,
2116    ) -> Result<Self> {
2117        let header = match verified_runs {
2118            Some(cache) => {
2119                let header = read_header_fast_from_file(&mut file)?;
2120                if cache.lock().contains(&header.run_id) {
2121                    header
2122                } else {
2123                    let verified = read_header_from_file(&mut file)?;
2124                    cache.lock().insert(verified.run_id);
2125                    verified
2126                }
2127            }
2128            None => read_header_from_file(&mut file)?,
2129        };
2130        let mut dir = read_column_dir_from_file(&mut file, &header)?;
2131        validate_column_directory_layout(&header, &dir)?;
2132        if header.is_encrypted() != kek.is_some() {
2133            return Err(MongrelError::Encryption(
2134                "sorted-run encryption mode differs from the database".into(),
2135            ));
2136        }
2137        // Unwrap this run's per-file DEK (stored wrapped in its Encryption
2138        // Descriptor) using the table KEK, then build the page cipher.
2139        let (cipher, nonce_prefix): (Option<Box<dyn Cipher>>, [u8; 12]) = if header.is_encrypted() {
2140            let kek = kek.as_ref().ok_or_else(|| {
2141                MongrelError::Encryption(
2142                    "run is encrypted but no key-encryption key was provided".into(),
2143                )
2144            })?;
2145            if header.encryption_descriptor_offset == 0 {
2146                return Err(MongrelError::Encryption(
2147                    "encrypted run has no encryption descriptor".into(),
2148                ));
2149            }
2150            let desc_bytes = read_encryption_descriptor_bytes_from_file(&mut file, &header)?;
2151            // Authenticate the cleartext metadata (header‖dir‖descriptor) under
2152            // the KEK-derived MAC key BEFORE trusting any offset/stat to drive a
2153            // read. Required for every encrypted run (no downgrade path: an
2154            // attacker can neither strip the encryption — pages stay ciphertext —
2155            // nor forge the tag without the key).
2156            verify_run_mac(&mut file, &header, &dir, kek, &desc_bytes)?;
2157            let enc = crate::encryption::build_run_cipher(kek, &desc_bytes)?;
2158            // With the metadata authenticated, decrypt the per-page min/max
2159            // envelope (v2 runs) and overlay it so zone-map pruning works on
2160            // encrypted columns exactly as it does on plaintext ones.
2161            if header.encrypted_stats_offset != 0 {
2162                overlay_encrypted_stats(
2163                    &mut file,
2164                    &header,
2165                    enc.cipher.as_ref(),
2166                    enc.nonce_prefix,
2167                    &mut dir,
2168                )?;
2169            }
2170            (Some(enc.cipher), enc.nonce_prefix)
2171        } else {
2172            (None, [0u8; 12])
2173        };
2174        // Best-effort memory map: lets the OS page cache manage I/O and removes
2175        // per-page seek+read syscalls. Falls back to read() on empty/unmappable
2176        // files. The `file` handle is kept for the lifetime of the mapping.
2177        // (Per-column `MADV_WILLNEED` read-ahead is issued from
2178        // `column_native_shared` — see Phase 15.2 — so a global advice policy is
2179        // not set here; that would degrade concurrent point lookups.)
2180        let mmap = unsafe { memmap2::MmapOptions::new().map(&file) }.ok();
2181        let _ = path;
2182        Ok(Self {
2183            file,
2184            mmap,
2185            header,
2186            dir,
2187            schema,
2188            table_id,
2189            cipher,
2190            nonce_prefix,
2191            col_cache: HashMap::new(),
2192            epoch_override: None,
2193            page_cache,
2194            decoded_cache,
2195        })
2196    }
2197
2198    pub fn header(&self) -> &RunHeader {
2199        &self.header
2200    }
2201
2202    pub(crate) fn validate_all_pages(&mut self) -> Result<()> {
2203        let mut columns = std::collections::HashSet::new();
2204        let row_header = self.find_header(SYS_ROW_ID)?.clone();
2205        let expected_rows = row_header
2206            .page_stats
2207            .iter()
2208            .map(|stat| stat.row_count)
2209            .collect::<Vec<_>>();
2210        let mut row_bounds = Vec::with_capacity(row_header.page_stats.len());
2211        let mut previous = None::<u64>;
2212        let mut first = None::<u64>;
2213        let mut last = None::<u64>;
2214        let mut counted = 0_u64;
2215        for (page, stat) in row_header.page_stats.iter().enumerate() {
2216            let bytes = self.read_page(SYS_ROW_ID, page)?;
2217            let native =
2218                columnar::decode_page_native(TypeId::Int64, &bytes, stat.row_count as usize)?;
2219            if columnar::page_stat_for(TypeId::Int64, &native, 0, 0).null_count != 0 {
2220                return Err(MongrelError::InvalidArgument(
2221                    "sorted run row-id page contains nulls".into(),
2222                ));
2223            }
2224            let columnar::NativeColumn::Int64 { data, validity } = native else {
2225                return Err(MongrelError::InvalidArgument(
2226                    "sorted run row-id page has the wrong type".into(),
2227                ));
2228            };
2229            let _ = validity;
2230            if data.is_empty() {
2231                if self.header.row_count != 0 || stat.row_count != 0 {
2232                    return Err(MongrelError::InvalidArgument(
2233                        "sorted run row-id page is unexpectedly empty".into(),
2234                    ));
2235                }
2236                row_bounds.push((0, 0));
2237                continue;
2238            }
2239            if data.iter().any(|value| *value < 0) {
2240                return Err(MongrelError::InvalidArgument(
2241                    "sorted run contains a negative row id".into(),
2242                ));
2243            }
2244            let page_first = data[0] as u64;
2245            let page_last = data[data.len() - 1] as u64;
2246            for value in data {
2247                let value = value as u64;
2248                if previous.is_some_and(|prior| {
2249                    value < prior || (self.header.is_clean() && value == prior)
2250                }) {
2251                    return Err(MongrelError::InvalidArgument(
2252                        "sorted run row ids are not ordered".into(),
2253                    ));
2254                }
2255                first.get_or_insert(value);
2256                previous = Some(value);
2257                last = Some(value);
2258                counted += 1;
2259            }
2260            if stat.first_row_id != page_first || stat.last_row_id != page_last {
2261                return Err(MongrelError::InvalidArgument(
2262                    "sorted run row-id page bounds are stale".into(),
2263                ));
2264            }
2265            row_bounds.push((page_first, page_last));
2266        }
2267        if counted != self.header.row_count
2268            || first.unwrap_or(0) != self.header.min_row_id
2269            || last.unwrap_or(0) != self.header.max_row_id
2270        {
2271            return Err(MongrelError::InvalidArgument(
2272                "sorted run header row bounds differ from its pages".into(),
2273            ));
2274        }
2275        for column in self.dir.clone() {
2276            if !columns.insert(column.column_id) {
2277                return Err(MongrelError::InvalidArgument(format!(
2278                    "sorted run contains duplicate column {}",
2279                    column.column_id
2280                )));
2281            }
2282            let rows = column
2283                .page_stats
2284                .iter()
2285                .map(|stat| stat.row_count)
2286                .collect::<Vec<_>>();
2287            if rows != expected_rows {
2288                return Err(MongrelError::InvalidArgument(
2289                    "sorted run columns have inconsistent page row counts".into(),
2290                ));
2291            }
2292            let ty = self.resolve_type(column.column_id);
2293            if column.type_id_tag != type_tag(&ty)
2294                || column.encoding > Encoding::Zstd as u8
2295                || (!matches!(column.column_id, SYS_ROW_ID | SYS_EPOCH | SYS_DELETED)
2296                    && self
2297                        .schema
2298                        .columns
2299                        .iter()
2300                        .all(|item| item.id != column.column_id))
2301            {
2302                return Err(MongrelError::InvalidArgument(
2303                    "sorted run column type or encoding is invalid".into(),
2304                ));
2305            }
2306            for (page, stat) in column.page_stats.iter().enumerate() {
2307                let bytes = self.read_page(column.column_id, page)?;
2308                if bytes.len() != stat.uncompressed_len as usize {
2309                    return Err(MongrelError::InvalidArgument(
2310                        "sorted run page length differs from its metadata".into(),
2311                    ));
2312                }
2313                let native =
2314                    match columnar::decode_page_native(ty.clone(), &bytes, stat.row_count as usize)
2315                    {
2316                        Ok(native) => native,
2317                        Err(MongrelError::InvalidArgument(message))
2318                            if message.starts_with("decode_page_native: unsupported ty") =>
2319                        {
2320                            let values =
2321                                columnar::decode_page(ty.clone(), &bytes, stat.row_count as usize)?;
2322                            columnar::values_to_native(ty.clone(), &values)
2323                        }
2324                        Err(error) => return Err(error),
2325                    };
2326                let (first_row_id, last_row_id) =
2327                    row_bounds.get(page).copied().ok_or_else(|| {
2328                        MongrelError::InvalidArgument(
2329                            "sorted run column has more pages than its row-id column".into(),
2330                        )
2331                    })?;
2332                let expected =
2333                    columnar::page_stat_for(ty.clone(), &native, first_row_id, last_row_id);
2334                if stat.first_row_id != expected.first_row_id
2335                    || stat.last_row_id != expected.last_row_id
2336                    || stat.null_count != expected.null_count
2337                    || stat.min != expected.min
2338                    || stat.max != expected.max
2339                {
2340                    return Err(MongrelError::InvalidArgument(
2341                        "sorted run page statistics differ from decoded values".into(),
2342                    ));
2343                }
2344            }
2345        }
2346        if !columns.contains(&SYS_ROW_ID)
2347            || !columns.contains(&SYS_EPOCH)
2348            || !columns.contains(&SYS_DELETED)
2349            || expected_rows.into_iter().map(u64::from).sum::<u64>() != self.header.row_count
2350        {
2351            return Err(MongrelError::InvalidArgument(
2352                "sorted run system columns or row count are invalid".into(),
2353            ));
2354        }
2355        if self.header.schema_id == self.schema.schema_id
2356            && self
2357                .schema
2358                .columns
2359                .iter()
2360                .any(|column| !columns.contains(&column.id))
2361        {
2362            return Err(MongrelError::InvalidArgument(
2363                "sorted run is missing a column from its declared schema".into(),
2364            ));
2365        }
2366
2367        // Page encodings and statistics are not enough to establish semantic
2368        // validity.  Reconstruct every materialized row and apply the schema's
2369        // row-level rules before the run can participate in recovery.
2370        for row_index in 0..usize::try_from(self.header.row_count).map_err(|_| {
2371            MongrelError::InvalidArgument("sorted run row count exceeds this platform".into())
2372        })? {
2373            let epoch = match self.column(SYS_EPOCH)?.get(row_index) {
2374                Some(Value::Int64(value)) if *value >= 0 => *value as u64,
2375                _ => {
2376                    return Err(MongrelError::InvalidArgument(
2377                        "sorted run contains an invalid commit epoch".into(),
2378                    ))
2379                }
2380            };
2381            if epoch > self.header.epoch_created || (self.header.is_uniform_epoch() && epoch != 0) {
2382                return Err(MongrelError::InvalidArgument(
2383                    "sorted run commit epoch exceeds its creation epoch".into(),
2384                ));
2385            }
2386            let deleted = match self.column(SYS_DELETED)?.get(row_index) {
2387                Some(Value::Bool(value)) => *value,
2388                _ => {
2389                    return Err(MongrelError::InvalidArgument(
2390                        "sorted run contains an invalid tombstone marker".into(),
2391                    ))
2392                }
2393            };
2394            let mut values = Vec::with_capacity(self.schema.columns.len());
2395            for column in self.schema.columns.clone() {
2396                let value = if columns.contains(&column.id) {
2397                    self.column(column.id)?
2398                        .get(row_index)
2399                        .cloned()
2400                        .unwrap_or(Value::Null)
2401                } else {
2402                    Value::Null
2403                };
2404                values.push((column.id, value));
2405            }
2406            if !deleted {
2407                self.schema
2408                    .validate_persisted_values(&values)
2409                    .map_err(|error| {
2410                        MongrelError::InvalidArgument(format!(
2411                            "sorted run row violates its schema: {error}"
2412                        ))
2413                    })?;
2414            }
2415        }
2416        Ok(())
2417    }
2418
2419    /// Overlay the real commit epoch for a uniform-epoch run (see
2420    /// [`RUN_FLAG_UNIFORM_EPOCH`]). No-op unless the run carries that flag, so it
2421    /// is always safe for the engine to call with the `RunRef.epoch_created`.
2422    pub(crate) fn set_uniform_epoch(&mut self, epoch: Epoch) {
2423        if self.header.is_uniform_epoch() {
2424            self.epoch_override = Some(epoch);
2425            // Drop any cached placeholder epoch column so the overlay takes hold.
2426            self.col_cache.remove(&SYS_EPOCH);
2427        }
2428    }
2429
2430    pub(crate) fn into_visible_version_cursor(
2431        self,
2432        snapshot: Epoch,
2433    ) -> Result<RunVisibleVersionCursor> {
2434        RunVisibleVersionCursor::new(self, snapshot)
2435    }
2436
2437    /// Whether this run is "clean" (one version per RowId, no tombstones,
2438    /// ascending row_ids) — stamped at write time via [`RUN_FLAG_CLEAN`].
2439    pub fn is_clean(&self) -> bool {
2440        self.header.is_clean()
2441    }
2442
2443    pub fn row_count(&self) -> usize {
2444        self.header.row_count as usize
2445    }
2446
2447    pub(crate) fn clean_contiguous_row_ids(&self) -> bool {
2448        let n = self.row_count();
2449        n > 0
2450            && self.is_clean()
2451            && self.epoch_override.is_none()
2452            && self.header.max_row_id >= self.header.min_row_id
2453            && self
2454                .header
2455                .max_row_id
2456                .checked_sub(self.header.min_row_id)
2457                .and_then(|span| span.checked_add(1))
2458                == Some(n as u64)
2459    }
2460
2461    pub(crate) fn position_for_row_id_fast(&self, row_id: u64) -> Option<usize> {
2462        if !self.clean_contiguous_row_ids()
2463            || row_id < self.header.min_row_id
2464            || row_id > self.header.max_row_id
2465        {
2466            return None;
2467        }
2468        Some((row_id - self.header.min_row_id) as usize)
2469    }
2470
2471    pub(crate) fn positions_for_row_ids_fast(&self, row_ids: &[u64]) -> Option<Vec<usize>> {
2472        if !self.clean_contiguous_row_ids() {
2473            return None;
2474        }
2475        let mut positions = Vec::with_capacity(row_ids.len());
2476        for &row_id in row_ids {
2477            if let Some(pos) = self.position_for_row_id_fast(row_id) {
2478                positions.push(pos);
2479            }
2480        }
2481        positions.sort_unstable();
2482        Some(positions)
2483    }
2484
2485    fn resolve_type(&self, column_id: u16) -> TypeId {
2486        match column_id {
2487            SYS_ROW_ID | SYS_EPOCH => TypeId::Int64,
2488            SYS_DELETED => TypeId::Bool,
2489            _ => self
2490                .schema
2491                .columns
2492                .iter()
2493                .find(|c| c.id == column_id)
2494                .map(|c| c.ty.clone())
2495                .unwrap_or(TypeId::Bytes),
2496        }
2497    }
2498
2499    fn find_header(&self, column_id: u16) -> Result<&ColumnPageHeader> {
2500        self.dir
2501            .iter()
2502            .find(|h| h.column_id == column_id)
2503            .ok_or_else(|| MongrelError::ColumnNotFound(format!("column {column_id}")))
2504    }
2505
2506    /// Whether `column_id`'s pages are encrypted. Encrypted runs carry no
2507    /// cleartext per-page min/max (they would leak plaintext values), so the
2508    /// range resolvers must NOT prune by stats for such columns — a missing
2509    /// stat there means "hidden", not "all-null". They fall back to decrypting
2510    /// and scanning every page.
2511    fn col_encrypted(&self, column_id: u16) -> bool {
2512        self.dir
2513            .iter()
2514            .find(|h| h.column_id == column_id)
2515            .map(|h| h.flags & ColumnPageHeader::PAGE_ENCRYPTED != 0)
2516            .unwrap_or(false)
2517    }
2518
2519    pub(crate) fn read_page(&mut self, column_id: u16, page_seq: usize) -> Result<Vec<u8>> {
2520        let (offset, compressed_len, encrypted) = {
2521            let ch = self.find_header(column_id)?;
2522            let stat = ch
2523                .page_stats
2524                .get(page_seq)
2525                .ok_or_else(|| MongrelError::InvalidArgument("page seq out of range".into()))?;
2526            (
2527                stat.offset,
2528                stat.compressed_len,
2529                ch.flags & ColumnPageHeader::PAGE_ENCRYPTED != 0,
2530            )
2531        };
2532        let end = offset
2533            .checked_add(compressed_len as u64)
2534            .ok_or_else(|| MongrelError::InvalidArgument("run page offset overflows".into()))?;
2535        let start = usize::try_from(offset)
2536            .map_err(|_| MongrelError::InvalidArgument("run page offset is too large".into()))?;
2537        let end = usize::try_from(end)
2538            .map_err(|_| MongrelError::InvalidArgument("run page end is too large".into()))?;
2539        // Shared cache: serve the raw (on-disk / ciphertext) page bytes if
2540        // present, so concurrent readers never re-read or re-decrypt a page.
2541        let key = page_cache_key(self.table_id, self.header.run_id, column_id, page_seq);
2542        if let Some(cache) = &self.page_cache {
2543            if let Some(bytes) = cache.lock(&key).get(
2544                &key,
2545                crate::epoch::Snapshot::at(crate::epoch::Epoch(u64::MAX)),
2546            ) {
2547                return decrypt_or_passthrough(
2548                    self.cipher.as_deref(),
2549                    self.nonce_prefix,
2550                    column_id,
2551                    page_seq,
2552                    encrypted,
2553                    &bytes,
2554                );
2555            }
2556        }
2557        let buf = match &self.mmap {
2558            // Slice the mapping — no seek/read syscalls; the OS page cache fills
2559            // the pages on first touch.
2560            Some(m) => m
2561                .get(start..end)
2562                .ok_or_else(|| {
2563                    MongrelError::InvalidArgument("run page is outside the mapped file".into())
2564                })?
2565                .to_vec(),
2566            None => {
2567                self.file.seek(SeekFrom::Start(offset))?;
2568                let mut buf = vec![0u8; compressed_len as usize];
2569                self.file.read_exact(&mut buf)?;
2570                buf
2571            }
2572        };
2573        // Spill the raw bytes into the shared cache (post-read, pre-decrypt).
2574        if let Some(cache) = &self.page_cache {
2575            cache.lock(&key).insert(crate::page::CachedPage {
2576                committed_epoch: crate::epoch::Epoch(self.header.epoch_created),
2577                content_hash: key,
2578                bytes: bytes::Bytes::copy_from_slice(&buf),
2579            });
2580        }
2581        decrypt_or_passthrough(
2582            self.cipher.as_deref(),
2583            self.nonce_prefix,
2584            column_id,
2585            page_seq,
2586            encrypted,
2587            &buf,
2588        )
2589    }
2590
2591    /// `&self` version of [`Self::read_page`] restricted to the mmap-backed
2592    /// path, so page bytes can be read concurrently (rayon) without the
2593    /// `&mut self` file handle. Decryption (when enabled) uses the `Sync`
2594    /// cipher and a deterministic per-page nonce, so it is also parallel-safe.
2595    fn read_page_shared(&self, column_id: u16, page_seq: usize) -> Result<Vec<u8>> {
2596        let ch = self.find_header(column_id)?;
2597        let stat = ch
2598            .page_stats
2599            .get(page_seq)
2600            .ok_or_else(|| MongrelError::InvalidArgument("page seq out of range".into()))?;
2601        let encrypted = ch.flags & ColumnPageHeader::PAGE_ENCRYPTED != 0;
2602        // Non-blocking probe of the shared cache: never block the rayon pool on
2603        // a contended lock. On a hit, avoid the mmap slice + decrypt entirely.
2604        let key = page_cache_key(self.table_id, self.header.run_id, column_id, page_seq);
2605        if let Some(cache) = &self.page_cache {
2606            if let Some(guard) = cache.try_lock(&key) {
2607                if let Some(bytes) = guard.try_get(
2608                    &key,
2609                    crate::epoch::Snapshot::at(crate::epoch::Epoch(u64::MAX)),
2610                ) {
2611                    return decrypt_or_passthrough(
2612                        self.cipher.as_deref(),
2613                        self.nonce_prefix,
2614                        column_id,
2615                        page_seq,
2616                        encrypted,
2617                        &bytes,
2618                    );
2619                }
2620            }
2621        }
2622        let mmap = self.mmap.as_ref().ok_or_else(|| {
2623            MongrelError::InvalidArgument("parallel page decode requires an mmap-backed run".into())
2624        })?;
2625        let end = stat
2626            .offset
2627            .checked_add(stat.compressed_len as u64)
2628            .ok_or_else(|| MongrelError::InvalidArgument("run page offset overflows".into()))?;
2629        let start = usize::try_from(stat.offset)
2630            .map_err(|_| MongrelError::InvalidArgument("run page offset is too large".into()))?;
2631        let end = usize::try_from(end)
2632            .map_err(|_| MongrelError::InvalidArgument("run page end is too large".into()))?;
2633        let buf = mmap
2634            .get(start..end)
2635            .ok_or_else(|| {
2636                MongrelError::InvalidArgument("run page is outside the mapped file".into())
2637            })?
2638            .to_vec();
2639        // Opportunistic, non-blocking insert: populate the shared cache so later
2640        // readers (and encrypted re-reads) skip the mmap slice + decrypt. Never
2641        // block the rayon pool — if the lock is contended, just skip the insert.
2642        if let Some(cache) = &self.page_cache {
2643            if let Some(mut guard) = cache.try_lock(&key) {
2644                guard.insert(crate::page::CachedPage {
2645                    committed_epoch: crate::epoch::Epoch(self.header.epoch_created),
2646                    content_hash: key,
2647                    bytes: bytes::Bytes::copy_from_slice(&buf),
2648                });
2649            }
2650        }
2651        decrypt_or_passthrough(
2652            self.cipher.as_deref(),
2653            self.nonce_prefix,
2654            column_id,
2655            page_seq,
2656            encrypted,
2657            &buf,
2658        )
2659    }
2660
2661    /// Decode (and cache) a full column, concatenating all pages.
2662    fn column(&mut self, column_id: u16) -> Result<&[Value]> {
2663        // Uniform-epoch overlay: serve the `_epoch` column as a constant of the
2664        // real commit epoch instead of the placeholder stored on disk.
2665        if column_id == SYS_EPOCH {
2666            if let Some(ov) = self.epoch_override {
2667                if !self.col_cache.contains_key(&column_id) {
2668                    let n = self.row_count();
2669                    self.col_cache
2670                        .insert(column_id, vec![Value::Int64(ov.0 as i64); n]);
2671                }
2672                return Ok(self.col_cache.get(&column_id).unwrap().as_slice());
2673            }
2674        }
2675        if !self.col_cache.contains_key(&column_id) {
2676            let ty = self.resolve_type(column_id);
2677            let page_rows: Vec<usize> = {
2678                let ch = self.find_header(column_id)?;
2679                ch.page_stats.iter().map(|s| s.row_count as usize).collect()
2680            };
2681            let mut decoded: Vec<Value> = Vec::with_capacity(self.row_count());
2682            for (seq, &pr) in page_rows.iter().enumerate() {
2683                let page = self.read_page(column_id, seq)?;
2684                decoded.extend(columnar::decode_page(ty.clone(), &page, pr)?);
2685            }
2686            self.col_cache.insert(column_id, decoded);
2687        }
2688        Ok(self.col_cache.get(&column_id).unwrap().as_slice())
2689    }
2690
2691    /// Newest version of `row_id` with `epoch <= snapshot`, including tombstones
2692    /// (returned as a `Row` with `deleted=true`). `None` if no such version.
2693    ///
2694    /// Page-pruned: `SYS_ROW_ID` pages carry exact `first_row_id`/`last_row_id`
2695    /// bounds (rows are written in ascending `(RowId, Epoch)` order), so this
2696    /// decodes only the page(s) that can contain `row_id` instead of the whole
2697    /// column — the old implementation decoded every row's `SYS_ROW_ID` (and
2698    /// `SYS_EPOCH`) up front, making every single-row point lookup (the common
2699    /// case for a PK/unique check feeding insert/update/delete) pay a
2700    /// full-column decode. A row's version group can span at most two adjacent
2701    /// pages (split at a page boundary mid-group); `candidate_pages` below
2702    /// collects every page whose bounds include `row_id`, which is normally
2703    /// one page and two only in that split case.
2704    pub fn get_version(&mut self, row_id: RowId, snapshot: Epoch) -> Result<Option<(Epoch, Row)>> {
2705        match self.find_version_page(row_id, snapshot)? {
2706            None => Ok(None),
2707            Some((epoch, seq, local_index)) => Ok(Some((
2708                Epoch(epoch),
2709                self.materialize_in_page(seq, local_index)?,
2710            ))),
2711        }
2712    }
2713
2714    /// Page-pruned search for the newest version of `row_id` with `epoch <=
2715    /// snapshot`: `(epoch, page_seq, local_index)`, or `None` if no such
2716    /// version exists in this run. Factored out of [`Self::get_version`] so
2717    /// [`Self::get_version_column`] can reuse the exact same page-finding
2718    /// logic without re-deriving it.
2719    fn find_version_page(
2720        &mut self,
2721        row_id: RowId,
2722        snapshot: Epoch,
2723    ) -> Result<Option<(u64, usize, usize)>> {
2724        let n = self.row_count();
2725        if n == 0 {
2726            return Ok(None);
2727        }
2728        let target = row_id.0 as i64;
2729        let ch = self.find_header(SYS_ROW_ID)?;
2730        let mut page_start = 0u64;
2731        let candidate_pages: Vec<(usize, u64)> = ch // (page_seq, row offset of page start)
2732            .page_stats
2733            .iter()
2734            .enumerate()
2735            .filter_map(|(seq, s)| {
2736                let start = page_start;
2737                page_start += s.row_count as u64;
2738                (s.first_row_id <= row_id.0 && row_id.0 <= s.last_row_id).then_some((seq, start))
2739            })
2740            .collect();
2741        if candidate_pages.is_empty() {
2742            return Ok(None);
2743        }
2744        let ty = self.resolve_type(SYS_ROW_ID);
2745        let mut best: Option<(u64, usize, usize)> = None; // (epoch, page_seq, local index)
2746        for (seq, _page_row_start) in candidate_pages {
2747            let page_rows = self.find_header(SYS_ROW_ID)?.page_stats[seq].row_count as usize;
2748            let row_ids =
2749                match self.decode_page_native_cached(ty.clone(), SYS_ROW_ID, seq, page_rows)? {
2750                    columnar::NativeColumn::Int64 { data, .. } => data,
2751                    _ => return Err(MongrelError::InvalidArgument("sys row_id not int64".into())),
2752                };
2753            let local = match row_ids.binary_search(&target) {
2754                Ok(i) => i,
2755                Err(_) => continue,
2756            };
2757            let epoch_ty = self.resolve_type(SYS_EPOCH);
2758            let epochs =
2759                match self.decode_page_native_cached(epoch_ty, SYS_EPOCH, seq, page_rows)? {
2760                    columnar::NativeColumn::Int64 { data, .. } => data,
2761                    _ => return Err(MongrelError::InvalidArgument("sys epoch not int64".into())),
2762                };
2763            // `local` is one match; the row-id's full version group is the
2764            // contiguous run of equal row-ids around it within this page.
2765            let mut lo = local;
2766            while lo > 0 && row_ids[lo - 1] == target {
2767                lo -= 1;
2768            }
2769            let mut hi = local;
2770            while hi + 1 < row_ids.len() && row_ids[hi + 1] == target {
2771                hi += 1;
2772            }
2773            for (i, &epoch) in epochs[lo..=hi].iter().enumerate() {
2774                let epoch = epoch as u64;
2775                if epoch <= snapshot.0 && best.map(|(be, ..)| epoch > be).unwrap_or(true) {
2776                    best = Some((epoch, seq, lo + i));
2777                }
2778            }
2779        }
2780        Ok(best)
2781    }
2782
2783    /// Like [`Self::get_version`], but decodes only `column_id` (plus the
2784    /// `SYS_DELETED` flag) instead of materializing every schema column via
2785    /// [`Self::materialize_in_page`]. For a wide schema this avoids paying to
2786    /// decode every other column's page just to read one value and throw the
2787    /// rest away — e.g. `Table::remove_hot_for_row`'s PK-only lookup, which
2788    /// used to pull the whole row (every column, every page) just for the
2789    /// primary key.
2790    pub fn get_version_column(
2791        &mut self,
2792        row_id: RowId,
2793        snapshot: Epoch,
2794        column_id: u16,
2795    ) -> Result<Option<(Epoch, bool, Option<Value>)>> {
2796        let Some((epoch, seq, local_index)) = self.find_version_page(row_id, snapshot)? else {
2797            return Ok(None);
2798        };
2799        let page_rows = self.find_header(SYS_ROW_ID)?.page_stats[seq].row_count as usize;
2800        let page_start: usize = self.find_header(SYS_ROW_ID)?.page_stats[..seq]
2801            .iter()
2802            .map(|s| s.row_count as usize)
2803            .sum();
2804        let global_index = page_start + local_index;
2805        let native_at = |slf: &mut Self, cid: u16| -> Result<Option<Value>> {
2806            if !slf.dir.iter().any(|h| h.column_id == cid) {
2807                return Ok(None);
2808            }
2809            let ty = slf.resolve_type(cid);
2810            if !matches!(
2811                ty,
2812                TypeId::Bool
2813                    | TypeId::Int8
2814                    | TypeId::Int16
2815                    | TypeId::Int32
2816                    | TypeId::Int64
2817                    | TypeId::UInt8
2818                    | TypeId::UInt16
2819                    | TypeId::UInt32
2820                    | TypeId::UInt64
2821                    | TypeId::Float32
2822                    | TypeId::Float64
2823                    | TypeId::TimestampNanos
2824                    | TypeId::Date32
2825                    | TypeId::Bytes
2826            ) {
2827                return Ok(slf.column(cid)?.get(global_index).cloned());
2828            }
2829            Ok(slf
2830                .decode_page_native_cached(ty, cid, seq, page_rows)?
2831                .value_at(local_index))
2832        };
2833        let deleted = matches!(native_at(self, SYS_DELETED)?, Some(Value::Bool(true)));
2834        let value = native_at(self, column_id)?;
2835        Ok(Some((Epoch(epoch), deleted, value)))
2836    }
2837
2838    /// Newest version epoch and tombstone flag without decoding a user column.
2839    pub fn get_version_visibility(
2840        &mut self,
2841        row_id: RowId,
2842        snapshot: Epoch,
2843    ) -> Result<Option<(Epoch, bool)>> {
2844        let Some((epoch, seq, local_index)) = self.find_version_page(row_id, snapshot)? else {
2845            return Ok(None);
2846        };
2847        let page_rows = self.find_header(SYS_ROW_ID)?.page_stats[seq].row_count as usize;
2848        let deleted = match self.decode_page_native_cached(
2849            self.resolve_type(SYS_DELETED),
2850            SYS_DELETED,
2851            seq,
2852            page_rows,
2853        )? {
2854            columnar::NativeColumn::Bool { data, .. } => data[local_index] != 0,
2855            _ => return Err(MongrelError::InvalidArgument("sys deleted not bool".into())),
2856        };
2857        Ok(Some((Epoch(epoch), deleted)))
2858    }
2859
2860    /// Build a `Row` from page `seq`'s data at `local_index`, decoding only
2861    /// that one page per column instead of [`Self::materialize`]'s whole-column
2862    /// `Vec<Value>` decode — used by [`Self::get_version`], which already knows
2863    /// the exact page from its page-pruned search. Only for scalar types with a
2864    /// [`columnar::NativeColumn`] representation; any other column (e.g. a
2865    /// fixed-size `Embedding`, which `NativeColumn` has no variant for) falls
2866    /// back to the whole-column path for that column specifically, so this is
2867    /// never wrong, only sometimes not faster.
2868    fn materialize_in_page(&mut self, seq: usize, local_index: usize) -> Result<Row> {
2869        let page_rows = self.find_header(SYS_ROW_ID)?.page_stats[seq].row_count as usize;
2870        let page_start: usize = self.find_header(SYS_ROW_ID)?.page_stats[..seq]
2871            .iter()
2872            .map(|s| s.row_count as usize)
2873            .sum();
2874        let global_index = page_start + local_index;
2875        let native_at = |slf: &mut Self, column_id: u16| -> Result<Option<Value>> {
2876            // Absent column (schema evolution: added after this run was
2877            // written) reads as null, matching `materialize`'s own guard.
2878            if !slf.dir.iter().any(|h| h.column_id == column_id) {
2879                return Ok(None);
2880            }
2881            let ty = slf.resolve_type(column_id);
2882            if !matches!(
2883                ty,
2884                TypeId::Bool
2885                    | TypeId::Int8
2886                    | TypeId::Int16
2887                    | TypeId::Int32
2888                    | TypeId::Int64
2889                    | TypeId::UInt8
2890                    | TypeId::UInt16
2891                    | TypeId::UInt32
2892                    | TypeId::UInt64
2893                    | TypeId::Float32
2894                    | TypeId::Float64
2895                    | TypeId::TimestampNanos
2896                    | TypeId::Date32
2897                    | TypeId::Bytes
2898            ) {
2899                // Not a NativeColumn-representable scalar (e.g. Embedding) —
2900                // fall back to the always-correct whole-column decode.
2901                return Ok(slf.column(column_id)?.get(global_index).cloned());
2902            }
2903            Ok(slf
2904                .decode_page_native_cached(ty, column_id, seq, page_rows)?
2905                .value_at(local_index))
2906        };
2907        let row_id = RowId(match native_at(self, SYS_ROW_ID)? {
2908            Some(Value::Int64(x)) => x as u64,
2909            _ => 0,
2910        });
2911        let committed_epoch = Epoch(match native_at(self, SYS_EPOCH)? {
2912            Some(Value::Int64(x)) => x as u64,
2913            _ => 0,
2914        });
2915        let deleted = matches!(native_at(self, SYS_DELETED)?, Some(Value::Bool(true)));
2916        let col_ids: Vec<u16> = self.schema.columns.iter().map(|c| c.id).collect();
2917        let mut columns = HashMap::new();
2918        for id in col_ids {
2919            columns.insert(id, native_at(self, id)?.unwrap_or(Value::Null));
2920        }
2921        Ok(Row {
2922            row_id,
2923            committed_epoch,
2924            columns,
2925            deleted,
2926        })
2927    }
2928
2929    /// Every row in the run (all versions), in `(RowId, Epoch)` order. Used by
2930    /// compaction, which must see every version to apply snapshot retention.
2931    pub fn all_rows(&mut self) -> Result<Vec<Row>> {
2932        let n = self.row_count();
2933        let mut out = Vec::with_capacity(n);
2934        for i in 0..n {
2935            out.push(self.materialize(i)?);
2936        }
2937        Ok(out)
2938    }
2939
2940    /// Every version with cooperative cancellation and a hard row bound.
2941    pub fn all_rows_controlled(
2942        &mut self,
2943        control: &crate::ExecutionControl,
2944        max_rows: usize,
2945    ) -> Result<Vec<Row>> {
2946        let n = self.row_count();
2947        if n > max_rows {
2948            return Err(MongrelError::ResourceLimitExceeded {
2949                resource: "controlled run row materialization",
2950                requested: n,
2951                limit: max_rows,
2952            });
2953        }
2954        let mut out = Vec::with_capacity(n);
2955        for i in 0..n {
2956            if i % 256 == 0 {
2957                control.checkpoint()?;
2958            }
2959            out.push(self.materialize(i)?);
2960        }
2961        control.checkpoint()?;
2962        Ok(out)
2963    }
2964
2965    /// Indices of the newest non-deleted version per `RowId` visible at
2966    /// `snapshot`, ascending. This is the columnar scan primitive: compute the
2967    /// visible set once (one pass over the row-id/epoch/deleted columns), then
2968    /// [`Self::gather_column`] each user column at these indices — no per-row
2969    /// `HashMap`/`Row` materialization.
2970    pub fn visible_indices(&mut self, snapshot: Epoch) -> Result<Vec<usize>> {
2971        let n = self.row_count();
2972        if n == 0 {
2973            return Ok(Vec::new());
2974        }
2975        let row_ids = self.column(SYS_ROW_ID)?.to_vec();
2976        let epochs = self.column(SYS_EPOCH)?.to_vec();
2977        let deleted = self.column(SYS_DELETED)?.to_vec();
2978        let mut best: HashMap<u64, (u64, usize)> = HashMap::new();
2979        for i in 0..n {
2980            let rid = int_at(&row_ids, i);
2981            let e = int_at(&epochs, i);
2982            if e > snapshot.0 {
2983                continue;
2984            }
2985            best.entry(rid)
2986                .and_modify(|(be, bi)| {
2987                    if e > *be {
2988                        *be = e;
2989                        *bi = i;
2990                    }
2991                })
2992                .or_insert((e, i));
2993        }
2994        let mut idxs: Vec<usize> = best.into_values().map(|(_, i)| i).collect();
2995        idxs.retain(|&i| !bool_at(&deleted, i));
2996        idxs.sort_unstable();
2997        Ok(idxs)
2998    }
2999
3000    /// Gather `column_id`'s values at the given indices (column cached). Used
3001    /// with [`Self::visible_indices`] for vectorized scans. A column absent from
3002    /// this run (e.g. added via schema evolution after the run was written)
3003    /// yields all-nulls.
3004    pub fn gather_column(&mut self, column_id: u16, indices: &[usize]) -> Result<Vec<Value>> {
3005        if !self.dir.iter().any(|h| h.column_id == column_id) {
3006            return Ok(vec![Value::Null; indices.len()]);
3007        }
3008        let col = self.column(column_id)?;
3009        Ok(indices
3010            .iter()
3011            .map(|&i| col.get(i).cloned().unwrap_or(Value::Null))
3012            .collect())
3013    }
3014
3015    /// Decode a column straight to a typed [`NativeColumn`] (no `Value`),
3016    /// concatenating all pages. A column absent from this run (schema evolution)
3017    /// yields an all-null column. Pages are decoded in parallel (rayon) when the
3018    /// run is mmap-backed and has more than one page; otherwise sequentially.
3019    pub fn column_native(&mut self, column_id: u16) -> Result<columnar::NativeColumn> {
3020        use rayon::prelude::*;
3021        if column_id == SYS_EPOCH {
3022            if let Some(ov) = self.epoch_override {
3023                return Ok(columnar::NativeColumn::int64_constant(
3024                    ov.0 as i64,
3025                    self.row_count(),
3026                ));
3027            }
3028        }
3029        let ty = self.resolve_type(column_id);
3030        let n = self.row_count();
3031        let Some(ch) = self.dir.iter().find(|h| h.column_id == column_id) else {
3032            return Ok(columnar::null_native(ty, n));
3033        };
3034        let page_count = ch.page_count as usize;
3035        let page_rows: Vec<usize> = ch.page_stats.iter().map(|s| s.row_count as usize).collect();
3036        if page_count == 0 {
3037            return Ok(columnar::null_native(ty, n));
3038        }
3039        let parts: Vec<columnar::NativeColumn> = if self.mmap.is_some() && page_count > 1 {
3040            // Parallel decode: each page is an independent region of the shared
3041            // mmap; the cipher (if any) is Sync. Spread the (CPU-bound) decode
3042            // across the rayon thread pool.
3043            let reader: &RunReader = self;
3044            (0..page_count)
3045                .into_par_iter()
3046                .map(|seq| {
3047                    let raw = reader.read_page_shared(column_id, seq)?;
3048                    columnar::decode_page_native(ty.clone(), &raw, page_rows[seq])
3049                })
3050                .collect::<Result<Vec<_>>>()?
3051        } else {
3052            let mut out = Vec::with_capacity(page_count);
3053            for (seq, &pr) in page_rows.iter().enumerate() {
3054                let page = self.read_page(column_id, seq)?;
3055                out.push(columnar::decode_page_native(ty.clone(), &page, pr)?);
3056            }
3057            out
3058        };
3059        Ok(columnar::NativeColumn::concat(&parts))
3060    }
3061
3062    /// Whether this reader is backed by a memory map (the prerequisite for the
3063    /// `&self` parallel decode paths). Readers on filesystems that reject mmap
3064    /// fall back to per-page `read()` and `has_mmap()` is false.
3065    pub fn has_mmap(&self) -> bool {
3066        self.mmap.is_some()
3067    }
3068
3069    /// `&self` variant of [`column_native`] for cross-column parallel scans
3070    /// (Phase 15.1). Requires the mmap backing (uses [`read_page_shared`], which
3071    /// is rayon-safe); callers without mmap use the `&mut` [`column_native`].
3072    /// Pages within the column decode in parallel when there is more than one,
3073    /// and `MADV_WILLNEED` is hinted up front so the kernel pre-faults the whole
3074    /// column's byte range (Phase 15.2) before the decode workers touch it.
3075    pub fn column_native_shared(&self, column_id: u16) -> Result<columnar::NativeColumn> {
3076        use rayon::prelude::*;
3077        if column_id == SYS_EPOCH {
3078            if let Some(ov) = self.epoch_override {
3079                return Ok(columnar::NativeColumn::int64_constant(
3080                    ov.0 as i64,
3081                    self.row_count(),
3082                ));
3083            }
3084        }
3085        let ty = self.resolve_type(column_id);
3086        let n = self.row_count();
3087        let Some(ch) = self.dir.iter().find(|h| h.column_id == column_id) else {
3088            return Ok(columnar::null_native(ty, n));
3089        };
3090        let page_count = ch.page_count as usize;
3091        let page_rows: Vec<usize> = ch.page_stats.iter().map(|s| s.row_count as usize).collect();
3092        if page_count == 0 {
3093            return Ok(columnar::null_native(ty, n));
3094        }
3095        // Phase 15.2: best-effort read-ahead. Tell the kernel to page-in the
3096        // column's full byte range before the workers fan out, overlapping the
3097        // disk I/O with the upcoming decode CPU.
3098        #[cfg(unix)]
3099        {
3100            if let (Some(m), Some(first)) = (&self.mmap, ch.page_stats.first()) {
3101                let start = first.offset as usize;
3102                let end = (ch.page_region_offset as usize) + (ch.page_region_len as usize);
3103                if end > start {
3104                    let _ = m.advise_range(memmap2::Advice::WillNeed, start, end - start);
3105                }
3106            }
3107        }
3108        let run_id = self.header.run_id;
3109        // Decode in parallel (cache probes use `try_lock` → no worker blocking).
3110        // Each item is the decoded page plus its key when it was a cache miss
3111        // (hits return `None` for the key so we don't re-insert/clone them).
3112        let mut parts_keys: Vec<(columnar::NativeColumn, Option<[u8; 32]>)> = if page_count > 1 {
3113            (0..page_count)
3114                .into_par_iter()
3115                .map(|seq| {
3116                    self.decode_page_cached(ty.clone(), column_id, seq, page_rows[seq], run_id)
3117                })
3118                .collect::<Result<Vec<_>>>()?
3119        } else {
3120            vec![self.decode_page_cached(ty, column_id, 0, page_rows[0], run_id)?]
3121        };
3122        // Sequentially cache the freshly-decoded pages — no parallel contention
3123        // on the insert, so every miss is reliably stored for the next scan.
3124        if let Some(cache) = &self.decoded_cache {
3125            for (col, key) in parts_keys.iter_mut() {
3126                if let Some(k) = key.take() {
3127                    cache.lock(&k).insert(k, Arc::new(col.clone()));
3128                }
3129            }
3130        }
3131        let parts: Vec<columnar::NativeColumn> = parts_keys.into_iter().map(|(c, _)| c).collect();
3132        Ok(columnar::NativeColumn::concat(&parts))
3133    }
3134
3135    /// Decode one page for the shared scan path, consulting the decoded-page
3136    /// cache first (Phase 15.4). Returns the decoded page plus `Some(key)` on a
3137    /// cache miss (so the caller can insert it) or `None` on a hit (already
3138    /// cached). Cache probes use `try_lock` so rayon workers never block.
3139    fn decode_page_cached(
3140        &self,
3141        ty: TypeId,
3142        column_id: u16,
3143        seq: usize,
3144        nrows: usize,
3145        run_id: u128,
3146    ) -> Result<(columnar::NativeColumn, Option<[u8; 32]>)> {
3147        let key = page_cache_key(self.table_id, run_id, column_id, seq);
3148        if let Some(cache) = &self.decoded_cache {
3149            if let Some(g) = cache.try_lock(&key) {
3150                if let Some(hit) = g.try_get(&key) {
3151                    return Ok(((*hit).clone(), None));
3152                }
3153            }
3154        }
3155        let raw = self.read_page_shared(column_id, seq)?;
3156        let col = columnar::decode_page_native(ty, &raw, nrows)?;
3157        Ok((col, Some(key)))
3158    }
3159
3160    /// [`Self::decode_page_cached`], but for the sequential point-lookup paths
3161    /// (`find_version_page`, `materialize_in_page`, `get_version_column`) via
3162    /// [`Self::read_page`] (handles the non-mmap-backed fallback) instead of
3163    /// [`Self::read_page_shared`] (mmap-only, for the parallel rayon scan
3164    /// path). No rayon pool to avoid blocking here, so a plain `.lock()` is
3165    /// fine — unlike the scan path's `try_lock`, this always consults the
3166    /// cache rather than skipping it under contention. Without this, every
3167    /// point lookup re-decompressed its page from scratch even when a prior
3168    /// lookup had just decoded the exact same page (the dominant remaining
3169    /// cost measured for `remove_hot_for_row`'s on-disk path).
3170    fn decode_page_native_cached(
3171        &mut self,
3172        ty: TypeId,
3173        column_id: u16,
3174        seq: usize,
3175        nrows: usize,
3176    ) -> Result<columnar::NativeColumn> {
3177        let key = page_cache_key(self.table_id, self.header.run_id, column_id, seq);
3178        if let Some(cache) = &self.decoded_cache {
3179            if let Some(hit) = cache.lock(&key).try_get(&key) {
3180                return Ok((*hit).clone());
3181            }
3182        }
3183        let raw = self.read_page(column_id, seq)?;
3184        let col = columnar::decode_page_native(ty, &raw, nrows)?;
3185        if let Some(cache) = &self.decoded_cache {
3186            cache
3187                .lock(&key)
3188                .insert(key, std::sync::Arc::new(col.clone()));
3189        }
3190        Ok(col)
3191    }
3192
3193    /// Row ids whose Int64 value is in `[lo, hi]`, **skipping pages whose
3194    /// `[min,max]` stat excludes the range** (Parquet-style page-index pruning).
3195    /// Nulls are excluded. Used by `Table::query_columns_native` to serve
3196    /// `Condition::Range` without decoding every page.
3197    pub fn range_row_ids_i64(
3198        &mut self,
3199        column_id: u16,
3200        lo: i64,
3201        hi: i64,
3202    ) -> Result<std::collections::HashSet<u64>> {
3203        Ok(self
3204            .range_row_id_set_i64(column_id, lo, hi)?
3205            .into_sorted_vec()
3206            .into_iter()
3207            .collect())
3208    }
3209
3210    pub(crate) fn range_row_id_set_i64(
3211        &mut self,
3212        column_id: u16,
3213        lo: i64,
3214        hi: i64,
3215    ) -> Result<RowIdSet> {
3216        let info: Vec<(Option<i64>, Option<i64>, usize)> =
3217            match self.dir.iter().find(|h| h.column_id == column_id) {
3218                Some(ch) => ch
3219                    .page_stats
3220                    .iter()
3221                    .map(|s| {
3222                        (
3223                            be_i64(s.min.as_deref()),
3224                            be_i64(s.max.as_deref()),
3225                            s.row_count as usize,
3226                        )
3227                    })
3228                    .collect(),
3229                None => return Ok(RowIdSet::empty()),
3230            };
3231        // Encrypted columns are pruneable only when this run carries the
3232        // decrypted stats envelope (overlaid at open). Without one (a run
3233        // whose writer recorded no stats at all), a missing min/max means
3234        // "unknown" — never prune — whereas with the envelope (and always for
3235        // plaintext runs) a missing min/max means an all-null page.
3236        let stats_pruneable =
3237            !self.col_encrypted(column_id) || self.header.encrypted_stats_offset != 0;
3238        let clean_contiguous = self.clean_contiguous_row_ids();
3239        let mut out = Vec::new();
3240        let mut page_start = 0usize;
3241        for (seq, (mn, mx, nrows)) in info.into_iter().enumerate() {
3242            let current_page_start = page_start;
3243            page_start += nrows;
3244            // Skip pages that cannot contain a match (or are all-null).
3245            let skip = stats_pruneable
3246                && match (mn, mx) {
3247                    (Some(mn), Some(mx)) => mx < lo || mn > hi,
3248                    _ => true,
3249                };
3250            if skip {
3251                continue;
3252            }
3253            let val_page = self.read_page(column_id, seq)?;
3254            let vals =
3255                columnar::decode_page_native(self.resolve_type(column_id), &val_page, nrows)?;
3256            if let columnar::NativeColumn::Int64 { data: v, validity } = vals {
3257                if clean_contiguous {
3258                    for (i, val) in v.iter().enumerate() {
3259                        if columnar::validity_bit(&validity, i) && *val >= lo && *val <= hi {
3260                            out.push(self.header.min_row_id + current_page_start as u64 + i as u64);
3261                        }
3262                    }
3263                } else {
3264                    let rid_page = self.read_page(SYS_ROW_ID, seq)?;
3265                    let rids = columnar::decode_page_native(TypeId::Int64, &rid_page, nrows)?;
3266                    if let columnar::NativeColumn::Int64 { data: r, .. } = rids {
3267                        for (i, val) in v.iter().enumerate() {
3268                            if columnar::validity_bit(&validity, i) && *val >= lo && *val <= hi {
3269                                out.push(r[i] as u64);
3270                            }
3271                        }
3272                    }
3273                }
3274            }
3275        }
3276        Ok(RowIdSet::from_unsorted(out))
3277    }
3278
3279    /// Float64 analogue of [`Self::range_row_ids_i64`] with per-bound
3280    /// inclusivity, for `Condition::RangeF64`.
3281    pub fn range_row_ids_f64(
3282        &mut self,
3283        column_id: u16,
3284        lo: f64,
3285        lo_inclusive: bool,
3286        hi: f64,
3287        hi_inclusive: bool,
3288    ) -> Result<std::collections::HashSet<u64>> {
3289        Ok(self
3290            .range_row_id_set_f64(column_id, lo, lo_inclusive, hi, hi_inclusive)?
3291            .into_sorted_vec()
3292            .into_iter()
3293            .collect())
3294    }
3295
3296    pub(crate) fn range_row_id_set_f64(
3297        &mut self,
3298        column_id: u16,
3299        lo: f64,
3300        lo_inclusive: bool,
3301        hi: f64,
3302        hi_inclusive: bool,
3303    ) -> Result<RowIdSet> {
3304        let info: Vec<(Option<f64>, Option<f64>, usize)> =
3305            match self.dir.iter().find(|h| h.column_id == column_id) {
3306                Some(ch) => ch
3307                    .page_stats
3308                    .iter()
3309                    .map(|s| {
3310                        (
3311                            be_f64(s.min.as_deref()),
3312                            be_f64(s.max.as_deref()),
3313                            s.row_count as usize,
3314                        )
3315                    })
3316                    .collect(),
3317                None => return Ok(RowIdSet::empty()),
3318            };
3319        // Encrypted columns are pruneable only when this run carries the
3320        // decrypted stats envelope (overlaid at open). Without one (a run
3321        // whose writer recorded no stats at all), a missing min/max means
3322        // "unknown" — never prune — whereas with the envelope (and always for
3323        // plaintext runs) a missing min/max means an all-null page.
3324        let stats_pruneable =
3325            !self.col_encrypted(column_id) || self.header.encrypted_stats_offset != 0;
3326        let clean_contiguous = self.clean_contiguous_row_ids();
3327        let mut out = Vec::new();
3328        let mut page_start = 0usize;
3329        for (seq, (mn, mx, nrows)) in info.into_iter().enumerate() {
3330            let current_page_start = page_start;
3331            page_start += nrows;
3332            // A page can be dropped iff every value fails the predicate, i.e. the
3333            // largest fails the lo-test or the smallest fails the hi-test.
3334            let skip = stats_pruneable
3335                && match (mn, mx) {
3336                    (Some(mn), Some(mx)) => {
3337                        let skip_lo = mx < lo || (!lo_inclusive && mx == lo);
3338                        let skip_hi = mn > hi || (!hi_inclusive && mn == hi);
3339                        skip_lo || skip_hi
3340                    }
3341                    _ => true,
3342                };
3343            if skip {
3344                continue;
3345            }
3346            let val_page = self.read_page(column_id, seq)?;
3347            let vals = columnar::decode_page_native(TypeId::Float64, &val_page, nrows)?;
3348            if let columnar::NativeColumn::Float64 { data: v, validity } = vals {
3349                if clean_contiguous {
3350                    for (i, val) in v.iter().enumerate() {
3351                        if !columnar::validity_bit(&validity, i) || val.is_nan() {
3352                            continue;
3353                        }
3354                        let ok_lo = if lo_inclusive { *val >= lo } else { *val > lo };
3355                        let ok_hi = if hi_inclusive { *val <= hi } else { *val < hi };
3356                        if ok_lo && ok_hi {
3357                            out.push(self.header.min_row_id + current_page_start as u64 + i as u64);
3358                        }
3359                    }
3360                } else {
3361                    let rid_page = self.read_page(SYS_ROW_ID, seq)?;
3362                    let rids = columnar::decode_page_native(TypeId::Int64, &rid_page, nrows)?;
3363                    if let columnar::NativeColumn::Int64 { data: r, .. } = rids {
3364                        for (i, val) in v.iter().enumerate() {
3365                            if !columnar::validity_bit(&validity, i) || val.is_nan() {
3366                                continue;
3367                            }
3368                            let ok_lo = if lo_inclusive { *val >= lo } else { *val > lo };
3369                            let ok_hi = if hi_inclusive { *val <= hi } else { *val < hi };
3370                            if ok_lo && ok_hi {
3371                                out.push(r[i] as u64);
3372                            }
3373                        }
3374                    }
3375                }
3376            }
3377        }
3378        Ok(RowIdSet::from_unsorted(out))
3379    }
3380
3381    /// Page-pruned row-id set for `IS NULL` / `IS NOT NULL` on `column_id`.
3382    /// Skips pages whose `null_count` makes a match impossible (no nulls for
3383    /// `IS NULL`, all-nulls for `IS NOT NULL`), then decodes the validity bitmap
3384    /// of surviving pages to pinpoint matching rows.
3385    pub(crate) fn null_row_id_set(&mut self, column_id: u16, want_nulls: bool) -> Result<RowIdSet> {
3386        let stats: Vec<(usize, usize)> = match self.dir.iter().find(|h| h.column_id == column_id) {
3387            Some(ch) => ch
3388                .page_stats
3389                .iter()
3390                .map(|s| (s.null_count as usize, s.row_count as usize))
3391                .collect(),
3392            None => return Ok(RowIdSet::empty()),
3393        };
3394        let ty = self.resolve_type(column_id);
3395        let clean_contiguous = self.clean_contiguous_row_ids();
3396        let mut out = Vec::new();
3397        let mut page_start = 0usize;
3398        for (seq, (null_count, nrows)) in stats.into_iter().enumerate() {
3399            let current_page_start = page_start;
3400            page_start += nrows;
3401            // Skip pages that cannot match.
3402            if want_nulls && null_count == 0 {
3403                continue;
3404            }
3405            if !want_nulls && null_count == nrows {
3406                continue;
3407            }
3408            let val_page = self.read_page(column_id, seq)?;
3409            let col = columnar::decode_page_native(ty.clone(), &val_page, nrows)?;
3410            let validity = col.validity();
3411            if clean_contiguous {
3412                for i in 0..nrows {
3413                    let is_null = !columnar::validity_bit(validity, i);
3414                    if is_null == want_nulls {
3415                        out.push(self.header.min_row_id + current_page_start as u64 + i as u64);
3416                    }
3417                }
3418            } else {
3419                let rid_page = self.read_page(SYS_ROW_ID, seq)?;
3420                let rids = columnar::decode_page_native(TypeId::Int64, &rid_page, nrows)?;
3421                if let columnar::NativeColumn::Int64 { data: r, .. } = rids {
3422                    for (i, &rid) in r.iter().enumerate().take(nrows) {
3423                        let is_null = !columnar::validity_bit(validity, i);
3424                        if is_null == want_nulls {
3425                            out.push(rid as u64);
3426                        }
3427                    }
3428                }
3429            }
3430        }
3431        Ok(RowIdSet::from_unsorted(out))
3432    }
3433
3434    pub fn visible_indices_native(&mut self, snapshot: Epoch) -> Result<Vec<usize>> {
3435        let n = self.row_count();
3436        if n == 0 {
3437            return Ok(Vec::new());
3438        }
3439        let (row_ids, epochs, deleted) = self.system_columns_native()?;
3440        let mut best: HashMap<u64, (u64, usize)> = HashMap::new();
3441        for i in 0..n {
3442            let rid = row_ids[i] as u64;
3443            let e = epochs[i] as u64;
3444            if e > snapshot.0 {
3445                continue;
3446            }
3447            best.entry(rid)
3448                .and_modify(|(be, bi)| {
3449                    if e > *be {
3450                        *be = e;
3451                        *bi = i;
3452                    }
3453                })
3454                .or_insert((e, i));
3455        }
3456        let mut idxs: Vec<usize> = best.into_values().map(|(_, i)| i).collect();
3457        idxs.retain(|&i| deleted[i] == 0);
3458        idxs.sort_unstable();
3459        Ok(idxs)
3460    }
3461
3462    /// Page-pruned, **MVCC-visible** Int64 range resolution (Phase 16.3).
3463    ///
3464    /// Like [`Self::range_row_ids_i64`] (skips pages whose `[min,max]` excludes
3465    /// `[lo, hi]`) but restricts the output to the newest non-deleted version per
3466    /// `RowId` visible at `snapshot`. This is the layout-independent range
3467    /// primitive: correct under any memtable / multi-run / deletion-vector state,
3468    /// so the engine no longer has to fall back to a full-column decode when the
3469    /// "single clean run" invariant doesn't hold. Nulls are excluded.
3470    pub fn range_row_ids_visible_i64(
3471        &mut self,
3472        column_id: u16,
3473        lo: i64,
3474        hi: i64,
3475        snapshot: Epoch,
3476    ) -> Result<Vec<u64>> {
3477        let stats: Vec<(Option<i64>, Option<i64>, usize)> = match self.column_page_stats(column_id)
3478        {
3479            Some(s) => s
3480                .iter()
3481                .map(|st| {
3482                    (
3483                        be_i64(st.min.as_deref()),
3484                        be_i64(st.max.as_deref()),
3485                        st.row_count as usize,
3486                    )
3487                })
3488                .collect(),
3489            None => return Ok(Vec::new()),
3490        };
3491        // Encrypted columns are pruneable only when this run carries the
3492        // decrypted stats envelope (overlaid at open). Without one (a run
3493        // whose writer recorded no stats at all), a missing min/max means
3494        // "unknown" — never prune — whereas with the envelope (and always for
3495        // plaintext runs) a missing min/max means an all-null page.
3496        let stats_pruneable =
3497            !self.col_encrypted(column_id) || self.header.encrypted_stats_offset != 0;
3498        let (positions, rids) = self.visible_positions_with_rids(snapshot)?;
3499        let mut out: Vec<u64> = Vec::new();
3500        let mut vis = 0usize;
3501        let mut page_start = 0usize;
3502        for (seq, &(mn, mx, nrows)) in stats.iter().enumerate() {
3503            let page_end = page_start + nrows;
3504            // A page can be dropped iff every value fails the predicate.
3505            let skip = stats_pruneable
3506                && match (mn, mx) {
3507                    (Some(mn), Some(mx)) => mx < lo || mn > hi,
3508                    _ => true, // all-null / no stats → nulls never match
3509                };
3510            if !skip {
3511                let val_page = self.read_page(column_id, seq)?;
3512                let vals = columnar::decode_page_native(TypeId::Int64, &val_page, nrows)?;
3513                if let columnar::NativeColumn::Int64 { data: v, validity } = vals {
3514                    while vis < positions.len() && positions[vis] < page_end {
3515                        let local = positions[vis] - page_start;
3516                        if columnar::validity_bit(&validity, local)
3517                            && v[local] >= lo
3518                            && v[local] <= hi
3519                        {
3520                            out.push(rids[vis] as u64);
3521                        }
3522                        vis += 1;
3523                    }
3524                }
3525            } else {
3526                while vis < positions.len() && positions[vis] < page_end {
3527                    vis += 1;
3528                }
3529            }
3530            page_start = page_end;
3531        }
3532        Ok(out)
3533    }
3534
3535    /// Float64 analogue of [`Self::range_row_ids_visible_i64`] with per-bound
3536    /// inclusivity (Phase 16.3).
3537    pub fn range_row_ids_visible_f64(
3538        &mut self,
3539        column_id: u16,
3540        lo: f64,
3541        lo_inclusive: bool,
3542        hi: f64,
3543        hi_inclusive: bool,
3544        snapshot: Epoch,
3545    ) -> Result<Vec<u64>> {
3546        let stats: Vec<(Option<f64>, Option<f64>, usize)> = match self.column_page_stats(column_id)
3547        {
3548            Some(s) => s
3549                .iter()
3550                .map(|st| {
3551                    (
3552                        be_f64(st.min.as_deref()),
3553                        be_f64(st.max.as_deref()),
3554                        st.row_count as usize,
3555                    )
3556                })
3557                .collect(),
3558            None => return Ok(Vec::new()),
3559        };
3560        // Encrypted columns are pruneable only when this run carries the
3561        // decrypted stats envelope (overlaid at open). Without one (a run
3562        // whose writer recorded no stats at all), a missing min/max means
3563        // "unknown" — never prune — whereas with the envelope (and always for
3564        // plaintext runs) a missing min/max means an all-null page.
3565        let stats_pruneable =
3566            !self.col_encrypted(column_id) || self.header.encrypted_stats_offset != 0;
3567        let (positions, rids) = self.visible_positions_with_rids(snapshot)?;
3568        let mut out: Vec<u64> = Vec::new();
3569        let mut vis = 0usize;
3570        let mut page_start = 0usize;
3571        for (seq, &(mn, mx, nrows)) in stats.iter().enumerate() {
3572            let page_end = page_start + nrows;
3573            let skip = stats_pruneable
3574                && match (mn, mx) {
3575                    (Some(mn), Some(mx)) => {
3576                        let skip_lo = mx < lo || (!lo_inclusive && mx == lo);
3577                        let skip_hi = mn > hi || (!hi_inclusive && mn == hi);
3578                        skip_lo || skip_hi
3579                    }
3580                    _ => true,
3581                };
3582            if !skip {
3583                let val_page = self.read_page(column_id, seq)?;
3584                let vals = columnar::decode_page_native(TypeId::Float64, &val_page, nrows)?;
3585                if let columnar::NativeColumn::Float64 { data: v, validity } = vals {
3586                    while vis < positions.len() && positions[vis] < page_end {
3587                        let local = positions[vis] - page_start;
3588                        if columnar::validity_bit(&validity, local) && !v[local].is_nan() {
3589                            let val = v[local];
3590                            let ok_lo = if lo_inclusive { val >= lo } else { val > lo };
3591                            let ok_hi = if hi_inclusive { val <= hi } else { val < hi };
3592                            if ok_lo && ok_hi {
3593                                out.push(rids[vis] as u64);
3594                            }
3595                        }
3596                        vis += 1;
3597                    }
3598                }
3599            } else {
3600                while vis < positions.len() && positions[vis] < page_end {
3601                    vis += 1;
3602                }
3603            }
3604            page_start = page_end;
3605        }
3606        Ok(out)
3607    }
3608
3609    /// MVCC-visible `IS NULL` / `IS NOT NULL` resolution. Follows the same
3610    /// page-stat-pruned + visible-positions pattern as
3611    /// [`Self::range_row_ids_visible_i64`], but checks the validity bitmap
3612    /// instead of a value range. Pages with no nulls (for IS NULL) or all-nulls
3613    /// (for IS NOT NULL) are skipped.
3614    pub fn null_row_ids_visible(
3615        &mut self,
3616        column_id: u16,
3617        want_nulls: bool,
3618        snapshot: Epoch,
3619    ) -> Result<Vec<u64>> {
3620        let stats: Vec<(usize, usize)> = match self.column_page_stats(column_id) {
3621            Some(s) => s
3622                .iter()
3623                .map(|st| (st.null_count as usize, st.row_count as usize))
3624                .collect(),
3625            None => return Ok(Vec::new()),
3626        };
3627        let ty = self.resolve_type(column_id);
3628        let (positions, rids) = self.visible_positions_with_rids(snapshot)?;
3629        let mut out: Vec<u64> = Vec::new();
3630        let mut vis = 0usize;
3631        let mut page_start = 0usize;
3632        for (seq, &(null_count, nrows)) in stats.iter().enumerate() {
3633            let page_end = page_start + nrows;
3634            let skip = (want_nulls && null_count == 0) || (!want_nulls && null_count == nrows);
3635            if !skip {
3636                let val_page = self.read_page(column_id, seq)?;
3637                let col = columnar::decode_page_native(ty.clone(), &val_page, nrows)?;
3638                let validity = col.validity();
3639                while vis < positions.len() && positions[vis] < page_end {
3640                    let local = positions[vis] - page_start;
3641                    let is_null = !columnar::validity_bit(validity, local);
3642                    if is_null == want_nulls {
3643                        out.push(rids[vis] as u64);
3644                    }
3645                    vis += 1;
3646                }
3647            } else {
3648                while vis < positions.len() && positions[vis] < page_end {
3649                    vis += 1;
3650                }
3651            }
3652            page_start = page_end;
3653        }
3654        Ok(out)
3655    }
3656
3657    /// tombstones excluded) paired with each position's `RowId`, in one pass.
3658    /// Used by [`crate::cursor::NativePageCursor`] to map survivors to pages
3659    /// without re-decoding the system columns.
3660    pub fn visible_positions_with_rids(
3661        &mut self,
3662        snapshot: Epoch,
3663    ) -> Result<(Vec<usize>, Vec<i64>)> {
3664        let n = self.row_count();
3665        if n == 0 {
3666            return Ok((Vec::new(), Vec::new()));
3667        }
3668        // Clean-run fast path (Phase 16.3c): one version per RowId, no
3669        // tombstones, ascending row_ids ⟹ every position is the newest (and
3670        // only) visible version. Skip decoding epoch/deleted and the group-
3671        // collapse loop; just decode row_ids (needed for survivor↔position
3672        // mapping) and return identity positions [0..n).
3673        // A uniform-epoch overlay must still gate by snapshot, so skip the clean
3674        // fast path when an override is active (defensive: spill runs are never
3675        // written clean).
3676        if self.is_clean()
3677            && self.epoch_override.is_none()
3678            && self.header.epoch_created <= snapshot.0
3679        {
3680            let row_ids = match self.column_native_shared(SYS_ROW_ID)? {
3681                columnar::NativeColumn::Int64 { data, .. } => data,
3682                _ => return Err(MongrelError::InvalidArgument("sys row_id not int64".into())),
3683            };
3684            let positions: Vec<usize> = (0..n).collect();
3685            return Ok((positions, row_ids));
3686        }
3687        let (row_ids, epochs, deleted) = self.system_columns_native()?;
3688        // Runs are written in `(RowId, Epoch)` ascending order (Bε-tree
3689        // composite key), so same-rid positions are consecutive with the
3690        // newest (highest epoch) last. One linear pass keeps the last position
3691        // per rid with `epoch <= snapshot`, dropping tombstones — no HashMap,
3692        // no per-row hashing, sequential memory access. (Phase 16.3.)
3693        //
3694        // Invariant: every write path (memtable drain, mutable-run spill,
3695        // bulk_load's sequential alloc) produces runs in this order; if a path
3696        // ever wrote an unsorted run this would under-count (only consecutive
3697        // dup groups merge) and must be reverted to the HashMap form.
3698        let mut idxs: Vec<usize> = Vec::new();
3699        let mut i = 0;
3700        while i < n {
3701            let rid = row_ids[i] as u64;
3702            // Walk the consecutive rid group; epochs rise within it, so the
3703            // last position with epoch <= snapshot is the newest visible one.
3704            let mut best: Option<usize> = None;
3705            let mut j = i;
3706            while j < n && row_ids[j] as u64 == rid {
3707                if epochs[j] as u64 <= snapshot.0 {
3708                    best = Some(j);
3709                }
3710                j += 1;
3711            }
3712            if let Some(b) = best {
3713                if deleted[b] == 0 {
3714                    idxs.push(b);
3715                }
3716            }
3717            i = j;
3718        }
3719        // Groups are processed in rid-ascending = position-ascending order.
3720        let rids: Vec<i64> = idxs.iter().map(|&k| row_ids[k]).collect();
3721        Ok((idxs, rids))
3722    }
3723
3724    /// Row count of each PAX page of `column_id`, in page order. Every column
3725    /// in a run shares the same PAX row partition, so this yields the table's
3726    /// page layout (cumulative sums give page start offsets).
3727    pub fn page_row_counts(&self, column_id: u16) -> Result<Vec<usize>> {
3728        Ok(self
3729            .find_header(column_id)?
3730            .page_stats
3731            .iter()
3732            .map(|s| s.row_count as usize)
3733            .collect())
3734    }
3735
3736    /// Whether this run stores `column_id` (false for a column added via
3737    /// `add_column` after the run was written — those read as all-null).
3738    pub fn has_column(&self, column_id: u16) -> bool {
3739        self.dir.iter().any(|h| h.column_id == column_id)
3740    }
3741
3742    /// The per-page [`PageStat`]s for `column_id`, or `None` if the column is
3743    /// absent from this run (schema evolution). Used to compute exact column
3744    /// min/max/null_count for the analytical aggregate fast path.
3745    pub fn column_page_stats(&self, column_id: u16) -> Option<&[crate::page::PageStat]> {
3746        self.dir
3747            .iter()
3748            .find(|h| h.column_id == column_id)
3749            .map(|ch| ch.page_stats.as_slice())
3750    }
3751
3752    /// Decode the system columns once, as typed buffers. Uses the shared
3753    /// parallel + decoded-page-cached path (`column_native_shared`) so the
3754    /// row-id/epoch/deleted pages decode concurrently and stay cached across
3755    /// queries — MVCC visibility resolution is on every scan's hot path.
3756    pub(crate) fn system_columns_native(&mut self) -> Result<(Vec<i64>, Vec<i64>, Vec<u8>)> {
3757        let row_ids = match self.column_native_shared(SYS_ROW_ID)? {
3758            columnar::NativeColumn::Int64 { data, .. } => data,
3759            _ => return Err(MongrelError::InvalidArgument("sys row_id not int64".into())),
3760        };
3761        let epochs = match self.column_native_shared(SYS_EPOCH)? {
3762            columnar::NativeColumn::Int64 { data, .. } => data,
3763            _ => return Err(MongrelError::InvalidArgument("sys epoch not int64".into())),
3764        };
3765        let deleted = match self.column_native_shared(SYS_DELETED)? {
3766            columnar::NativeColumn::Bool { data, .. } => data,
3767            _ => return Err(MongrelError::InvalidArgument("sys deleted not bool".into())),
3768        };
3769        Ok((row_ids, epochs, deleted))
3770    }
3771
3772    /// Newest visible version per `RowId` at `snapshot`, **including
3773    /// tombstones** (as `Row`s with `deleted=true`). Ascending `RowId`. Used by
3774    /// the engine to merge versions across runs and the memtable.
3775    pub fn visible_versions(&mut self, snapshot: Epoch) -> Result<Vec<Row>> {
3776        let n = self.row_count();
3777        if n == 0 {
3778            return Ok(Vec::new());
3779        }
3780        let row_ids = self.column(SYS_ROW_ID)?.to_vec();
3781        let epochs = self.column(SYS_EPOCH)?.to_vec();
3782        let mut best: HashMap<u64, (u64, usize)> = HashMap::new();
3783        for i in 0..n {
3784            let rid = int_at(&row_ids, i);
3785            let epoch = int_at(&epochs, i);
3786            if epoch > snapshot.0 {
3787                continue;
3788            }
3789            best.entry(rid)
3790                .and_modify(|e| {
3791                    if epoch > e.0 {
3792                        *e = (epoch, i);
3793                    }
3794                })
3795                .or_insert((epoch, i));
3796        }
3797        let mut picks: Vec<usize> = best.into_values().map(|(_, i)| i).collect();
3798        picks.sort();
3799        let mut out = Vec::with_capacity(picks.len());
3800        for i in picks {
3801            out.push(self.materialize(i)?);
3802        }
3803        Ok(out)
3804    }
3805
3806    /// All non-deleted rows visible at `snapshot`. Ascending `RowId`.
3807    pub fn visible_rows(&mut self, snapshot: Epoch) -> Result<Vec<Row>> {
3808        Ok(self
3809            .visible_versions(snapshot)?
3810            .into_iter()
3811            .filter(|r| !r.deleted)
3812            .collect())
3813    }
3814
3815    pub(crate) fn materialize(&mut self, index: usize) -> Result<Row> {
3816        let row_id = RowId(int_at(self.column(SYS_ROW_ID)?, index));
3817        let epoch = Epoch(int_at(self.column(SYS_EPOCH)?, index));
3818        let deleted = bool_at(self.column(SYS_DELETED)?, index);
3819        let col_ids: Vec<u16> = self.schema.columns.iter().map(|c| c.id).collect();
3820        let mut columns = HashMap::new();
3821        for id in col_ids {
3822            let val = if self.dir.iter().any(|h| h.column_id == id) {
3823                self.column(id)?.get(index).cloned().unwrap_or(Value::Null)
3824            } else {
3825                // Column added via schema evolution after this run was written.
3826                Value::Null
3827            };
3828            columns.insert(id, val);
3829        }
3830        Ok(Row {
3831            row_id,
3832            committed_epoch: epoch,
3833            columns,
3834            deleted,
3835        })
3836    }
3837
3838    /// Batched row materialization (Phase 16.3b finish): decode each system +
3839    /// user column **once** via the typed, page-cached `column_native` path and
3840    /// gather only the requested `indices` straight into `Row`s. This replaces
3841    /// N independent `materialize` calls, each of which rebuilt a full-column
3842    /// `Vec<Value>` (heap-allocating one `Value` per row) and then `.cloned()`
3843    /// a single slot. Exact semantics retained: schema-evolved columns absent
3844    /// from this run read `Value::Null`; deleted rows are still returned (the
3845    /// caller filters them).
3846    pub(crate) fn materialize_batch(&mut self, indices: &[usize]) -> Result<Vec<Row>> {
3847        if indices.is_empty() {
3848            return Ok(Vec::new());
3849        }
3850        use std::collections::HashMap;
3851        let rid_col = self.column_native_shared(SYS_ROW_ID)?;
3852        let epoch_col = self.column_native_shared(SYS_EPOCH)?;
3853        let del_col = self.column_native_shared(SYS_DELETED)?;
3854        let mut user: HashMap<u16, columnar::NativeColumn> = HashMap::new();
3855        let present: Vec<u16> = self
3856            .schema
3857            .columns
3858            .iter()
3859            .map(|c| c.id)
3860            .filter(|id| self.dir.iter().any(|h| h.column_id == *id))
3861            .collect();
3862        for id in present {
3863            user.insert(id, self.column_native(id)?);
3864        }
3865        let i64_at = |col: &columnar::NativeColumn, i: usize| -> i64 {
3866            match col {
3867                columnar::NativeColumn::Int64 { data, .. } => data.get(i).copied().unwrap_or(0),
3868                _ => 0,
3869            }
3870        };
3871        let bool_at_native = |col: &columnar::NativeColumn, i: usize| -> bool {
3872            match col {
3873                columnar::NativeColumn::Bool { data, .. } => {
3874                    data.get(i).copied().map(|b| b != 0).unwrap_or(false)
3875                }
3876                _ => false,
3877            }
3878        };
3879        let mut rows = Vec::with_capacity(indices.len());
3880        for &idx in indices {
3881            let row_id = RowId(i64_at(&rid_col, idx) as u64);
3882            let epoch = Epoch(i64_at(&epoch_col, idx) as u64);
3883            let deleted = bool_at_native(&del_col, idx);
3884            let mut columns = HashMap::with_capacity(self.schema.columns.len());
3885            for cdef in self.schema.columns.iter() {
3886                let val = match user.get(&cdef.id) {
3887                    Some(col) => col.value_at(idx).unwrap_or(Value::Null),
3888                    None => Value::Null,
3889                };
3890                columns.insert(cdef.id, val);
3891            }
3892            rows.push(Row {
3893                row_id,
3894                committed_epoch: epoch,
3895                columns,
3896                deleted,
3897            });
3898        }
3899        Ok(rows)
3900    }
3901}
3902
3903fn int_at(vals: &[Value], i: usize) -> u64 {
3904    match vals.get(i) {
3905        Some(Value::Int64(x)) => *x as u64,
3906        _ => 0,
3907    }
3908}
3909
3910fn bool_at(vals: &[Value], i: usize) -> bool {
3911    matches!(vals.get(i), Some(Value::Bool(true)))
3912}
3913
3914#[cfg(test)]
3915mod tests {
3916    use super::*;
3917    use crate::columnar::NativeColumn;
3918    use crate::memtable::Value;
3919    use crate::rowid::RowId;
3920    use crate::schema::{ColumnDef, ColumnFlags};
3921    use tempfile::tempdir;
3922
3923    fn schema() -> Schema {
3924        Schema {
3925            schema_id: 1,
3926            columns: vec![
3927                ColumnDef {
3928                    id: 1,
3929                    name: "id".into(),
3930                    ty: TypeId::Int64,
3931                    flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
3932                    default_value: None,
3933                },
3934                ColumnDef {
3935                    id: 2,
3936                    name: "name".into(),
3937                    ty: TypeId::Bytes,
3938                    flags: ColumnFlags::empty(),
3939                    default_value: None,
3940                },
3941            ],
3942            indexes: Vec::new(),
3943            colocation: vec![],
3944            constraints: Default::default(),
3945            clustered: false,
3946        }
3947    }
3948
3949    fn rows() -> Vec<Row> {
3950        vec![
3951            Row::new(RowId(1), Epoch(10))
3952                .with_column(1, Value::Int64(1))
3953                .with_column(2, Value::Bytes(b"alice".to_vec())),
3954            Row::new(RowId(2), Epoch(10))
3955                .with_column(1, Value::Int64(2))
3956                .with_column(2, Value::Bytes(b"bob".to_vec())),
3957            Row::new(RowId(3), Epoch(10))
3958                .with_column(1, Value::Int64(3))
3959                .with_column(2, Value::Bytes(b"carol".to_vec())),
3960        ]
3961    }
3962
3963    #[test]
3964    fn flush_then_read_mvcc() {
3965        let dir = tempdir().unwrap();
3966        let path = dir.path().join("r-1.sr");
3967        let header = RunWriter::new(&schema(), 1, Epoch(10), 0)
3968            .write(&path, &rows())
3969            .unwrap();
3970        assert_eq!(header.row_count, 3);
3971
3972        let mut r = RunReader::open(&path, schema(), None).unwrap();
3973        // Point lookup.
3974        let (e, row) = r.get_version(RowId(2), Epoch(20)).unwrap().unwrap();
3975        assert_eq!(e, Epoch(10));
3976        assert_eq!(row.row_id, RowId(2));
3977        assert!(matches!(row.columns.get(&2), Some(Value::Bytes(_))));
3978        // Missing.
3979        assert!(r.get_version(RowId(99), Epoch(20)).unwrap().is_none());
3980        // Scan.
3981        let all = r.visible_rows(Epoch(20)).unwrap();
3982        assert_eq!(all.len(), 3);
3983    }
3984
3985    #[test]
3986    fn visible_version_cursor_preserves_order_snapshot_and_tombstones() {
3987        let dir = tempdir().unwrap();
3988        let path = dir.path().join("r-cursor.sr");
3989        let mut tombstone = Row::new(RowId(2), Epoch(2));
3990        tombstone.deleted = true;
3991        let versions = vec![
3992            Row::new(RowId(1), Epoch(4)).with_column(1, Value::Int64(10)),
3993            Row::new(RowId(2), Epoch(1)).with_column(1, Value::Int64(2)),
3994            tombstone,
3995            Row::new(RowId(3), Epoch(1)).with_column(1, Value::Int64(20)),
3996            Row::new(RowId(3), Epoch(3)).with_column(1, Value::Int64(23)),
3997            Row::new(RowId(4), Epoch(4)).with_column(1, Value::Int64(30)),
3998        ];
3999        RunWriter::new(&schema(), 7, Epoch(4), 0)
4000            .write(&path, &versions)
4001            .unwrap();
4002
4003        let control = crate::ExecutionControl::new(None);
4004        let mut cursor = RunReader::open(&path, schema(), None)
4005            .unwrap()
4006            .into_visible_version_cursor(Epoch(2))
4007            .unwrap();
4008        let first = cursor.next_visible_version(&control).unwrap().unwrap();
4009        assert_eq!(first.row_id, RowId(2));
4010        assert!(first.deleted);
4011        assert_eq!(first.committed_epoch, Epoch(2));
4012        let second = cursor.next_visible_version(&control).unwrap().unwrap();
4013        assert_eq!(second.row_id, RowId(3));
4014        assert_eq!(second.committed_epoch, Epoch(1));
4015        let row = cursor.materialize(second, &control).unwrap();
4016        assert_eq!(row.columns.get(&1), Some(&Value::Int64(20)));
4017        assert!(cursor.next_visible_version(&control).unwrap().is_none());
4018    }
4019
4020    #[test]
4021    fn learned_index_was_stored() {
4022        let dir = tempdir().unwrap();
4023        let path = dir.path().join("r-2.sr");
4024        RunWriter::new(&schema(), 2, Epoch(1), 0)
4025            .write(&path, &rows())
4026            .unwrap();
4027        let header = read_header(&path).unwrap();
4028        assert!(
4029            header.index_trailer_offset != 0,
4030            "trailer should be present"
4031        );
4032    }
4033
4034    #[test]
4035    fn low_level_container_still_round_trips() {
4036        let dir = tempdir().unwrap();
4037        let path = dir.path().join("r-3.sr");
4038        let cols = vec![ColumnPayload {
4039            column_id: 1,
4040            type_id_tag: 8,
4041            encoding: Encoding::Plain,
4042            pages: vec![vec![1, 2, 3, 4]],
4043            page_stats: Vec::new(),
4044        }];
4045        let header = write_run(
4046            &path,
4047            &RunSpec {
4048                run_id: 1,
4049                schema_id: 1,
4050                epoch_created: 1,
4051                level: 0,
4052                flags: 0,
4053                sort_key_column_id: SORT_KEY_ROW_ID,
4054                row_count: 1,
4055                min_row_id: 0,
4056                max_row_id: 0,
4057                columns: &cols,
4058            },
4059        )
4060        .unwrap();
4061        let back = read_header(&path).unwrap();
4062        assert_eq!(back.content_hash, header.content_hash);
4063        assert_eq!(read_column_dir(&path, &back).unwrap().len(), 1);
4064    }
4065
4066    #[test]
4067    fn detects_corruption() {
4068        let dir = tempdir().unwrap();
4069        let path = dir.path().join("r-4.sr");
4070        write_run(
4071            &path,
4072            &RunSpec {
4073                run_id: 1,
4074                schema_id: 1,
4075                epoch_created: 1,
4076                level: 0,
4077                flags: 0,
4078                sort_key_column_id: SORT_KEY_ROW_ID,
4079                row_count: 1,
4080                min_row_id: 0,
4081                max_row_id: 0,
4082                columns: &[ColumnPayload {
4083                    column_id: 1,
4084                    type_id_tag: 8,
4085                    encoding: Encoding::Plain,
4086                    pages: vec![vec![1, 2, 3]],
4087                    page_stats: Vec::new(),
4088                }],
4089            },
4090        )
4091        .unwrap();
4092        let mut bytes = std::fs::read(&path).unwrap();
4093        bytes[300] ^= 0xFF;
4094        std::fs::write(&path, bytes).unwrap();
4095        let err = read_header(&path).unwrap_err();
4096        assert!(
4097            matches!(err, MongrelError::ChecksumMismatch { .. }),
4098            "got {err:?}"
4099        );
4100    }
4101
4102    /// Phase 14.6: the direct-to-mmap placement logic must produce a byte-
4103    /// identical run to the in-buffer fallback. We drive `plan_run` + `place_run`
4104    /// into a plain `Vec<u8>` (so it's testable even where file mmap is denied —
4105    /// e.g. this sandbox), compare against `write_run_vec`, and additionally
4106    /// check that `write_run_mmap` agrees wherever a mapping can be created.
4107    #[test]
4108    fn mmap_and_vec_writers_are_byte_identical() {
4109        let dir = tempdir().unwrap();
4110        let stats = vec![PageStat {
4111            first_row_id: 0,
4112            last_row_id: 1,
4113            null_count: 0,
4114            row_count: 2,
4115            min: Some(vec![0]),
4116            max: Some(vec![9]),
4117            offset: 0,
4118            compressed_len: 0,
4119            uncompressed_len: 0,
4120        }];
4121        let spec = RunSpec {
4122            run_id: 7,
4123            schema_id: 1,
4124            epoch_created: 10,
4125            level: 0,
4126            flags: 0,
4127            sort_key_column_id: SORT_KEY_ROW_ID,
4128            row_count: 2,
4129            min_row_id: 0,
4130            max_row_id: 1,
4131            columns: &[
4132                ColumnPayload {
4133                    column_id: 1,
4134                    type_id_tag: 8,
4135                    encoding: Encoding::Plain,
4136                    pages: vec![vec![1, 2, 3, 4], vec![5, 6, 7, 8]],
4137                    page_stats: stats.clone(),
4138                },
4139                ColumnPayload {
4140                    column_id: 2,
4141                    type_id_tag: 8,
4142                    encoding: Encoding::Plain,
4143                    pages: vec![vec![10, 20], vec![30, 40]],
4144                    page_stats: stats.clone(),
4145                },
4146            ],
4147        };
4148        let trailer = b"learned-trailer-bytes";
4149
4150        // (1) placement path into a Vec-backed buffer.
4151        let plan = plan_run(&spec, None, Some(trailer)).expect("plan");
4152        let mut buf = vec![0u8; plan.total];
4153        let h_place = place_run(&spec, None, Some(trailer), &plan, &mut buf)
4154            .expect("place_run into Vec succeeds");
4155
4156        // (2) in-buffer fallback writer to a real file.
4157        let path_vec = dir.path().join("vec.sr");
4158        let h_vec = write_run_vec(&path_vec, &spec, None, Some(trailer)).unwrap();
4159        let bv = std::fs::read(&path_vec).unwrap();
4160
4161        assert_eq!(h_place.content_hash, h_vec.content_hash);
4162        assert_eq!(h_place.footer_offset, h_vec.footer_offset);
4163        assert_eq!(
4164            buf, bv,
4165            "place_run and write_run_vec must be byte-identical"
4166        );
4167        assert!(read_header(&path_vec).is_ok());
4168
4169        // (3) the mmap writer, when the FS supports it, must also agree. Where
4170        // file mmap is denied (some sandboxes), it reports the fallback sentinel
4171        // and `write_run_with` transparently uses the vec path instead.
4172        let path_mmap = dir.path().join("mmap.sr");
4173        match write_run_mmap(&path_mmap, &spec, None, Some(trailer)) {
4174            Ok(h_mmap) => {
4175                let bm = std::fs::read(&path_mmap).unwrap();
4176                assert_eq!(bm, bv, "mmap run must be byte-identical to vec run");
4177                assert_eq!(h_mmap.content_hash, h_vec.content_hash);
4178            }
4179            Err(e) if is_mmap_unavailable(&e) => {
4180                eprintln!("note: file mmap unavailable here; vec path covered (1)/(2)");
4181            }
4182            Err(e) => panic!("unexpected mmap error: {e:?}"),
4183        }
4184    }
4185
4186    /// Phase 15.1: the `&self` parallel-decode path (`column_native_shared`)
4187    /// must yield the same `NativeColumn` as the `&mut` `column_native` path,
4188    /// column for column, on a multi-page run. This guards the cross-column
4189    /// parallel scan used by `visible_columns_native`.
4190    #[test]
4191    fn column_native_shared_matches_column_native() {
4192        let dir = tempdir().unwrap();
4193        let path = dir.path().join("r.sr");
4194        // Enough rows to span >1 page (PAGE_ROWS = 65 536) so the parallel page
4195        // branch runs, plus a partial tail page.
4196        let n = 65_536 * 2 + 9;
4197        let id_col = NativeColumn::int64_sequence(1, n);
4198        let mut offsets = vec![0u32];
4199        let mut values = Vec::new();
4200        for i in 0..n {
4201            values.extend_from_slice(format!("v{}", i % 17).as_bytes());
4202            offsets.push(values.len() as u32);
4203        }
4204        let name_col = NativeColumn::Bytes {
4205            offsets,
4206            values,
4207            validity: vec![0xFF; n.div_ceil(8)],
4208        };
4209        let header = RunWriter::new(&schema(), 9, Epoch(5), 0)
4210            .write_native(&path, &[(1, id_col), (2, name_col)], n, 1)
4211            .unwrap();
4212
4213        let mut reader = RunReader::open_with_cache(&path, schema(), None, None, None, 0, None)
4214            .expect("open reader");
4215        assert!(reader.has_mmap(), "test env must support read-only mmap");
4216        assert_eq!(reader.row_count(), header.row_count as usize);
4217
4218        for cid in [1u16, 2] {
4219            let a = reader.column_native(cid).expect("column_native");
4220            let b = reader
4221                .column_native_shared(cid)
4222                .expect("column_native_shared");
4223            assert_eq!(a.len(), b.len(), "len mismatch col {cid}");
4224            // Byte-level equality of the typed buffers.
4225            match (&a, &b) {
4226                (
4227                    NativeColumn::Int64 {
4228                        data: da,
4229                        validity: va,
4230                    },
4231                    NativeColumn::Int64 {
4232                        data: db,
4233                        validity: vb,
4234                    },
4235                ) => {
4236                    assert_eq!(da, db, "Int64 data col {cid}");
4237                    assert_eq!(va, vb, "Int64 validity col {cid}");
4238                }
4239                (
4240                    NativeColumn::Bytes {
4241                        offsets: oa,
4242                        values: ua,
4243                        validity: va,
4244                    },
4245                    NativeColumn::Bytes {
4246                        offsets: ob,
4247                        values: ub,
4248                        validity: vb,
4249                    },
4250                ) => {
4251                    assert_eq!(oa, ob, "Bytes offsets col {cid}");
4252                    assert_eq!(ua, ub, "Bytes values col {cid}");
4253                    assert_eq!(va, vb, "Bytes validity col {cid}");
4254                }
4255                _ => panic!("type mismatch col {cid}: {a:?} vs {b:?}"),
4256            }
4257        }
4258    }
4259
4260    #[test]
4261    fn page_cache_key_distinguishes_tables() {
4262        let a = page_cache_key(1, 5, 2, 3);
4263        let b = page_cache_key(2, 5, 2, 3);
4264        assert_ne!(a, b, "same run/col/page, different table must differ");
4265        // same table different run/col/page also differ (sanity)
4266        assert_ne!(page_cache_key(1, 5, 2, 3), page_cache_key(1, 6, 2, 3));
4267        assert_ne!(page_cache_key(1, 5, 2, 3), page_cache_key(1, 5, 2, 4));
4268    }
4269}