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