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