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.
2982        let mut best: Option<(u64, Option<HlcTimestamp>, usize, usize)> = None; // (epoch, commit_ts, page_seq, local index)
2983        for (seq, _page_row_start) in candidate_pages {
2984            let page_rows = self.find_header(SYS_ROW_ID)?.page_stats[seq].row_count as usize;
2985            let row_ids =
2986                match self.decode_page_native_cached(ty.clone(), SYS_ROW_ID, seq, page_rows)? {
2987                    columnar::NativeColumn::Int64 { data, .. } => data,
2988                    _ => return Err(MongrelError::InvalidArgument("sys row_id not int64".into())),
2989                };
2990            let local = match row_ids.binary_search(&target) {
2991                Ok(i) => i,
2992                Err(_) => continue,
2993            };
2994            let epoch_ty = self.resolve_type(SYS_EPOCH);
2995            let epochs =
2996                match self.decode_page_native_cached(epoch_ty, SYS_EPOCH, seq, page_rows)? {
2997                    columnar::NativeColumn::Int64 { data, .. } => data,
2998                    _ => return Err(MongrelError::InvalidArgument("sys epoch not int64".into())),
2999                };
3000            // `local` is one match; the row-id's full version group is the
3001            // contiguous run of equal row-ids around it within this page.
3002            let mut lo = local;
3003            while lo > 0 && row_ids[lo - 1] == target {
3004                lo -= 1;
3005            }
3006            let mut hi = local;
3007            while hi + 1 < row_ids.len() && row_ids[hi + 1] == target {
3008                hi += 1;
3009            }
3010            // Optionally decode SYS_COMMIT_TS for this page (P0.5-T3). The
3011            // decode is amortized across the whole group loop, and skipped
3012            // entirely for runs that don't carry the column.
3013            let commit_ts_native = if has_commit_ts_col {
3014                let page = self.read_page(SYS_COMMIT_TS, seq)?;
3015                Some(columnar::decode_page_native(
3016                    TypeId::Bytes,
3017                    &page,
3018                    page_rows,
3019                )?)
3020            } else {
3021                None
3022            };
3023            for (i, &epoch_val) in epochs[lo..=hi].iter().enumerate() {
3024                let epoch = epoch_val as u64;
3025                let commit_ts = commit_ts_native
3026                    .as_ref()
3027                    .and_then(|col| decode_commit_ts_value(col.value_at(lo + i).as_ref()));
3028                // `observes_row` is the HLC-aware predicate: when the snapshot
3029                // is HLC-pinned, an HLC-stamped candidate whose HLC is within
3030                // the snapshot wins regardless of its local epoch.
3031                if !snapshot.observes_row(Epoch(epoch), commit_ts) {
3032                    continue;
3033                }
3034                let candidate = (epoch, commit_ts, seq, lo + i);
3035                let is_newer = best.as_ref().is_none_or(|cur| {
3036                    Snapshot::version_is_newer(Epoch(candidate.0), candidate.1, Epoch(cur.0), cur.1)
3037                });
3038                if is_newer {
3039                    best = Some(candidate);
3040                }
3041            }
3042        }
3043        Ok(best)
3044    }
3045
3046    /// Like [`Self::get_version`], but decodes only `column_id` (plus the
3047    /// `SYS_DELETED` flag) instead of materializing every schema column via
3048    /// [`Self::materialize_in_page`]. For a wide schema this avoids paying to
3049    /// decode every other column's page just to read one value and throw the
3050    /// rest away — e.g. `Table::remove_hot_for_row`'s PK-only lookup, which
3051    /// used to pull the whole row (every column, every page) just for the
3052    /// primary key.
3053    pub fn get_version_column(
3054        &mut self,
3055        row_id: RowId,
3056        snapshot: Epoch,
3057        column_id: u16,
3058    ) -> Result<Option<(Epoch, bool, Option<Value>)>> {
3059        self.get_version_column_at(row_id, Snapshot::at(snapshot), column_id)
3060    }
3061
3062    /// Full-Snapshot variant of [`Self::get_version_column`].
3063    pub fn get_version_column_at(
3064        &mut self,
3065        row_id: RowId,
3066        snapshot: Snapshot,
3067        column_id: u16,
3068    ) -> Result<Option<(Epoch, bool, Option<Value>)>> {
3069        let Some((epoch, _commit_ts, seq, local_index)) =
3070            self.find_version_page_at(row_id, snapshot)?
3071        else {
3072            return Ok(None);
3073        };
3074        let page_rows = self.find_header(SYS_ROW_ID)?.page_stats[seq].row_count as usize;
3075        let page_start: usize = self.find_header(SYS_ROW_ID)?.page_stats[..seq]
3076            .iter()
3077            .map(|s| s.row_count as usize)
3078            .sum();
3079        let global_index = page_start + local_index;
3080        let native_at = |slf: &mut Self, cid: u16| -> Result<Option<Value>> {
3081            if !slf.dir.iter().any(|h| h.column_id == cid) {
3082                return Ok(None);
3083            }
3084            let ty = slf.resolve_type(cid);
3085            if !matches!(
3086                ty,
3087                TypeId::Bool
3088                    | TypeId::Int8
3089                    | TypeId::Int16
3090                    | TypeId::Int32
3091                    | TypeId::Int64
3092                    | TypeId::UInt8
3093                    | TypeId::UInt16
3094                    | TypeId::UInt32
3095                    | TypeId::UInt64
3096                    | TypeId::Float32
3097                    | TypeId::Float64
3098                    | TypeId::TimestampNanos
3099                    | TypeId::Date32
3100                    | TypeId::Bytes
3101            ) {
3102                return Ok(slf.column(cid)?.get(global_index).cloned());
3103            }
3104            Ok(slf
3105                .decode_page_native_cached(ty, cid, seq, page_rows)?
3106                .value_at(local_index))
3107        };
3108        let deleted = matches!(native_at(self, SYS_DELETED)?, Some(Value::Bool(true)));
3109        let value = native_at(self, column_id)?;
3110        Ok(Some((Epoch(epoch), deleted, value)))
3111    }
3112
3113    /// Metadata-preserving counterpart of [`Self::get_version_column_at`]
3114    /// (REM-001). Returns `commit_ts` so the caller can compare candidates
3115    /// across runs with [`VersionStamp::is_newer_than`] instead of falling
3116    /// back to epoch-only ordering.
3117    pub(crate) fn get_version_column_full_at(
3118        &mut self,
3119        row_id: RowId,
3120        snapshot: Snapshot,
3121        column_id: u16,
3122    ) -> Result<Option<VersionedColumnValue>> {
3123        let Some((epoch, commit_ts, seq, local_index)) =
3124            self.find_version_page_at(row_id, snapshot)?
3125        else {
3126            return Ok(None);
3127        };
3128        let page_rows = self.find_header(SYS_ROW_ID)?.page_stats[seq].row_count as usize;
3129        let page_start: usize = self.find_header(SYS_ROW_ID)?.page_stats[..seq]
3130            .iter()
3131            .map(|s| s.row_count as usize)
3132            .sum();
3133        let global_index = page_start + local_index;
3134        let native_at = |slf: &mut Self, cid: u16| -> Result<Option<Value>> {
3135            if !slf.dir.iter().any(|h| h.column_id == cid) {
3136                return Ok(None);
3137            }
3138            let ty = slf.resolve_type(cid);
3139            if !matches!(
3140                ty,
3141                TypeId::Bool
3142                    | TypeId::Int8
3143                    | TypeId::Int16
3144                    | TypeId::Int32
3145                    | TypeId::Int64
3146                    | TypeId::UInt8
3147                    | TypeId::UInt16
3148                    | TypeId::UInt32
3149                    | TypeId::UInt64
3150                    | TypeId::Float32
3151                    | TypeId::Float64
3152                    | TypeId::TimestampNanos
3153                    | TypeId::Date32
3154                    | TypeId::Bytes
3155            ) {
3156                return Ok(slf.column(cid)?.get(global_index).cloned());
3157            }
3158            Ok(slf
3159                .decode_page_native_cached(ty, cid, seq, page_rows)?
3160                .value_at(local_index))
3161        };
3162        let deleted = matches!(native_at(self, SYS_DELETED)?, Some(Value::Bool(true)));
3163        let value = native_at(self, column_id)?;
3164        Ok(Some(VersionedColumnValue {
3165            stamp: VersionStamp {
3166                epoch: Epoch(epoch),
3167                commit_ts,
3168            },
3169            deleted,
3170            value,
3171        }))
3172    }
3173
3174    /// Newest version epoch and tombstone flag without decoding a user column.
3175    pub fn get_version_visibility(
3176        &mut self,
3177        row_id: RowId,
3178        snapshot: Epoch,
3179    ) -> Result<Option<(Epoch, bool)>> {
3180        self.get_version_visibility_at(row_id, Snapshot::at(snapshot))
3181    }
3182
3183    /// Full-Snapshot variant of [`Self::get_version_visibility`].
3184    pub fn get_version_visibility_at(
3185        &mut self,
3186        row_id: RowId,
3187        snapshot: Snapshot,
3188    ) -> Result<Option<(Epoch, bool)>> {
3189        let Some((epoch, _commit_ts, seq, local_index)) =
3190            self.find_version_page_at(row_id, snapshot)?
3191        else {
3192            return Ok(None);
3193        };
3194        let page_rows = self.find_header(SYS_ROW_ID)?.page_stats[seq].row_count as usize;
3195        let deleted = match self.decode_page_native_cached(
3196            self.resolve_type(SYS_DELETED),
3197            SYS_DELETED,
3198            seq,
3199            page_rows,
3200        )? {
3201            columnar::NativeColumn::Bool { data, .. } => data[local_index] != 0,
3202            _ => return Err(MongrelError::InvalidArgument("sys deleted not bool".into())),
3203        };
3204        Ok(Some((Epoch(epoch), deleted)))
3205    }
3206
3207    /// Metadata-preserving counterpart of [`Self::get_version_visibility_at`]
3208    /// (REM-001). Returns `commit_ts` so cross-run folds can compare
3209    /// candidates by full version stamp rather than epoch alone.
3210    pub(crate) fn get_version_visibility_full_at(
3211        &mut self,
3212        row_id: RowId,
3213        snapshot: Snapshot,
3214    ) -> Result<Option<VersionVisibility>> {
3215        let Some((epoch, commit_ts, seq, local_index)) =
3216            self.find_version_page_at(row_id, snapshot)?
3217        else {
3218            return Ok(None);
3219        };
3220        let page_rows = self.find_header(SYS_ROW_ID)?.page_stats[seq].row_count as usize;
3221        let deleted = match self.decode_page_native_cached(
3222            self.resolve_type(SYS_DELETED),
3223            SYS_DELETED,
3224            seq,
3225            page_rows,
3226        )? {
3227            columnar::NativeColumn::Bool { data, .. } => data[local_index] != 0,
3228            _ => return Err(MongrelError::InvalidArgument("sys deleted not bool".into())),
3229        };
3230        Ok(Some(VersionVisibility {
3231            stamp: VersionStamp {
3232                epoch: Epoch(epoch),
3233                commit_ts,
3234            },
3235            deleted,
3236        }))
3237    }
3238
3239    /// Build a `Row` from page `seq`'s data at `local_index`, decoding only
3240    /// that one page per column instead of [`Self::materialize`]'s whole-column
3241    /// `Vec<Value>` decode — used by [`Self::get_version`], which already knows
3242    /// the exact page from its page-pruned search. Only for scalar types with a
3243    /// [`columnar::NativeColumn`] representation; any other column (e.g. a
3244    /// fixed-size `Embedding`, which `NativeColumn` has no variant for) falls
3245    /// back to the whole-column path for that column specifically, so this is
3246    /// never wrong, only sometimes not faster.
3247    fn materialize_in_page(&mut self, seq: usize, local_index: usize) -> Result<Row> {
3248        let page_rows = self.find_header(SYS_ROW_ID)?.page_stats[seq].row_count as usize;
3249        let page_start: usize = self.find_header(SYS_ROW_ID)?.page_stats[..seq]
3250            .iter()
3251            .map(|s| s.row_count as usize)
3252            .sum();
3253        let global_index = page_start + local_index;
3254        let native_at = |slf: &mut Self, column_id: u16| -> Result<Option<Value>> {
3255            // Absent column (schema evolution: added after this run was
3256            // written) reads as null, matching `materialize`'s own guard.
3257            if !slf.dir.iter().any(|h| h.column_id == column_id) {
3258                return Ok(None);
3259            }
3260            let ty = slf.resolve_type(column_id);
3261            if !matches!(
3262                ty,
3263                TypeId::Bool
3264                    | TypeId::Int8
3265                    | TypeId::Int16
3266                    | TypeId::Int32
3267                    | TypeId::Int64
3268                    | TypeId::UInt8
3269                    | TypeId::UInt16
3270                    | TypeId::UInt32
3271                    | TypeId::UInt64
3272                    | TypeId::Float32
3273                    | TypeId::Float64
3274                    | TypeId::TimestampNanos
3275                    | TypeId::Date32
3276                    | TypeId::Bytes
3277            ) {
3278                // Not a NativeColumn-representable scalar (e.g. Embedding) —
3279                // fall back to the always-correct whole-column decode.
3280                return Ok(slf.column(column_id)?.get(global_index).cloned());
3281            }
3282            Ok(slf
3283                .decode_page_native_cached(ty, column_id, seq, page_rows)?
3284                .value_at(local_index))
3285        };
3286        let row_id = RowId(match native_at(self, SYS_ROW_ID)? {
3287            Some(Value::Int64(x)) => x as u64,
3288            _ => 0,
3289        });
3290        let committed_epoch = Epoch(match native_at(self, SYS_EPOCH)? {
3291            Some(Value::Int64(x)) => x as u64,
3292            _ => 0,
3293        });
3294        let deleted = matches!(native_at(self, SYS_DELETED)?, Some(Value::Bool(true)));
3295        let commit_ts = if self.has_column(SYS_COMMIT_TS) {
3296            decode_commit_ts_value(native_at(self, SYS_COMMIT_TS)?.as_ref())
3297        } else {
3298            None
3299        };
3300        let col_ids: Vec<u16> = self.schema.columns.iter().map(|c| c.id).collect();
3301        let mut columns = HashMap::new();
3302        for id in col_ids {
3303            columns.insert(id, native_at(self, id)?.unwrap_or(Value::Null));
3304        }
3305        Ok(Row {
3306            row_id,
3307            committed_epoch,
3308            columns,
3309            deleted,
3310            commit_ts,
3311        })
3312    }
3313
3314    /// Every row in the run (all versions), in `(RowId, Epoch)` order. Used by
3315    /// compaction, which must see every version to apply snapshot retention.
3316    pub fn all_rows(&mut self) -> Result<Vec<Row>> {
3317        let n = self.row_count();
3318        let mut out = Vec::with_capacity(n);
3319        for i in 0..n {
3320            out.push(self.materialize(i)?);
3321        }
3322        Ok(out)
3323    }
3324
3325    /// Visit every version in the run, exposing only the system columns
3326    /// (`SYS_ROW_ID`, `SYS_EPOCH`, `SYS_DELETED`, `SYS_COMMIT_TS`) without
3327    /// materializing any user column. Used by the run-lookup directory
3328    /// rebuild path so a checkpoint rebuild never pays to decode user data
3329    /// pages (spec §8.4 step 4). The visitor returns `Ok(())` to continue or
3330    /// any other `Result` to short-circuit.
3331    pub fn for_each_system<F>(&mut self, mut visit: F) -> Result<()>
3332    where
3333        F: FnMut(RowId, Epoch, Option<HlcTimestamp>, bool) -> Result<()>,
3334    {
3335        // Cache the system columns once so the visitor can fire without
3336        // re-decoding every page. A run with 256M rows still pays the
3337        // page-decoding cost, but no user-column decode is ever performed.
3338        let row_ids = self.column(SYS_ROW_ID)?.to_vec();
3339        let epochs = self.column(SYS_EPOCH)?.to_vec();
3340        let deleted = self.column(SYS_DELETED)?.to_vec();
3341        let has_commit_ts = self.has_column(SYS_COMMIT_TS);
3342        let commit_ts: Vec<Option<HlcTimestamp>> = if has_commit_ts {
3343            let col = self.column(SYS_COMMIT_TS)?;
3344            let mut out = Vec::with_capacity(col.len());
3345            for v in col {
3346                out.push(decode_commit_ts_value(Some(v)));
3347            }
3348            out
3349        } else {
3350            Vec::new()
3351        };
3352        debug_assert_eq!(row_ids.len(), epochs.len());
3353        debug_assert_eq!(row_ids.len(), deleted.len());
3354        for (i, rid_val) in row_ids.iter().enumerate() {
3355            let rid = match rid_val {
3356                Value::Int64(n) => RowId(*n as u64),
3357                _ => continue,
3358            };
3359            let epoch = match epochs[i] {
3360                Value::Int64(n) => Epoch(n as u64),
3361                _ => Epoch(0),
3362            };
3363            let ts = commit_ts.get(i).copied().flatten();
3364            let del = match deleted[i] {
3365                Value::Bool(b) => b,
3366                _ => false,
3367            };
3368            visit(rid, epoch, ts, del)?;
3369        }
3370        Ok(())
3371    }
3372
3373    /// Every version with cooperative cancellation and a hard row bound.
3374    pub fn all_rows_controlled(
3375        &mut self,
3376        control: &crate::ExecutionControl,
3377        max_rows: usize,
3378    ) -> Result<Vec<Row>> {
3379        let n = self.row_count();
3380        if n > max_rows {
3381            return Err(MongrelError::ResourceLimitExceeded {
3382                resource: "controlled run row materialization",
3383                requested: n,
3384                limit: max_rows,
3385            });
3386        }
3387        let mut out = Vec::with_capacity(n);
3388        for i in 0..n {
3389            if i % 256 == 0 {
3390                control.checkpoint()?;
3391            }
3392            out.push(self.materialize(i)?);
3393        }
3394        control.checkpoint()?;
3395        Ok(out)
3396    }
3397
3398    /// Indices of the newest non-deleted version per `RowId` visible at
3399    /// `snapshot`, ascending. This is the columnar scan primitive: compute the
3400    /// visible set once (one pass over the row-id/epoch/deleted columns), then
3401    /// [`Self::gather_column`] each user column at these indices — no per-row
3402    /// `HashMap`/`Row` materialization.
3403    pub fn visible_indices(&mut self, snapshot: Epoch) -> Result<Vec<usize>> {
3404        self.visible_indices_at(Snapshot::at(snapshot))
3405    }
3406
3407    /// Full-Snapshot variant of [`Self::visible_indices`]: visibility uses
3408    /// [`Snapshot::observes_row`] and `version_is_newer` so an HLC-pinned
3409    /// snapshot returns the HLC-visible winner.
3410    pub fn visible_indices_at(&mut self, snapshot: Snapshot) -> Result<Vec<usize>> {
3411        let n = self.row_count();
3412        if n == 0 {
3413            return Ok(Vec::new());
3414        }
3415        let row_ids = self.column(SYS_ROW_ID)?.to_vec();
3416        let epochs = self.column(SYS_EPOCH)?.to_vec();
3417        let deleted = self.column(SYS_DELETED)?.to_vec();
3418        let commit_ts_native = if self.has_column(SYS_COMMIT_TS) {
3419            Some(self.column(SYS_COMMIT_TS)?.to_vec())
3420        } else {
3421            None
3422        };
3423        let mut best: HashMap<u64, (u64, Option<HlcTimestamp>, usize)> = HashMap::new();
3424        for i in 0..n {
3425            let rid = int_at(&row_ids, i);
3426            let e = int_at(&epochs, i);
3427            let commit_ts = commit_ts_native
3428                .as_ref()
3429                .and_then(|vals| decode_commit_ts_value(vals.get(i)));
3430            if !snapshot.observes_row(Epoch(e), commit_ts) {
3431                continue;
3432            }
3433            best.entry(rid)
3434                .and_modify(|(be, bts, bi)| {
3435                    if Snapshot::version_is_newer(Epoch(e), commit_ts, Epoch(*be), *bts) {
3436                        *be = e;
3437                        *bts = commit_ts;
3438                        *bi = i;
3439                    }
3440                })
3441                .or_insert((e, commit_ts, i));
3442        }
3443        let mut idxs: Vec<usize> = best.into_values().map(|(_, _, i)| i).collect();
3444        idxs.retain(|&i| !bool_at(&deleted, i));
3445        idxs.sort_unstable();
3446        Ok(idxs)
3447    }
3448
3449    /// Gather `column_id`'s values at the given indices (column cached). Used
3450    /// with [`Self::visible_indices`] for vectorized scans. A column absent from
3451    /// this run (e.g. added via schema evolution after the run was written)
3452    /// yields all-nulls.
3453    pub fn gather_column(&mut self, column_id: u16, indices: &[usize]) -> Result<Vec<Value>> {
3454        if !self.dir.iter().any(|h| h.column_id == column_id) {
3455            return Ok(vec![Value::Null; indices.len()]);
3456        }
3457        let col = self.column(column_id)?;
3458        Ok(indices
3459            .iter()
3460            .map(|&i| col.get(i).cloned().unwrap_or(Value::Null))
3461            .collect())
3462    }
3463
3464    /// Decode a column straight to a typed [`NativeColumn`] (no `Value`),
3465    /// concatenating all pages. A column absent from this run (schema evolution)
3466    /// yields an all-null column. Pages are decoded in parallel (rayon) when the
3467    /// run is mmap-backed and has more than one page; otherwise sequentially.
3468    pub fn column_native(&mut self, column_id: u16) -> Result<columnar::NativeColumn> {
3469        use rayon::prelude::*;
3470        if column_id == SYS_EPOCH {
3471            if let Some(ov) = self.epoch_override {
3472                return Ok(columnar::NativeColumn::int64_constant(
3473                    ov.0 as i64,
3474                    self.row_count(),
3475                ));
3476            }
3477        }
3478        let ty = self.resolve_type(column_id);
3479        let n = self.row_count();
3480        let Some(ch) = self.dir.iter().find(|h| h.column_id == column_id) else {
3481            return Ok(columnar::null_native(ty, n));
3482        };
3483        let page_count = ch.page_count as usize;
3484        let page_rows: Vec<usize> = ch.page_stats.iter().map(|s| s.row_count as usize).collect();
3485        if page_count == 0 {
3486            return Ok(columnar::null_native(ty, n));
3487        }
3488        let parts: Vec<columnar::NativeColumn> = if self.mmap.is_some() && page_count > 1 {
3489            // Parallel decode: each page is an independent region of the shared
3490            // mmap; the cipher (if any) is Sync. Spread the (CPU-bound) decode
3491            // across the rayon thread pool.
3492            let reader: &RunReader = self;
3493            (0..page_count)
3494                .into_par_iter()
3495                .map(|seq| {
3496                    let raw = reader.read_page_shared(column_id, seq)?;
3497                    columnar::decode_page_native(ty.clone(), &raw, page_rows[seq])
3498                })
3499                .collect::<Result<Vec<_>>>()?
3500        } else {
3501            let mut out = Vec::with_capacity(page_count);
3502            for (seq, &pr) in page_rows.iter().enumerate() {
3503                let page = self.read_page(column_id, seq)?;
3504                out.push(columnar::decode_page_native(ty.clone(), &page, pr)?);
3505            }
3506            out
3507        };
3508        Ok(columnar::NativeColumn::concat(&parts))
3509    }
3510
3511    /// Whether this reader is backed by a memory map (the prerequisite for the
3512    /// `&self` parallel decode paths). Readers on filesystems that reject mmap
3513    /// fall back to per-page `read()` and `has_mmap()` is false.
3514    pub fn has_mmap(&self) -> bool {
3515        self.mmap.is_some()
3516    }
3517
3518    /// `&self` variant of [`column_native`] for cross-column parallel scans
3519    /// (Phase 15.1). Requires the mmap backing (uses [`read_page_shared`], which
3520    /// is rayon-safe); callers without mmap use the `&mut` [`column_native`].
3521    /// Pages within the column decode in parallel when there is more than one,
3522    /// and `MADV_WILLNEED` is hinted up front so the kernel pre-faults the whole
3523    /// column's byte range (Phase 15.2) before the decode workers touch it.
3524    pub fn column_native_shared(&self, column_id: u16) -> Result<columnar::NativeColumn> {
3525        use rayon::prelude::*;
3526        if column_id == SYS_EPOCH {
3527            if let Some(ov) = self.epoch_override {
3528                return Ok(columnar::NativeColumn::int64_constant(
3529                    ov.0 as i64,
3530                    self.row_count(),
3531                ));
3532            }
3533        }
3534        let ty = self.resolve_type(column_id);
3535        let n = self.row_count();
3536        let Some(ch) = self.dir.iter().find(|h| h.column_id == column_id) else {
3537            return Ok(columnar::null_native(ty, n));
3538        };
3539        let page_count = ch.page_count as usize;
3540        let page_rows: Vec<usize> = ch.page_stats.iter().map(|s| s.row_count as usize).collect();
3541        if page_count == 0 {
3542            return Ok(columnar::null_native(ty, n));
3543        }
3544        // Phase 15.2: best-effort read-ahead. Tell the kernel to page-in the
3545        // column's full byte range before the workers fan out, overlapping the
3546        // disk I/O with the upcoming decode CPU.
3547        #[cfg(unix)]
3548        {
3549            if let (Some(m), Some(first)) = (&self.mmap, ch.page_stats.first()) {
3550                let start = first.offset as usize;
3551                let end = (ch.page_region_offset as usize) + (ch.page_region_len as usize);
3552                if end > start {
3553                    let _ = m.advise_range(memmap2::Advice::WillNeed, start, end - start);
3554                }
3555            }
3556        }
3557        let run_id = self.header.run_id;
3558        // Decode in parallel (cache probes use `try_lock` → no worker blocking).
3559        // Each item is the decoded page plus its key when it was a cache miss
3560        // (hits return `None` for the key so we don't re-insert/clone them).
3561        let mut parts_keys: Vec<(columnar::NativeColumn, Option<[u8; 32]>)> = if page_count > 1 {
3562            (0..page_count)
3563                .into_par_iter()
3564                .map(|seq| {
3565                    self.decode_page_cached(ty.clone(), column_id, seq, page_rows[seq], run_id)
3566                })
3567                .collect::<Result<Vec<_>>>()?
3568        } else {
3569            vec![self.decode_page_cached(ty, column_id, 0, page_rows[0], run_id)?]
3570        };
3571        // Sequentially cache the freshly-decoded pages — no parallel contention
3572        // on the insert, so every miss is reliably stored for the next scan.
3573        if let Some(cache) = &self.decoded_cache {
3574            for (col, key) in parts_keys.iter_mut() {
3575                if let Some(k) = key.take() {
3576                    cache.lock(&k).insert(k, Arc::new(col.clone()));
3577                }
3578            }
3579        }
3580        let parts: Vec<columnar::NativeColumn> = parts_keys.into_iter().map(|(c, _)| c).collect();
3581        Ok(columnar::NativeColumn::concat(&parts))
3582    }
3583
3584    /// Decode one page for the shared scan path, consulting the decoded-page
3585    /// cache first (Phase 15.4). Returns the decoded page plus `Some(key)` on a
3586    /// cache miss (so the caller can insert it) or `None` on a hit (already
3587    /// cached). Cache probes use `try_lock` so rayon workers never block.
3588    fn decode_page_cached(
3589        &self,
3590        ty: TypeId,
3591        column_id: u16,
3592        seq: usize,
3593        nrows: usize,
3594        run_id: u128,
3595    ) -> Result<(columnar::NativeColumn, Option<[u8; 32]>)> {
3596        let key = page_cache_key(self.table_id, run_id, column_id, seq);
3597        if let Some(cache) = &self.decoded_cache {
3598            if let Some(g) = cache.try_lock(&key) {
3599                if let Some(hit) = g.try_get(&key) {
3600                    return Ok(((*hit).clone(), None));
3601                }
3602            }
3603        }
3604        let raw = self.read_page_shared(column_id, seq)?;
3605        let col = columnar::decode_page_native(ty, &raw, nrows)?;
3606        Ok((col, Some(key)))
3607    }
3608
3609    /// [`Self::decode_page_cached`], but for the sequential point-lookup paths
3610    /// (`find_version_page`, `materialize_in_page`, `get_version_column`) via
3611    /// [`Self::read_page`] (handles the non-mmap-backed fallback) instead of
3612    /// [`Self::read_page_shared`] (mmap-only, for the parallel rayon scan
3613    /// path). No rayon pool to avoid blocking here, so a plain `.lock()` is
3614    /// fine — unlike the scan path's `try_lock`, this always consults the
3615    /// cache rather than skipping it under contention. Without this, every
3616    /// point lookup re-decompressed its page from scratch even when a prior
3617    /// lookup had just decoded the exact same page (the dominant remaining
3618    /// cost measured for `remove_hot_for_row`'s on-disk path).
3619    fn decode_page_native_cached(
3620        &mut self,
3621        ty: TypeId,
3622        column_id: u16,
3623        seq: usize,
3624        nrows: usize,
3625    ) -> Result<columnar::NativeColumn> {
3626        let key = page_cache_key(self.table_id, self.header.run_id, column_id, seq);
3627        if let Some(cache) = &self.decoded_cache {
3628            if let Some(hit) = cache.lock(&key).try_get(&key) {
3629                return Ok((*hit).clone());
3630            }
3631        }
3632        let raw = self.read_page(column_id, seq)?;
3633        let col = columnar::decode_page_native(ty, &raw, nrows)?;
3634        if let Some(cache) = &self.decoded_cache {
3635            cache
3636                .lock(&key)
3637                .insert(key, std::sync::Arc::new(col.clone()));
3638        }
3639        Ok(col)
3640    }
3641
3642    /// Row ids whose Int64 value is in `[lo, hi]`, **skipping pages whose
3643    /// `[min,max]` stat excludes the range** (Parquet-style page-index pruning).
3644    /// Nulls are excluded. Used by `Table::query_columns_native` to serve
3645    /// `Condition::Range` without decoding every page.
3646    pub fn range_row_ids_i64(
3647        &mut self,
3648        column_id: u16,
3649        lo: i64,
3650        hi: i64,
3651    ) -> Result<std::collections::HashSet<u64>> {
3652        Ok(self
3653            .range_row_id_set_i64(column_id, lo, hi)?
3654            .into_sorted_vec()
3655            .into_iter()
3656            .collect())
3657    }
3658
3659    pub(crate) fn range_row_id_set_i64(
3660        &mut self,
3661        column_id: u16,
3662        lo: i64,
3663        hi: i64,
3664    ) -> Result<RowIdSet> {
3665        let info: Vec<(Option<i64>, Option<i64>, usize)> =
3666            match self.dir.iter().find(|h| h.column_id == column_id) {
3667                Some(ch) => ch
3668                    .page_stats
3669                    .iter()
3670                    .map(|s| {
3671                        (
3672                            be_i64(s.min.as_deref()),
3673                            be_i64(s.max.as_deref()),
3674                            s.row_count as usize,
3675                        )
3676                    })
3677                    .collect(),
3678                None => return Ok(RowIdSet::empty()),
3679            };
3680        // Encrypted columns are pruneable only when this run carries the
3681        // decrypted stats envelope (overlaid at open). Without one (a run
3682        // whose writer recorded no stats at all), a missing min/max means
3683        // "unknown" — never prune — whereas with the envelope (and always for
3684        // plaintext runs) a missing min/max means an all-null page.
3685        let stats_pruneable =
3686            !self.col_encrypted(column_id) || self.header.encrypted_stats_offset != 0;
3687        let clean_contiguous = self.clean_contiguous_row_ids();
3688        let mut out = Vec::new();
3689        let mut page_start = 0usize;
3690        for (seq, (mn, mx, nrows)) in info.into_iter().enumerate() {
3691            let current_page_start = page_start;
3692            page_start += nrows;
3693            // Skip pages that cannot contain a match (or are all-null).
3694            let skip = stats_pruneable
3695                && match (mn, mx) {
3696                    (Some(mn), Some(mx)) => mx < lo || mn > hi,
3697                    _ => true,
3698                };
3699            if skip {
3700                continue;
3701            }
3702            let val_page = self.read_page(column_id, seq)?;
3703            let vals =
3704                columnar::decode_page_native(self.resolve_type(column_id), &val_page, nrows)?;
3705            if let columnar::NativeColumn::Int64 { data: v, validity } = vals {
3706                if clean_contiguous {
3707                    for (i, val) in v.iter().enumerate() {
3708                        if columnar::validity_bit(&validity, i) && *val >= lo && *val <= hi {
3709                            out.push(self.header.min_row_id + current_page_start as u64 + i as u64);
3710                        }
3711                    }
3712                } else {
3713                    let rid_page = self.read_page(SYS_ROW_ID, seq)?;
3714                    let rids = columnar::decode_page_native(TypeId::Int64, &rid_page, nrows)?;
3715                    if let columnar::NativeColumn::Int64 { data: r, .. } = rids {
3716                        for (i, val) in v.iter().enumerate() {
3717                            if columnar::validity_bit(&validity, i) && *val >= lo && *val <= hi {
3718                                out.push(r[i] as u64);
3719                            }
3720                        }
3721                    }
3722                }
3723            }
3724        }
3725        Ok(RowIdSet::from_unsorted(out))
3726    }
3727
3728    /// Float64 analogue of [`Self::range_row_ids_i64`] with per-bound
3729    /// inclusivity, for `Condition::RangeF64`.
3730    pub fn range_row_ids_f64(
3731        &mut self,
3732        column_id: u16,
3733        lo: f64,
3734        lo_inclusive: bool,
3735        hi: f64,
3736        hi_inclusive: bool,
3737    ) -> Result<std::collections::HashSet<u64>> {
3738        Ok(self
3739            .range_row_id_set_f64(column_id, lo, lo_inclusive, hi, hi_inclusive)?
3740            .into_sorted_vec()
3741            .into_iter()
3742            .collect())
3743    }
3744
3745    pub(crate) fn range_row_id_set_f64(
3746        &mut self,
3747        column_id: u16,
3748        lo: f64,
3749        lo_inclusive: bool,
3750        hi: f64,
3751        hi_inclusive: bool,
3752    ) -> Result<RowIdSet> {
3753        let info: Vec<(Option<f64>, Option<f64>, usize)> =
3754            match self.dir.iter().find(|h| h.column_id == column_id) {
3755                Some(ch) => ch
3756                    .page_stats
3757                    .iter()
3758                    .map(|s| {
3759                        (
3760                            be_f64(s.min.as_deref()),
3761                            be_f64(s.max.as_deref()),
3762                            s.row_count as usize,
3763                        )
3764                    })
3765                    .collect(),
3766                None => return Ok(RowIdSet::empty()),
3767            };
3768        // Encrypted columns are pruneable only when this run carries the
3769        // decrypted stats envelope (overlaid at open). Without one (a run
3770        // whose writer recorded no stats at all), a missing min/max means
3771        // "unknown" — never prune — whereas with the envelope (and always for
3772        // plaintext runs) a missing min/max means an all-null page.
3773        let stats_pruneable =
3774            !self.col_encrypted(column_id) || self.header.encrypted_stats_offset != 0;
3775        let clean_contiguous = self.clean_contiguous_row_ids();
3776        let mut out = Vec::new();
3777        let mut page_start = 0usize;
3778        for (seq, (mn, mx, nrows)) in info.into_iter().enumerate() {
3779            let current_page_start = page_start;
3780            page_start += nrows;
3781            // A page can be dropped iff every value fails the predicate, i.e. the
3782            // largest fails the lo-test or the smallest fails the hi-test.
3783            let skip = stats_pruneable
3784                && match (mn, mx) {
3785                    (Some(mn), Some(mx)) => {
3786                        let skip_lo = mx < lo || (!lo_inclusive && mx == lo);
3787                        let skip_hi = mn > hi || (!hi_inclusive && mn == hi);
3788                        skip_lo || skip_hi
3789                    }
3790                    _ => true,
3791                };
3792            if skip {
3793                continue;
3794            }
3795            let val_page = self.read_page(column_id, seq)?;
3796            let vals = columnar::decode_page_native(TypeId::Float64, &val_page, nrows)?;
3797            if let columnar::NativeColumn::Float64 { data: v, validity } = vals {
3798                if clean_contiguous {
3799                    for (i, val) in v.iter().enumerate() {
3800                        if !columnar::validity_bit(&validity, i) || val.is_nan() {
3801                            continue;
3802                        }
3803                        let ok_lo = if lo_inclusive { *val >= lo } else { *val > lo };
3804                        let ok_hi = if hi_inclusive { *val <= hi } else { *val < hi };
3805                        if ok_lo && ok_hi {
3806                            out.push(self.header.min_row_id + current_page_start as u64 + i as u64);
3807                        }
3808                    }
3809                } else {
3810                    let rid_page = self.read_page(SYS_ROW_ID, seq)?;
3811                    let rids = columnar::decode_page_native(TypeId::Int64, &rid_page, nrows)?;
3812                    if let columnar::NativeColumn::Int64 { data: r, .. } = rids {
3813                        for (i, val) in v.iter().enumerate() {
3814                            if !columnar::validity_bit(&validity, i) || val.is_nan() {
3815                                continue;
3816                            }
3817                            let ok_lo = if lo_inclusive { *val >= lo } else { *val > lo };
3818                            let ok_hi = if hi_inclusive { *val <= hi } else { *val < hi };
3819                            if ok_lo && ok_hi {
3820                                out.push(r[i] as u64);
3821                            }
3822                        }
3823                    }
3824                }
3825            }
3826        }
3827        Ok(RowIdSet::from_unsorted(out))
3828    }
3829
3830    /// Page-pruned row-id set for `IS NULL` / `IS NOT NULL` on `column_id`.
3831    /// Skips pages whose `null_count` makes a match impossible (no nulls for
3832    /// `IS NULL`, all-nulls for `IS NOT NULL`), then decodes the validity bitmap
3833    /// of surviving pages to pinpoint matching rows.
3834    pub(crate) fn null_row_id_set(&mut self, column_id: u16, want_nulls: bool) -> Result<RowIdSet> {
3835        let stats: Vec<(usize, usize)> = match self.dir.iter().find(|h| h.column_id == column_id) {
3836            Some(ch) => ch
3837                .page_stats
3838                .iter()
3839                .map(|s| (s.null_count as usize, s.row_count as usize))
3840                .collect(),
3841            None => return Ok(RowIdSet::empty()),
3842        };
3843        let ty = self.resolve_type(column_id);
3844        let clean_contiguous = self.clean_contiguous_row_ids();
3845        let mut out = Vec::new();
3846        let mut page_start = 0usize;
3847        for (seq, (null_count, nrows)) in stats.into_iter().enumerate() {
3848            let current_page_start = page_start;
3849            page_start += nrows;
3850            // Skip pages that cannot match.
3851            if want_nulls && null_count == 0 {
3852                continue;
3853            }
3854            if !want_nulls && null_count == nrows {
3855                continue;
3856            }
3857            let val_page = self.read_page(column_id, seq)?;
3858            let col = columnar::decode_page_native(ty.clone(), &val_page, nrows)?;
3859            let validity = col.validity();
3860            if clean_contiguous {
3861                for i in 0..nrows {
3862                    let is_null = !columnar::validity_bit(validity, i);
3863                    if is_null == want_nulls {
3864                        out.push(self.header.min_row_id + current_page_start as u64 + i as u64);
3865                    }
3866                }
3867            } else {
3868                let rid_page = self.read_page(SYS_ROW_ID, seq)?;
3869                let rids = columnar::decode_page_native(TypeId::Int64, &rid_page, nrows)?;
3870                if let columnar::NativeColumn::Int64 { data: r, .. } = rids {
3871                    for (i, &rid) in r.iter().enumerate().take(nrows) {
3872                        let is_null = !columnar::validity_bit(validity, i);
3873                        if is_null == want_nulls {
3874                            out.push(rid as u64);
3875                        }
3876                    }
3877                }
3878            }
3879        }
3880        Ok(RowIdSet::from_unsorted(out))
3881    }
3882
3883    pub fn visible_indices_native(&mut self, snapshot: Epoch) -> Result<Vec<usize>> {
3884        self.visible_indices_native_at(Snapshot::at(snapshot))
3885    }
3886
3887    /// Full-Snapshot variant of [`Self::visible_indices_native`].
3888    pub fn visible_indices_native_at(&mut self, snapshot: Snapshot) -> Result<Vec<usize>> {
3889        let n = self.row_count();
3890        if n == 0 {
3891            return Ok(Vec::new());
3892        }
3893        let (row_ids, epochs, deleted) = self.system_columns_native()?;
3894        let commit_ts_native = if self.has_column(SYS_COMMIT_TS) {
3895            Some(self.column_native_shared(SYS_COMMIT_TS)?)
3896        } else {
3897            None
3898        };
3899        let mut best: HashMap<u64, (u64, Option<HlcTimestamp>, usize)> = HashMap::new();
3900        for i in 0..n {
3901            let rid = row_ids[i] as u64;
3902            let e = epochs[i] as u64;
3903            let commit_ts = commit_ts_native
3904                .as_ref()
3905                .and_then(|col| decode_commit_ts_value(col.value_at(i).as_ref()));
3906            if !snapshot.observes_row(Epoch(e), commit_ts) {
3907                continue;
3908            }
3909            best.entry(rid)
3910                .and_modify(|(be, bts, bi)| {
3911                    if Snapshot::version_is_newer(Epoch(e), commit_ts, Epoch(*be), *bts) {
3912                        *be = e;
3913                        *bts = commit_ts;
3914                        *bi = i;
3915                    }
3916                })
3917                .or_insert((e, commit_ts, i));
3918        }
3919        let mut idxs: Vec<usize> = best.into_values().map(|(_, _, i)| i).collect();
3920        idxs.retain(|&i| deleted[i] == 0);
3921        idxs.sort_unstable();
3922        Ok(idxs)
3923    }
3924
3925    /// Page-pruned, **MVCC-visible** Int64 range resolution (Phase 16.3).
3926    ///
3927    /// Like [`Self::range_row_ids_i64`] (skips pages whose `[min,max]` excludes
3928    /// `[lo, hi]`) but restricts the output to the newest non-deleted version per
3929    /// `RowId` visible at `snapshot`. This is the layout-independent range
3930    /// primitive: correct under any memtable / multi-run / deletion-vector state,
3931    /// so the engine no longer has to fall back to a full-column decode when the
3932    /// "single clean run" invariant doesn't hold. Nulls are excluded.
3933    pub fn range_row_ids_visible_i64(
3934        &mut self,
3935        column_id: u16,
3936        lo: i64,
3937        hi: i64,
3938        snapshot: Epoch,
3939    ) -> Result<Vec<u64>> {
3940        self.range_row_ids_visible_i64_at(column_id, lo, hi, Snapshot::at(snapshot))
3941    }
3942
3943    /// Full-Snapshot variant of [`Self::range_row_ids_visible_i64`].
3944    pub fn range_row_ids_visible_i64_at(
3945        &mut self,
3946        column_id: u16,
3947        lo: i64,
3948        hi: i64,
3949        snapshot: Snapshot,
3950    ) -> Result<Vec<u64>> {
3951        let stats: Vec<(Option<i64>, Option<i64>, usize)> = match self.column_page_stats(column_id)
3952        {
3953            Some(s) => s
3954                .iter()
3955                .map(|st| {
3956                    (
3957                        be_i64(st.min.as_deref()),
3958                        be_i64(st.max.as_deref()),
3959                        st.row_count as usize,
3960                    )
3961                })
3962                .collect(),
3963            None => return Ok(Vec::new()),
3964        };
3965        // Encrypted columns are pruneable only when this run carries the
3966        // decrypted stats envelope (overlaid at open). Without one (a run
3967        // whose writer recorded no stats at all), a missing min/max means
3968        // "unknown" — never prune — whereas with the envelope (and always for
3969        // plaintext runs) a missing min/max means an all-null page.
3970        let stats_pruneable =
3971            !self.col_encrypted(column_id) || self.header.encrypted_stats_offset != 0;
3972        let (positions, rids) = self.visible_positions_with_rids_at(snapshot)?;
3973        let mut out: Vec<u64> = Vec::new();
3974        let mut vis = 0usize;
3975        let mut page_start = 0usize;
3976        for (seq, &(mn, mx, nrows)) in stats.iter().enumerate() {
3977            let page_end = page_start + nrows;
3978            // A page can be dropped iff every value fails the predicate.
3979            let skip = stats_pruneable
3980                && match (mn, mx) {
3981                    (Some(mn), Some(mx)) => mx < lo || mn > hi,
3982                    _ => true, // all-null / no stats → nulls never match
3983                };
3984            if !skip {
3985                let val_page = self.read_page(column_id, seq)?;
3986                let vals = columnar::decode_page_native(TypeId::Int64, &val_page, nrows)?;
3987                if let columnar::NativeColumn::Int64 { data: v, validity } = vals {
3988                    while vis < positions.len() && positions[vis] < page_end {
3989                        let local = positions[vis] - page_start;
3990                        if columnar::validity_bit(&validity, local)
3991                            && v[local] >= lo
3992                            && v[local] <= hi
3993                        {
3994                            out.push(rids[vis] as u64);
3995                        }
3996                        vis += 1;
3997                    }
3998                }
3999            } else {
4000                while vis < positions.len() && positions[vis] < page_end {
4001                    vis += 1;
4002                }
4003            }
4004            page_start = page_end;
4005        }
4006        Ok(out)
4007    }
4008
4009    /// Float64 analogue of [`Self::range_row_ids_visible_i64`] with per-bound
4010    /// inclusivity (Phase 16.3).
4011    pub fn range_row_ids_visible_f64(
4012        &mut self,
4013        column_id: u16,
4014        lo: f64,
4015        lo_inclusive: bool,
4016        hi: f64,
4017        hi_inclusive: bool,
4018        snapshot: Epoch,
4019    ) -> Result<Vec<u64>> {
4020        self.range_row_ids_visible_f64_at(
4021            column_id,
4022            lo,
4023            lo_inclusive,
4024            hi,
4025            hi_inclusive,
4026            Snapshot::at(snapshot),
4027        )
4028    }
4029
4030    /// Full-Snapshot variant of [`Self::range_row_ids_visible_f64`].
4031    pub fn range_row_ids_visible_f64_at(
4032        &mut self,
4033        column_id: u16,
4034        lo: f64,
4035        lo_inclusive: bool,
4036        hi: f64,
4037        hi_inclusive: bool,
4038        snapshot: Snapshot,
4039    ) -> Result<Vec<u64>> {
4040        let stats: Vec<(Option<f64>, Option<f64>, usize)> = match self.column_page_stats(column_id)
4041        {
4042            Some(s) => s
4043                .iter()
4044                .map(|st| {
4045                    (
4046                        be_f64(st.min.as_deref()),
4047                        be_f64(st.max.as_deref()),
4048                        st.row_count as usize,
4049                    )
4050                })
4051                .collect(),
4052            None => return Ok(Vec::new()),
4053        };
4054        // Encrypted columns are pruneable only when this run carries the
4055        // decrypted stats envelope (overlaid at open). Without one (a run
4056        // whose writer recorded no stats at all), a missing min/max means
4057        // "unknown" — never prune — whereas with the envelope (and always for
4058        // plaintext runs) a missing min/max means an all-null page.
4059        let stats_pruneable =
4060            !self.col_encrypted(column_id) || self.header.encrypted_stats_offset != 0;
4061        let (positions, rids) = self.visible_positions_with_rids_at(snapshot)?;
4062        let mut out: Vec<u64> = Vec::new();
4063        let mut vis = 0usize;
4064        let mut page_start = 0usize;
4065        for (seq, &(mn, mx, nrows)) in stats.iter().enumerate() {
4066            let page_end = page_start + nrows;
4067            let skip = stats_pruneable
4068                && match (mn, mx) {
4069                    (Some(mn), Some(mx)) => {
4070                        let skip_lo = mx < lo || (!lo_inclusive && mx == lo);
4071                        let skip_hi = mn > hi || (!hi_inclusive && mn == hi);
4072                        skip_lo || skip_hi
4073                    }
4074                    _ => true,
4075                };
4076            if !skip {
4077                let val_page = self.read_page(column_id, seq)?;
4078                let vals = columnar::decode_page_native(TypeId::Float64, &val_page, nrows)?;
4079                if let columnar::NativeColumn::Float64 { data: v, validity } = vals {
4080                    while vis < positions.len() && positions[vis] < page_end {
4081                        let local = positions[vis] - page_start;
4082                        if columnar::validity_bit(&validity, local) && !v[local].is_nan() {
4083                            let val = v[local];
4084                            let ok_lo = if lo_inclusive { val >= lo } else { val > lo };
4085                            let ok_hi = if hi_inclusive { val <= hi } else { val < hi };
4086                            if ok_lo && ok_hi {
4087                                out.push(rids[vis] as u64);
4088                            }
4089                        }
4090                        vis += 1;
4091                    }
4092                }
4093            } else {
4094                while vis < positions.len() && positions[vis] < page_end {
4095                    vis += 1;
4096                }
4097            }
4098            page_start = page_end;
4099        }
4100        Ok(out)
4101    }
4102
4103    /// MVCC-visible `IS NULL` / `IS NOT NULL` resolution. Follows the same
4104    /// page-stat-pruned + visible-positions pattern as
4105    /// [`Self::range_row_ids_visible_i64`], but checks the validity bitmap
4106    /// instead of a value range. Pages with no nulls (for IS NULL) or all-nulls
4107    /// (for IS NOT NULL) are skipped.
4108    pub fn null_row_ids_visible(
4109        &mut self,
4110        column_id: u16,
4111        want_nulls: bool,
4112        snapshot: Epoch,
4113    ) -> Result<Vec<u64>> {
4114        self.null_row_ids_visible_at(column_id, want_nulls, Snapshot::at(snapshot))
4115    }
4116
4117    /// Full-Snapshot variant of [`Self::null_row_ids_visible`].
4118    pub fn null_row_ids_visible_at(
4119        &mut self,
4120        column_id: u16,
4121        want_nulls: bool,
4122        snapshot: Snapshot,
4123    ) -> Result<Vec<u64>> {
4124        let stats: Vec<(usize, usize)> = match self.column_page_stats(column_id) {
4125            Some(s) => s
4126                .iter()
4127                .map(|st| (st.null_count as usize, st.row_count as usize))
4128                .collect(),
4129            None => return Ok(Vec::new()),
4130        };
4131        let ty = self.resolve_type(column_id);
4132        let (positions, rids) = self.visible_positions_with_rids_at(snapshot)?;
4133        let mut out: Vec<u64> = Vec::new();
4134        let mut vis = 0usize;
4135        let mut page_start = 0usize;
4136        for (seq, &(null_count, nrows)) in stats.iter().enumerate() {
4137            let page_end = page_start + nrows;
4138            let skip = (want_nulls && null_count == 0) || (!want_nulls && null_count == nrows);
4139            if !skip {
4140                let val_page = self.read_page(column_id, seq)?;
4141                let col = columnar::decode_page_native(ty.clone(), &val_page, nrows)?;
4142                let validity = col.validity();
4143                while vis < positions.len() && positions[vis] < page_end {
4144                    let local = positions[vis] - page_start;
4145                    let is_null = !columnar::validity_bit(validity, local);
4146                    if is_null == want_nulls {
4147                        out.push(rids[vis] as u64);
4148                    }
4149                    vis += 1;
4150                }
4151            } else {
4152                while vis < positions.len() && positions[vis] < page_end {
4153                    vis += 1;
4154                }
4155            }
4156            page_start = page_end;
4157        }
4158        Ok(out)
4159    }
4160
4161    /// Row ids whose newest visible version in this run at `snapshot` is a
4162    /// tombstone (`deleted=true`). These rids must be subtracted from a
4163    /// multi-run range survivor set, otherwise a tombstone landing in a
4164    /// newer run can't strip its alive preimage from an older run that the
4165    /// learned index (built only from run 0) or `range_row_ids_visible_i64`
4166    /// happily returned.
4167    pub fn tombstoned_row_ids(&mut self, snapshot: Epoch) -> Result<Vec<u64>> {
4168        self.tombstoned_row_ids_at(Snapshot::at(snapshot))
4169    }
4170
4171    /// Full-Snapshot variant of [`Self::tombstoned_row_ids`].
4172    pub fn tombstoned_row_ids_at(&mut self, snapshot: Snapshot) -> Result<Vec<u64>> {
4173        let n = self.row_count();
4174        if n == 0 {
4175            return Ok(Vec::new());
4176        }
4177        // Clean runs have no tombstones.
4178        if self.is_clean()
4179            && self.epoch_override.is_none()
4180            && self.header.epoch_created <= snapshot.epoch.0
4181            && (!snapshot.uses_hlc_authority() || !self.has_column(SYS_COMMIT_TS))
4182        {
4183            return Ok(Vec::new());
4184        }
4185        let (row_ids, epochs, deleted) = self.system_columns_native()?;
4186        let commit_ts_native = if self.has_column(SYS_COMMIT_TS) {
4187            Some(self.column_native_shared(SYS_COMMIT_TS)?)
4188        } else {
4189            None
4190        };
4191        let mut out = Vec::new();
4192        let mut i = 0;
4193        while i < n {
4194            let rid = row_ids[i] as u64;
4195            let mut best: Option<usize> = None;
4196            let mut j = i;
4197            while j < n && row_ids[j] as u64 == rid {
4198                let e = epochs[j] as u64;
4199                let commit_ts = commit_ts_native
4200                    .as_ref()
4201                    .and_then(|col| decode_commit_ts_value(col.value_at(j).as_ref()));
4202                if snapshot.observes_row(Epoch(e), commit_ts)
4203                    && (best.is_none_or(|cur| {
4204                        Snapshot::version_is_newer(
4205                            Epoch(e),
4206                            commit_ts,
4207                            Epoch(epochs[cur] as u64),
4208                            commit_ts_native
4209                                .as_ref()
4210                                .and_then(|col| decode_commit_ts_value(col.value_at(cur).as_ref())),
4211                        )
4212                    }))
4213                {
4214                    best = Some(j);
4215                }
4216                j += 1;
4217            }
4218            if let Some(b) = best {
4219                if deleted[b] != 0 {
4220                    out.push(rid);
4221                }
4222            }
4223            i = j;
4224        }
4225        Ok(out)
4226    }
4227
4228    /// tombstones excluded) paired with each position's `RowId`, in one pass.
4229    /// Used by [`crate::cursor::NativePageCursor`] to map survivors to pages
4230    /// without re-decoding the system columns.
4231    pub fn visible_positions_with_rids(
4232        &mut self,
4233        snapshot: Epoch,
4234    ) -> Result<(Vec<usize>, Vec<i64>)> {
4235        self.visible_positions_with_rids_at(Snapshot::at(snapshot))
4236    }
4237
4238    /// Full-Snapshot variant of [`Self::visible_positions_with_rids`]: visibility
4239    /// uses [`Snapshot::observes_row`] and `version_is_newer` so an HLC-pinned
4240    /// snapshot returns the HLC-visible winner per row.
4241    pub fn visible_positions_with_rids_at(
4242        &mut self,
4243        snapshot: Snapshot,
4244    ) -> Result<(Vec<usize>, Vec<i64>)> {
4245        let n = self.row_count();
4246        if n == 0 {
4247            return Ok((Vec::new(), Vec::new()));
4248        }
4249        // Clean-run fast path (Phase 16.3c): one version per RowId, no
4250        // tombstones, ascending row_ids ⟹ every position is the newest (and
4251        // only) visible version. Skip decoding epoch/deleted and the group-
4252        // collapse loop; just decode row_ids (needed for survivor↔position
4253        // mapping) and return identity positions [0..n).
4254        // A uniform-epoch overlay must still gate by snapshot, so skip the clean
4255        // fast path when an override is active (defensive: spill runs are never
4256        // written clean). HLC authority requires honouring stamps, so when the
4257        // snapshot is HLC-pinned the fast path is safe: clean + uniform → at
4258        // most one stamped version per rid, and the row is observed iff
4259        // `observes_row` admits it (epoch-gated by `header.epoch_created`,
4260        // HLC-gated only when the run itself carries the stamp).
4261        if self.is_clean()
4262            && self.epoch_override.is_none()
4263            && self.header.epoch_created <= snapshot.epoch.0
4264        {
4265            let row_ids = match self.column_native_shared(SYS_ROW_ID)? {
4266                columnar::NativeColumn::Int64 { data, .. } => data,
4267                _ => return Err(MongrelError::InvalidArgument("sys row_id not int64".into())),
4268            };
4269            // HLC authority still applies when stamps are present.
4270            if !snapshot.uses_hlc_authority() || !self.has_column(SYS_COMMIT_TS) {
4271                let positions: Vec<usize> = (0..n).collect();
4272                return Ok((positions, row_ids));
4273            }
4274            // Drop positions whose HLC stamp (when present) is beyond the snapshot.
4275            let commit_ts_native = self.column_native_shared(SYS_COMMIT_TS)?;
4276            let mut idxs = Vec::with_capacity(n);
4277            let mut rids = Vec::with_capacity(n);
4278            for (i, &rid) in row_ids.iter().enumerate().take(n) {
4279                let ts = decode_commit_ts_value(commit_ts_native.value_at(i).as_ref());
4280                // Clean: at most one stamped or unstamped version per rid.
4281                if snapshot.observes_row(Epoch(self.header.epoch_created), ts) {
4282                    idxs.push(i);
4283                    rids.push(rid);
4284                }
4285            }
4286            return Ok((idxs, rids));
4287        }
4288        let (row_ids, epochs, deleted) = self.system_columns_native()?;
4289        let commit_ts_native = if self.has_column(SYS_COMMIT_TS) {
4290            Some(self.column_native_shared(SYS_COMMIT_TS)?)
4291        } else {
4292            None
4293        };
4294        // Runs are written in `(RowId, Epoch)` ascending order (Bε-tree
4295        // composite key), so same-rid positions are consecutive with the
4296        // newest (highest epoch) last. One linear pass keeps the newest
4297        // visible position per rid under [`Snapshot::observes_row`] and
4298        // [`Snapshot::version_is_newer`], dropping tombstones — no HashMap,
4299        // no per-row hashing, sequential memory access.
4300        let mut idxs: Vec<usize> = Vec::new();
4301        let mut i = 0;
4302        while i < n {
4303            let rid = row_ids[i] as u64;
4304            let mut best: Option<usize> = None;
4305            let mut j = i;
4306            while j < n && row_ids[j] as u64 == rid {
4307                let e = epochs[j] as u64;
4308                let commit_ts = commit_ts_native
4309                    .as_ref()
4310                    .and_then(|col| decode_commit_ts_value(col.value_at(j).as_ref()));
4311                if snapshot.observes_row(Epoch(e), commit_ts) {
4312                    let candidate = j;
4313                    let is_newer = best.is_none_or(|cur| {
4314                        Snapshot::version_is_newer(
4315                            Epoch(e),
4316                            commit_ts,
4317                            Epoch(epochs[cur] as u64),
4318                            commit_ts_native
4319                                .as_ref()
4320                                .and_then(|col| decode_commit_ts_value(col.value_at(cur).as_ref())),
4321                        )
4322                    });
4323                    if best.is_none() || is_newer {
4324                        best = Some(candidate);
4325                    }
4326                }
4327                j += 1;
4328            }
4329            if let Some(b) = best {
4330                if deleted[b] == 0 {
4331                    idxs.push(b);
4332                }
4333            }
4334            i = j;
4335        }
4336        let rids: Vec<i64> = idxs.iter().map(|&k| row_ids[k]).collect();
4337        Ok((idxs, rids))
4338    }
4339
4340    /// Row count of each PAX page of `column_id`, in page order. Every column
4341    /// in a run shares the same PAX row partition, so this yields the table's
4342    /// page layout (cumulative sums give page start offsets).
4343    pub fn page_row_counts(&self, column_id: u16) -> Result<Vec<usize>> {
4344        Ok(self
4345            .find_header(column_id)?
4346            .page_stats
4347            .iter()
4348            .map(|s| s.row_count as usize)
4349            .collect())
4350    }
4351
4352    /// Whether this run stores `column_id` (false for a column added via
4353    /// `add_column` after the run was written — those read as all-null).
4354    pub fn has_column(&self, column_id: u16) -> bool {
4355        self.dir.iter().any(|h| h.column_id == column_id)
4356    }
4357
4358    /// The per-page [`PageStat`]s for `column_id`, or `None` if the column is
4359    /// absent from this run (schema evolution). Used to compute exact column
4360    /// min/max/null_count for the analytical aggregate fast path.
4361    pub fn column_page_stats(&self, column_id: u16) -> Option<&[crate::page::PageStat]> {
4362        self.dir
4363            .iter()
4364            .find(|h| h.column_id == column_id)
4365            .map(|ch| ch.page_stats.as_slice())
4366    }
4367
4368    /// Decode the system columns once, as typed buffers. Uses the shared
4369    /// parallel + decoded-page-cached path (`column_native_shared`) so the
4370    /// row-id/epoch/deleted pages decode concurrently and stay cached across
4371    /// queries — MVCC visibility resolution is on every scan's hot path.
4372    pub(crate) fn system_columns_native(&mut self) -> Result<(Vec<i64>, Vec<i64>, Vec<u8>)> {
4373        let row_ids = match self.column_native_shared(SYS_ROW_ID)? {
4374            columnar::NativeColumn::Int64 { data, .. } => data,
4375            _ => return Err(MongrelError::InvalidArgument("sys row_id not int64".into())),
4376        };
4377        let epochs = match self.column_native_shared(SYS_EPOCH)? {
4378            columnar::NativeColumn::Int64 { data, .. } => data,
4379            _ => return Err(MongrelError::InvalidArgument("sys epoch not int64".into())),
4380        };
4381        let deleted = match self.column_native_shared(SYS_DELETED)? {
4382            columnar::NativeColumn::Bool { data, .. } => data,
4383            _ => return Err(MongrelError::InvalidArgument("sys deleted not bool".into())),
4384        };
4385        Ok((row_ids, epochs, deleted))
4386    }
4387
4388    /// Newest visible version per `RowId` at `snapshot`, **including
4389    /// tombstones** (as `Row`s with `deleted=true`). Ascending `RowId`. Used by
4390    /// the engine to merge versions across runs and the memtable.
4391    ///
4392    /// HLC stamps are restored when [`SYS_COMMIT_TS`] is present (P0.5-T3);
4393    /// legacy runs without the column materialise `commit_ts: None`. Callers
4394    /// that hold a full [`crate::epoch::Snapshot`] still apply
4395    /// [`crate::epoch::Snapshot::observes_row`].
4396    pub fn visible_versions(&mut self, snapshot: Epoch) -> Result<Vec<Row>> {
4397        self.visible_versions_at(Snapshot::at(snapshot))
4398    }
4399
4400    /// Full-Snapshot variant of [`Self::visible_versions`]: visibility uses
4401    /// [`Snapshot::observes_row`] and `version_is_newer` so the HLC-visible
4402    /// candidate wins under an HLC-pinned snapshot.
4403    pub fn visible_versions_at(&mut self, snapshot: Snapshot) -> Result<Vec<Row>> {
4404        let n = self.row_count();
4405        if n == 0 {
4406            return Ok(Vec::new());
4407        }
4408        let row_ids = self.column(SYS_ROW_ID)?.to_vec();
4409        let epochs = self.column(SYS_EPOCH)?.to_vec();
4410        let commit_ts_native = if self.has_column(SYS_COMMIT_TS) {
4411            Some(self.column(SYS_COMMIT_TS)?.to_vec())
4412        } else {
4413            None
4414        };
4415        let mut best: HashMap<u64, (u64, Option<HlcTimestamp>, usize)> = HashMap::new();
4416        for i in 0..n {
4417            let rid = int_at(&row_ids, i);
4418            let e = int_at(&epochs, i);
4419            let commit_ts = commit_ts_native
4420                .as_ref()
4421                .and_then(|vals| decode_commit_ts_value(vals.get(i)));
4422            if !snapshot.observes_row(Epoch(e), commit_ts) {
4423                continue;
4424            }
4425            best.entry(rid)
4426                .and_modify(|cur| {
4427                    if Snapshot::version_is_newer(Epoch(e), commit_ts, Epoch(cur.0), cur.1) {
4428                        *cur = (e, commit_ts, i);
4429                    }
4430                })
4431                .or_insert((e, commit_ts, i));
4432        }
4433        let mut picks: Vec<usize> = best.into_values().map(|(_, _, i)| i).collect();
4434        picks.sort();
4435        let mut out = Vec::with_capacity(picks.len());
4436        for i in picks {
4437            out.push(self.materialize(i)?);
4438        }
4439        Ok(out)
4440    }
4441
4442    /// All non-deleted rows visible at `snapshot`. Ascending `RowId`.
4443    pub fn visible_rows(&mut self, snapshot: Epoch) -> Result<Vec<Row>> {
4444        Ok(self
4445            .visible_versions_at(Snapshot::at(snapshot))?
4446            .into_iter()
4447            .filter(|r| !r.deleted)
4448            .collect())
4449    }
4450
4451    pub(crate) fn materialize(&mut self, index: usize) -> Result<Row> {
4452        let row_id = RowId(int_at(self.column(SYS_ROW_ID)?, index));
4453        let epoch = Epoch(int_at(self.column(SYS_EPOCH)?, index));
4454        let deleted = bool_at(self.column(SYS_DELETED)?, index);
4455        let commit_ts = self.commit_ts_at(index)?;
4456        let col_ids: Vec<u16> = self.schema.columns.iter().map(|c| c.id).collect();
4457        let mut columns = HashMap::new();
4458        for id in col_ids {
4459            let val = if self.dir.iter().any(|h| h.column_id == id) {
4460                self.column(id)?.get(index).cloned().unwrap_or(Value::Null)
4461            } else {
4462                // Column added via schema evolution after this run was written.
4463                Value::Null
4464            };
4465            columns.insert(id, val);
4466        }
4467        Ok(Row {
4468            row_id,
4469            committed_epoch: epoch,
4470            columns,
4471            deleted,
4472            commit_ts,
4473        })
4474    }
4475
4476    /// Batched row materialization (Phase 16.3b finish): decode each system +
4477    /// user column **once** via the typed, page-cached `column_native` path and
4478    /// gather only the requested `indices` straight into `Row`s. This replaces
4479    /// N independent `materialize` calls, each of which rebuilt a full-column
4480    /// `Vec<Value>` (heap-allocating one `Value` per row) and then `.cloned()`
4481    /// a single slot. Exact semantics retained: schema-evolved columns absent
4482    /// from this run read `Value::Null`; deleted rows are still returned (the
4483    /// caller filters them).
4484    pub(crate) fn materialize_batch(&mut self, indices: &[usize]) -> Result<Vec<Row>> {
4485        if indices.is_empty() {
4486            return Ok(Vec::new());
4487        }
4488        use std::collections::HashMap;
4489        let rid_col = self.column_native_shared(SYS_ROW_ID)?;
4490        let epoch_col = self.column_native_shared(SYS_EPOCH)?;
4491        let del_col = self.column_native_shared(SYS_DELETED)?;
4492        let commit_ts_col = if self.has_column(SYS_COMMIT_TS) {
4493            Some(self.column_native_shared(SYS_COMMIT_TS)?)
4494        } else {
4495            None
4496        };
4497        let mut user: HashMap<u16, columnar::NativeColumn> = HashMap::new();
4498        let present: Vec<u16> = self
4499            .schema
4500            .columns
4501            .iter()
4502            .map(|c| c.id)
4503            .filter(|id| self.dir.iter().any(|h| h.column_id == *id))
4504            .collect();
4505        for id in present {
4506            user.insert(id, self.column_native(id)?);
4507        }
4508        let i64_at = |col: &columnar::NativeColumn, i: usize| -> i64 {
4509            match col {
4510                columnar::NativeColumn::Int64 { data, .. } => data.get(i).copied().unwrap_or(0),
4511                _ => 0,
4512            }
4513        };
4514        let bool_at_native = |col: &columnar::NativeColumn, i: usize| -> bool {
4515            match col {
4516                columnar::NativeColumn::Bool { data, .. } => {
4517                    data.get(i).copied().map(|b| b != 0).unwrap_or(false)
4518                }
4519                _ => false,
4520            }
4521        };
4522        let mut rows = Vec::with_capacity(indices.len());
4523        for &idx in indices {
4524            let row_id = RowId(i64_at(&rid_col, idx) as u64);
4525            let epoch = Epoch(i64_at(&epoch_col, idx) as u64);
4526            let deleted = bool_at_native(&del_col, idx);
4527            let commit_ts = commit_ts_col
4528                .as_ref()
4529                .and_then(|col| decode_commit_ts_value(col.value_at(idx).as_ref()));
4530            let mut columns = HashMap::with_capacity(self.schema.columns.len());
4531            for cdef in self.schema.columns.iter() {
4532                let val = match user.get(&cdef.id) {
4533                    Some(col) => col.value_at(idx).unwrap_or(Value::Null),
4534                    None => Value::Null,
4535                };
4536                columns.insert(cdef.id, val);
4537            }
4538            rows.push(Row {
4539                row_id,
4540                committed_epoch: epoch,
4541                columns,
4542                deleted,
4543                commit_ts,
4544            });
4545        }
4546        Ok(rows)
4547    }
4548}
4549
4550fn int_at(vals: &[Value], i: usize) -> u64 {
4551    match vals.get(i) {
4552        Some(Value::Int64(x)) => *x as u64,
4553        _ => 0,
4554    }
4555}
4556
4557fn bool_at(vals: &[Value], i: usize) -> bool {
4558    matches!(vals.get(i), Some(Value::Bool(true)))
4559}
4560
4561#[cfg(test)]
4562mod tests {
4563    use super::*;
4564    use crate::columnar::NativeColumn;
4565    use crate::memtable::Value;
4566    use crate::rowid::RowId;
4567    use crate::schema::{ColumnDef, ColumnFlags};
4568    use tempfile::tempdir;
4569
4570    fn schema() -> Schema {
4571        Schema {
4572            schema_id: 1,
4573            columns: vec![
4574                ColumnDef {
4575                    id: 1,
4576                    name: "id".into(),
4577                    ty: TypeId::Int64,
4578                    flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
4579                    default_value: None,
4580                    embedding_source: None,
4581                },
4582                ColumnDef {
4583                    id: 2,
4584                    name: "name".into(),
4585                    ty: TypeId::Bytes,
4586                    flags: ColumnFlags::empty(),
4587                    default_value: None,
4588                    embedding_source: None,
4589                },
4590            ],
4591            indexes: Vec::new(),
4592            colocation: vec![],
4593            constraints: Default::default(),
4594            clustered: false,
4595        }
4596    }
4597
4598    fn rows() -> Vec<Row> {
4599        vec![
4600            Row::new(RowId(1), Epoch(10))
4601                .with_column(1, Value::Int64(1))
4602                .with_column(2, Value::Bytes(b"alice".to_vec())),
4603            Row::new(RowId(2), Epoch(10))
4604                .with_column(1, Value::Int64(2))
4605                .with_column(2, Value::Bytes(b"bob".to_vec())),
4606            Row::new(RowId(3), Epoch(10))
4607                .with_column(1, Value::Int64(3))
4608                .with_column(2, Value::Bytes(b"carol".to_vec())),
4609        ]
4610    }
4611
4612    #[test]
4613    fn flush_then_read_mvcc() {
4614        let dir = tempdir().unwrap();
4615        let path = dir.path().join("r-1.sr");
4616        let header = RunWriter::new(&schema(), 1, Epoch(10), 0)
4617            .write(&path, &rows())
4618            .unwrap();
4619        assert_eq!(header.row_count, 3);
4620
4621        let mut r = RunReader::open(&path, schema(), None).unwrap();
4622        // Point lookup.
4623        let (e, row) = r.get_version(RowId(2), Epoch(20)).unwrap().unwrap();
4624        assert_eq!(e, Epoch(10));
4625        assert_eq!(row.row_id, RowId(2));
4626        assert!(matches!(row.columns.get(&2), Some(Value::Bytes(_))));
4627        // Missing.
4628        assert!(r.get_version(RowId(99), Epoch(20)).unwrap().is_none());
4629        // Scan.
4630        let all = r.visible_rows(Epoch(20)).unwrap();
4631        assert_eq!(all.len(), 3);
4632        // Unstamped flush omits SYS_COMMIT_TS (legacy path).
4633        assert!(!r.has_column(SYS_COMMIT_TS));
4634        assert!(all.iter().all(|row| row.commit_ts.is_none()));
4635    }
4636
4637    /// ID: P0.5-T3 — flush preserves HLC stamps via optional SYS_COMMIT_TS;
4638    /// legacy runs without the column materialise commit_ts as None.
4639    #[test]
4640    fn p05_t3_sys_commit_ts_preserved_on_flush_and_legacy_is_none() {
4641        let dir = tempdir().unwrap();
4642        let stamped_path = dir.path().join("r-hlc.sr");
4643        let stamp = HlcTimestamp {
4644            physical_micros: 42_000_000,
4645            logical: 7,
4646            node_tiebreaker: 3,
4647        };
4648        let stamped = vec![
4649            Row::new_with_hlc(RowId(1), Epoch(10), stamp)
4650                .with_column(1, Value::Int64(1))
4651                .with_column(2, Value::Bytes(b"alice".to_vec())),
4652            Row::new(RowId(2), Epoch(11)) // mixed: unstamped sibling
4653                .with_column(1, Value::Int64(2))
4654                .with_column(2, Value::Bytes(b"bob".to_vec())),
4655        ];
4656        RunWriter::new(&schema(), 9, Epoch(11), 0)
4657            .write(&stamped_path, &stamped)
4658            .unwrap();
4659        let mut reader = RunReader::open(&stamped_path, schema(), None).unwrap();
4660        assert!(
4661            reader.has_column(SYS_COMMIT_TS),
4662            "stamped flush must emit optional SYS_COMMIT_TS"
4663        );
4664        let (_, row1) = reader
4665            .get_version(RowId(1), Epoch(20))
4666            .unwrap()
4667            .expect("row 1");
4668        assert_eq!(row1.commit_ts, Some(stamp));
4669        let (_, row2) = reader
4670            .get_version(RowId(2), Epoch(20))
4671            .unwrap()
4672            .expect("row 2");
4673        assert_eq!(row2.commit_ts, None, "Null stamp cell stays None");
4674        let versions = reader.visible_versions(Epoch(20)).unwrap();
4675        assert_eq!(versions.len(), 2);
4676        assert_eq!(
4677            versions
4678                .iter()
4679                .find(|r| r.row_id == RowId(1))
4680                .and_then(|r| r.commit_ts),
4681            Some(stamp)
4682        );
4683
4684        // Legacy run (no stamps on write) lacks the column → always None.
4685        let legacy_path = dir.path().join("r-legacy.sr");
4686        RunWriter::new(&schema(), 10, Epoch(10), 0)
4687            .write(&legacy_path, &rows())
4688            .unwrap();
4689        let mut legacy = RunReader::open(&legacy_path, schema(), None).unwrap();
4690        assert!(!legacy.has_column(SYS_COMMIT_TS));
4691        let all = legacy.visible_versions(Epoch(20)).unwrap();
4692        assert!(all.iter().all(|r| r.commit_ts.is_none()));
4693    }
4694
4695    #[test]
4696    fn visible_version_cursor_preserves_order_snapshot_and_tombstones() {
4697        let dir = tempdir().unwrap();
4698        let path = dir.path().join("r-cursor.sr");
4699        let mut tombstone = Row::new(RowId(2), Epoch(2));
4700        tombstone.deleted = true;
4701        let versions = vec![
4702            Row::new(RowId(1), Epoch(4)).with_column(1, Value::Int64(10)),
4703            Row::new(RowId(2), Epoch(1)).with_column(1, Value::Int64(2)),
4704            tombstone,
4705            Row::new(RowId(3), Epoch(1)).with_column(1, Value::Int64(20)),
4706            Row::new(RowId(3), Epoch(3)).with_column(1, Value::Int64(23)),
4707            Row::new(RowId(4), Epoch(4)).with_column(1, Value::Int64(30)),
4708        ];
4709        RunWriter::new(&schema(), 7, Epoch(4), 0)
4710            .write(&path, &versions)
4711            .unwrap();
4712
4713        let control = crate::ExecutionControl::new(None);
4714        let mut cursor = RunReader::open(&path, schema(), None)
4715            .unwrap()
4716            .into_visible_version_cursor(Epoch(2))
4717            .unwrap();
4718        let first = cursor.next_visible_version(&control).unwrap().unwrap();
4719        assert_eq!(first.row_id, RowId(2));
4720        assert!(first.deleted);
4721        assert_eq!(first.committed_epoch, Epoch(2));
4722        let second = cursor.next_visible_version(&control).unwrap().unwrap();
4723        assert_eq!(second.row_id, RowId(3));
4724        assert_eq!(second.committed_epoch, Epoch(1));
4725        let row = cursor.materialize(second, &control).unwrap();
4726        assert_eq!(row.columns.get(&1), Some(&Value::Int64(20)));
4727        assert!(cursor.next_visible_version(&control).unwrap().is_none());
4728    }
4729
4730    #[test]
4731    fn learned_index_was_stored() {
4732        let dir = tempdir().unwrap();
4733        let path = dir.path().join("r-2.sr");
4734        RunWriter::new(&schema(), 2, Epoch(1), 0)
4735            .write(&path, &rows())
4736            .unwrap();
4737        let header = read_header(&path).unwrap();
4738        assert!(
4739            header.index_trailer_offset != 0,
4740            "trailer should be present"
4741        );
4742    }
4743
4744    #[test]
4745    fn low_level_container_still_round_trips() {
4746        let dir = tempdir().unwrap();
4747        let path = dir.path().join("r-3.sr");
4748        let cols = vec![ColumnPayload {
4749            column_id: 1,
4750            type_id_tag: 8,
4751            encoding: Encoding::Plain,
4752            pages: vec![vec![1, 2, 3, 4]],
4753            page_stats: Vec::new(),
4754        }];
4755        let header = write_run(
4756            &path,
4757            &RunSpec {
4758                run_id: 1,
4759                schema_id: 1,
4760                epoch_created: 1,
4761                level: 0,
4762                flags: 0,
4763                sort_key_column_id: SORT_KEY_ROW_ID,
4764                row_count: 1,
4765                min_row_id: 0,
4766                max_row_id: 0,
4767                columns: &cols,
4768            },
4769        )
4770        .unwrap();
4771        let back = read_header(&path).unwrap();
4772        assert_eq!(back.content_hash, header.content_hash);
4773        assert_eq!(read_column_dir(&path, &back).unwrap().len(), 1);
4774    }
4775
4776    #[test]
4777    fn detects_corruption() {
4778        let dir = tempdir().unwrap();
4779        let path = dir.path().join("r-4.sr");
4780        write_run(
4781            &path,
4782            &RunSpec {
4783                run_id: 1,
4784                schema_id: 1,
4785                epoch_created: 1,
4786                level: 0,
4787                flags: 0,
4788                sort_key_column_id: SORT_KEY_ROW_ID,
4789                row_count: 1,
4790                min_row_id: 0,
4791                max_row_id: 0,
4792                columns: &[ColumnPayload {
4793                    column_id: 1,
4794                    type_id_tag: 8,
4795                    encoding: Encoding::Plain,
4796                    pages: vec![vec![1, 2, 3]],
4797                    page_stats: Vec::new(),
4798                }],
4799            },
4800        )
4801        .unwrap();
4802        let mut bytes = std::fs::read(&path).unwrap();
4803        bytes[300] ^= 0xFF;
4804        std::fs::write(&path, bytes).unwrap();
4805        let err = read_header(&path).unwrap_err();
4806        assert!(
4807            matches!(err, MongrelError::ChecksumMismatch { .. }),
4808            "got {err:?}"
4809        );
4810    }
4811
4812    /// Phase 14.6: the direct-to-mmap placement logic must produce a byte-
4813    /// identical run to the in-buffer fallback. We drive `plan_run` + `place_run`
4814    /// into a plain `Vec<u8>` (so it's testable even where file mmap is denied —
4815    /// e.g. this sandbox), compare against `write_run_vec`, and additionally
4816    /// check that `write_run_mmap` agrees wherever a mapping can be created.
4817    #[test]
4818    fn mmap_and_vec_writers_are_byte_identical() {
4819        let dir = tempdir().unwrap();
4820        let stats = vec![PageStat {
4821            first_row_id: 0,
4822            last_row_id: 1,
4823            null_count: 0,
4824            row_count: 2,
4825            min: Some(vec![0]),
4826            max: Some(vec![9]),
4827            offset: 0,
4828            compressed_len: 0,
4829            uncompressed_len: 0,
4830        }];
4831        let spec = RunSpec {
4832            run_id: 7,
4833            schema_id: 1,
4834            epoch_created: 10,
4835            level: 0,
4836            flags: 0,
4837            sort_key_column_id: SORT_KEY_ROW_ID,
4838            row_count: 2,
4839            min_row_id: 0,
4840            max_row_id: 1,
4841            columns: &[
4842                ColumnPayload {
4843                    column_id: 1,
4844                    type_id_tag: 8,
4845                    encoding: Encoding::Plain,
4846                    pages: vec![vec![1, 2, 3, 4], vec![5, 6, 7, 8]],
4847                    page_stats: stats.clone(),
4848                },
4849                ColumnPayload {
4850                    column_id: 2,
4851                    type_id_tag: 8,
4852                    encoding: Encoding::Plain,
4853                    pages: vec![vec![10, 20], vec![30, 40]],
4854                    page_stats: stats.clone(),
4855                },
4856            ],
4857        };
4858        let trailer = b"learned-trailer-bytes";
4859
4860        // (1) placement path into a Vec-backed buffer.
4861        let plan = plan_run(&spec, None, Some(trailer)).expect("plan");
4862        let mut buf = vec![0u8; plan.total];
4863        let h_place = place_run(&spec, None, Some(trailer), &plan, &mut buf)
4864            .expect("place_run into Vec succeeds");
4865
4866        // (2) in-buffer fallback writer to a real file.
4867        let path_vec = dir.path().join("vec.sr");
4868        let h_vec = write_run_vec(&path_vec, &spec, None, Some(trailer)).unwrap();
4869        let bv = std::fs::read(&path_vec).unwrap();
4870
4871        assert_eq!(h_place.content_hash, h_vec.content_hash);
4872        assert_eq!(h_place.footer_offset, h_vec.footer_offset);
4873        assert_eq!(
4874            buf, bv,
4875            "place_run and write_run_vec must be byte-identical"
4876        );
4877        assert!(read_header(&path_vec).is_ok());
4878
4879        // (3) the mmap writer, when the FS supports it, must also agree. Where
4880        // file mmap is denied (some sandboxes), it reports the fallback sentinel
4881        // and `write_run_with` transparently uses the vec path instead.
4882        let path_mmap = dir.path().join("mmap.sr");
4883        match write_run_mmap(&path_mmap, &spec, None, Some(trailer)) {
4884            Ok(h_mmap) => {
4885                let bm = std::fs::read(&path_mmap).unwrap();
4886                assert_eq!(bm, bv, "mmap run must be byte-identical to vec run");
4887                assert_eq!(h_mmap.content_hash, h_vec.content_hash);
4888            }
4889            Err(e) if is_mmap_unavailable(&e) => {
4890                eprintln!("note: file mmap unavailable here; vec path covered (1)/(2)");
4891            }
4892            Err(e) => panic!("unexpected mmap error: {e:?}"),
4893        }
4894    }
4895
4896    /// Phase 15.1: the `&self` parallel-decode path (`column_native_shared`)
4897    /// must yield the same `NativeColumn` as the `&mut` `column_native` path,
4898    /// column for column, on a multi-page run. This guards the cross-column
4899    /// parallel scan used by `visible_columns_native`.
4900    #[test]
4901    fn column_native_shared_matches_column_native() {
4902        let dir = tempdir().unwrap();
4903        let path = dir.path().join("r.sr");
4904        // Enough rows to span >1 page (PAGE_ROWS = 65 536) so the parallel page
4905        // branch runs, plus a partial tail page.
4906        let n = 65_536 * 2 + 9;
4907        let id_col = NativeColumn::int64_sequence(1, n);
4908        let mut offsets = vec![0u32];
4909        let mut values = Vec::new();
4910        for i in 0..n {
4911            values.extend_from_slice(format!("v{}", i % 17).as_bytes());
4912            offsets.push(values.len() as u32);
4913        }
4914        let name_col = NativeColumn::Bytes {
4915            offsets,
4916            values,
4917            validity: vec![0xFF; n.div_ceil(8)],
4918        };
4919        let header = RunWriter::new(&schema(), 9, Epoch(5), 0)
4920            .write_native(&path, &[(1, id_col), (2, name_col)], n, 1)
4921            .unwrap();
4922
4923        let mut reader = RunReader::open_with_cache(&path, schema(), None, None, None, 0, None)
4924            .expect("open reader");
4925        assert!(reader.has_mmap(), "test env must support read-only mmap");
4926        assert_eq!(reader.row_count(), header.row_count as usize);
4927
4928        for cid in [1u16, 2] {
4929            let a = reader.column_native(cid).expect("column_native");
4930            let b = reader
4931                .column_native_shared(cid)
4932                .expect("column_native_shared");
4933            assert_eq!(a.len(), b.len(), "len mismatch col {cid}");
4934            // Byte-level equality of the typed buffers.
4935            match (&a, &b) {
4936                (
4937                    NativeColumn::Int64 {
4938                        data: da,
4939                        validity: va,
4940                    },
4941                    NativeColumn::Int64 {
4942                        data: db,
4943                        validity: vb,
4944                    },
4945                ) => {
4946                    assert_eq!(da, db, "Int64 data col {cid}");
4947                    assert_eq!(va, vb, "Int64 validity col {cid}");
4948                }
4949                (
4950                    NativeColumn::Bytes {
4951                        offsets: oa,
4952                        values: ua,
4953                        validity: va,
4954                    },
4955                    NativeColumn::Bytes {
4956                        offsets: ob,
4957                        values: ub,
4958                        validity: vb,
4959                    },
4960                ) => {
4961                    assert_eq!(oa, ob, "Bytes offsets col {cid}");
4962                    assert_eq!(ua, ub, "Bytes values col {cid}");
4963                    assert_eq!(va, vb, "Bytes validity col {cid}");
4964                }
4965                _ => panic!("type mismatch col {cid}: {a:?} vs {b:?}"),
4966            }
4967        }
4968    }
4969
4970    #[test]
4971    fn page_cache_key_distinguishes_tables() {
4972        let a = page_cache_key(1, 5, 2, 3);
4973        let b = page_cache_key(2, 5, 2, 3);
4974        assert_ne!(a, b, "same run/col/page, different table must differ");
4975        // same table different run/col/page also differ (sanity)
4976        assert_ne!(page_cache_key(1, 5, 2, 3), page_cache_key(1, 6, 2, 3));
4977        assert_ne!(page_cache_key(1, 5, 2, 3), page_cache_key(1, 5, 2, 4));
4978    }
4979}