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