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