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