Skip to main content

mongreldb_core/
engine.rs

1//! The engine tying the write and read paths together.
2//!
3//! Sub-ms writes: [`Table::put`] appends to the WAL **without fsyncing**, upserts
4//! the skip-list memtable, and updates the in-memory HOT index + secondary
5//! indexes. A batch-driven [`Table::commit`] does the group `fsync` and bumps the
6//! epoch. [`Table::flush`] commits, drains the memtable into an immutable sorted
7//! run, and rotates the WAL. Reads merge versions across the live memtable and
8//! all sorted runs ([`Table::get`], [`Table::visible_rows`]).
9
10use crate::columnar;
11use crate::cursor::NativePageCursor;
12use crate::encryption::Kek;
13use crate::encryption::DEK_LEN;
14use crate::epoch::{Epoch, EpochAuthority, EpochGuard, MaintenanceReceipt, Snapshot};
15use crate::global_idx;
16use crate::index::{
17    AnnIndex, BitmapIndex, ColumnLearnedRange, FmIndex, HotIndex, IndexGeneration, MinHashIndex,
18    SparseIndex,
19};
20use crate::manifest::{self, Manifest, RunRef, TtlPolicy};
21use crate::memtable::{Memtable, Row, Value};
22use crate::mutable_run::MutableRun;
23use crate::row_id_set::RowIdSet;
24use crate::rowid::{RowId, RowIdAllocator};
25use crate::schema::{AlterColumn, ColumnDef, ColumnFlags, IndexDef, IndexKind, Schema, TypeId};
26use crate::sorted_run::{RunReader, RunVisibleVersion, RunVisibleVersionCursor, RunWriter};
27use crate::txn::{GroupCommit, OwnedRow};
28use crate::wal::{Op, SharedWal, Wal};
29use crate::{MongrelError, Result};
30use arc_swap::ArcSwap;
31use std::cmp::Reverse;
32use std::collections::{BTreeMap, BinaryHeap, HashMap, HashSet};
33use std::path::{Path, PathBuf};
34use std::sync::atomic::AtomicBool;
35use std::sync::Arc;
36use zeroize::Zeroizing;
37
38pub const WAL_DIR: &str = "_wal";
39pub const RUNS_DIR: &str = "_runs";
40pub const CACHE_DIR: &str = "_cache";
41pub const META_DIR: &str = "_meta";
42pub const RCACHE_DIR: &str = "_rcache";
43pub const KEYS_FILENAME: &str = "keys";
44pub const SCHEMA_FILENAME: &str = "schema.json";
45
46fn derive_next_run_id(
47    dir: &Path,
48    runs_root: Option<&crate::durable_file::DurableRoot>,
49    active: &[RunRef],
50    retiring: &[crate::manifest::RetiredRun],
51) -> Result<u64> {
52    let mut maximum = 0_u64;
53    for run_id in active
54        .iter()
55        .map(|run| run.run_id)
56        .chain(retiring.iter().map(|run| run.run_id))
57    {
58        let run_id = u64::try_from(run_id)
59            .map_err(|_| MongrelError::Full("run-id namespace exhausted".into()))?;
60        maximum = maximum.max(run_id);
61    }
62    let names = match runs_root {
63        Some(root) => root.list_regular_files(".")?,
64        None => std::fs::read_dir(dir.join(RUNS_DIR))?
65            .map(|entry| entry.map(|entry| entry.file_name()))
66            .collect::<std::io::Result<Vec<_>>>()?,
67    };
68    for name in names {
69        let Some(name) = name.to_str() else {
70            continue;
71        };
72        let Some(digits) = name
73            .strip_prefix("r-")
74            .and_then(|name| name.strip_suffix(".sr"))
75        else {
76            continue;
77        };
78        let Ok(run_id) = digits.parse::<u64>() else {
79            continue;
80        };
81        if name == format!("r-{run_id}.sr") {
82            maximum = maximum.max(run_id);
83        }
84    }
85    maximum
86        .checked_add(1)
87        .map(|next| next.max(1))
88        .ok_or_else(|| MongrelError::Full("run-id namespace exhausted".into()))
89}
90
91enum ControlledVisibleCandidate {
92    Memory(Row),
93    Run(RunVisibleVersion),
94}
95
96impl ControlledVisibleCandidate {
97    fn row_id(&self) -> RowId {
98        match self {
99            Self::Memory(row) => row.row_id,
100            Self::Run(version) => version.row_id,
101        }
102    }
103
104    fn committed_epoch(&self) -> Epoch {
105        match self {
106            Self::Memory(row) => row.committed_epoch,
107            Self::Run(version) => version.committed_epoch,
108        }
109    }
110
111    fn deleted(&self) -> bool {
112        match self {
113            Self::Memory(row) => row.deleted,
114            Self::Run(version) => version.deleted,
115        }
116    }
117}
118
119enum ControlledVisibleCursor {
120    Memory(std::vec::IntoIter<Row>),
121    Run(Box<RunVisibleVersionCursor>),
122    #[cfg(test)]
123    Synthetic {
124        next: u64,
125        end: u64,
126    },
127}
128
129struct ControlledVisibleSource {
130    cursor: ControlledVisibleCursor,
131    current: Option<ControlledVisibleCandidate>,
132}
133
134impl ControlledVisibleSource {
135    fn memory(rows: Vec<Row>) -> Self {
136        Self {
137            cursor: ControlledVisibleCursor::Memory(rows.into_iter()),
138            current: None,
139        }
140    }
141
142    fn run(cursor: RunVisibleVersionCursor) -> Self {
143        Self {
144            cursor: ControlledVisibleCursor::Run(Box::new(cursor)),
145            current: None,
146        }
147    }
148
149    #[cfg(test)]
150    fn synthetic(end: u64) -> Self {
151        Self {
152            cursor: ControlledVisibleCursor::Synthetic { next: 1, end },
153            current: None,
154        }
155    }
156
157    fn advance(&mut self, control: &crate::ExecutionControl) -> Result<()> {
158        self.current = match &mut self.cursor {
159            ControlledVisibleCursor::Memory(rows) => {
160                rows.next().map(ControlledVisibleCandidate::Memory)
161            }
162            ControlledVisibleCursor::Run(cursor) => cursor
163                .next_visible_version(control)?
164                .map(ControlledVisibleCandidate::Run),
165            #[cfg(test)]
166            ControlledVisibleCursor::Synthetic { next, end } => {
167                if *next > *end {
168                    None
169                } else {
170                    let row = Row::new(RowId(*next), Epoch(1));
171                    *next += 1;
172                    Some(ControlledVisibleCandidate::Memory(row))
173                }
174            }
175        };
176        Ok(())
177    }
178
179    fn pop(&mut self, control: &crate::ExecutionControl) -> Result<ControlledVisibleCandidate> {
180        let current = self.current.take().ok_or_else(|| {
181            MongrelError::Other("controlled visible source was not primed".into())
182        })?;
183        self.advance(control)?;
184        Ok(current)
185    }
186
187    fn materialize(
188        &mut self,
189        candidate: ControlledVisibleCandidate,
190        control: &crate::ExecutionControl,
191    ) -> Result<Row> {
192        match candidate {
193            ControlledVisibleCandidate::Memory(row) => Ok(row),
194            ControlledVisibleCandidate::Run(version) => match &mut self.cursor {
195                ControlledVisibleCursor::Run(cursor) => cursor.materialize(version, control),
196                _ => Err(MongrelError::Other(
197                    "run candidate escaped its controlled cursor".into(),
198                )),
199            },
200        }
201    }
202}
203
204fn merge_controlled_visible_sources(
205    sources: &mut [ControlledVisibleSource],
206    control: &crate::ExecutionControl,
207    mut expired: impl FnMut(&Row) -> bool,
208    mut visit: impl FnMut(Row) -> Result<()>,
209) -> Result<()> {
210    let mut heap = BinaryHeap::new();
211    for (source_index, source) in sources.iter_mut().enumerate() {
212        source.advance(control)?;
213        if let Some(candidate) = &source.current {
214            heap.push(Reverse((candidate.row_id(), source_index)));
215        }
216    }
217    let mut merged = 0_usize;
218    while let Some(Reverse((row_id, source_index))) = heap.pop() {
219        if merged.is_multiple_of(256) {
220            control.checkpoint()?;
221        }
222        merged += 1;
223        let mut best_source = source_index;
224        let mut best = sources[source_index].pop(control)?;
225        if let Some(next) = &sources[source_index].current {
226            heap.push(Reverse((next.row_id(), source_index)));
227        }
228        while heap
229            .peek()
230            .is_some_and(|Reverse((candidate, _))| *candidate == row_id)
231        {
232            let Some(Reverse((_, source_index))) = heap.pop() else {
233                break;
234            };
235            let candidate = sources[source_index].pop(control)?;
236            if candidate.committed_epoch() > best.committed_epoch() {
237                best = candidate;
238                best_source = source_index;
239            }
240            if let Some(next) = &sources[source_index].current {
241                heap.push(Reverse((next.row_id(), source_index)));
242            }
243        }
244        if best.deleted() {
245            continue;
246        }
247        let row = sources[best_source].materialize(best, control)?;
248        if !expired(&row) {
249            visit(row)?;
250        }
251    }
252    control.checkpoint()
253}
254
255#[cfg(test)]
256mod controlled_visible_cursor_tests {
257    use super::*;
258
259    #[test]
260    fn streams_more_than_one_million_rows_without_a_source_cap() {
261        let control = crate::ExecutionControl::new(None);
262        let mut sources = vec![ControlledVisibleSource::synthetic(1_000_001)];
263        let mut count = 0_u64;
264        let mut last = 0_u64;
265        merge_controlled_visible_sources(
266            &mut sources,
267            &control,
268            |_| false,
269            |row| {
270                count += 1;
271                assert!(row.row_id.0 > last);
272                last = row.row_id.0;
273                Ok(())
274            },
275        )
276        .unwrap();
277        assert_eq!(count, 1_000_001);
278        assert_eq!(last, 1_000_001);
279    }
280
281    #[test]
282    fn merge_orders_rows_and_honors_newest_tombstones() {
283        let control = crate::ExecutionControl::new(None);
284        let older = vec![
285            Row::new(RowId(1), Epoch(1)),
286            Row::new(RowId(2), Epoch(1)).with_column(1, Value::Int64(20)),
287            Row::new(RowId(4), Epoch(1)),
288        ];
289        let mut deleted = Row::new(RowId(1), Epoch(2));
290        deleted.deleted = true;
291        let newer = vec![
292            deleted,
293            Row::new(RowId(2), Epoch(2)).with_column(1, Value::Int64(22)),
294            Row::new(RowId(3), Epoch(2)),
295        ];
296        let mut sources = vec![
297            ControlledVisibleSource::memory(older),
298            ControlledVisibleSource::memory(newer),
299        ];
300        let mut rows = Vec::new();
301        merge_controlled_visible_sources(
302            &mut sources,
303            &control,
304            |_| false,
305            |row| {
306                rows.push(row);
307                Ok(())
308            },
309        )
310        .unwrap();
311        assert_eq!(
312            rows.iter().map(|row| row.row_id.0).collect::<Vec<_>>(),
313            vec![2, 3, 4]
314        );
315        assert_eq!(rows[0].columns.get(&1), Some(&Value::Int64(22)));
316    }
317}
318
319/// Current UTC time as an ISO-8601 string in bytes (e.g. `b"2024-07-07T14:30:00Z"`).
320/// Used by `DefaultExpr::Now` at stage time.
321fn iso_now_bytes() -> Vec<u8> {
322    let secs = std::time::SystemTime::now()
323        .duration_since(std::time::UNIX_EPOCH)
324        .map(|d| d.as_secs() as i64)
325        .unwrap_or(0);
326    let days = secs.div_euclid(86_400);
327    let rem = secs.rem_euclid(86_400);
328    let (hour, minute, second) = (rem / 3600, (rem % 3600) / 60, rem % 60);
329    let (year, month, day) = civil_from_days(days);
330    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z").into_bytes()
331}
332
333pub(crate) fn unix_nanos_now() -> i64 {
334    std::time::SystemTime::now()
335        .duration_since(std::time::UNIX_EPOCH)
336        .map(|d| d.as_nanos().min(i64::MAX as u128) as i64)
337        .unwrap_or(0)
338}
339
340fn ann_candidate_cap(
341    index_len: usize,
342    context: Option<&crate::query::AiExecutionContext>,
343) -> usize {
344    index_len
345        .min(crate::query::MAX_RAW_INDEX_CANDIDATES)
346        .min(context.map_or(
347            crate::query::MAX_RAW_INDEX_CANDIDATES,
348            crate::query::AiExecutionContext::max_fused_candidates,
349        ))
350}
351
352#[cfg(test)]
353mod ann_candidate_cap_tests {
354    use super::*;
355
356    #[test]
357    fn raw_and_request_candidate_ceilings_are_both_hard_bounds() {
358        assert_eq!(
359            ann_candidate_cap(crate::query::MAX_RAW_INDEX_CANDIDATES + 1, None),
360            crate::query::MAX_RAW_INDEX_CANDIDATES,
361        );
362        let context = crate::query::AiExecutionContext::with_limits(
363            std::time::Duration::from_secs(1),
364            usize::MAX,
365            17,
366        );
367        assert_eq!(ann_candidate_cap(1_000_000, Some(&context)), 17);
368    }
369}
370
371fn civil_from_days(z: i64) -> (i64, u32, u32) {
372    let z = z + 719_468;
373    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
374    let doe = z - era * 146_097;
375    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
376    let y = yoe + era * 400;
377    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
378    let mp = (5 * doy + 2) / 153;
379    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
380    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
381    (if m <= 2 { y + 1 } else { y }, m, d)
382}
383
384/// Derives the stable physical row id used by clustered (`WITHOUT ROWID`)
385/// tables from the encoded primary-key value.
386///
387/// Replicated tablet bootstrap uses this helper when it constructs the same
388/// logical row on every replica without going through the user transaction
389/// staging path.
390pub fn clustered_row_id(primary_key: &Value) -> RowId {
391    let mut hash: u64 = 0xcbf29ce484222325;
392    for byte in primary_key.encode_key() {
393        hash ^= u64::from(byte);
394        hash = hash.wrapping_mul(0x100000001b3);
395    }
396    RowId(hash.max(1))
397}
398
399const DEFAULT_SYNC_BYTE_THRESHOLD: u64 = 0; // manual commit only (pure group commit)
400pub(crate) const PAGE_CACHE_CAPACITY: u64 = 64 * 1024 * 1024; // 64 MiB shared page cache
401pub(crate) const DECODED_CACHE_CAPACITY: u64 = 64 * 1024 * 1024; // 64 MiB shared decoded-page cache (Phase 15.4)
402/// Default byte watermark at which the PMA mutable-run tier spills to an
403/// immutable `.sr` sorted run (Phase 11.1). Coalesces many small flushes into
404/// one larger run so the read path merges fewer readers.
405const DEFAULT_MUTABLE_RUN_SPILL_BYTES: u64 = 8 * 1024 * 1024;
406
407/// Engine-managed `AUTO_INCREMENT` counter state for a table (present iff the
408/// schema declares an `AUTO_INCREMENT` primary key).
409///
410/// `next` is the next value to hand out (1-based, monotonic, never reused). It
411/// is `0` while *unseeded* — the counter has never been advanced (fresh table or
412/// a legacy manifest predating `auto_inc_next`). When `seeded` is `false` the
413/// first allocation scans `max(PK)` over all visible rows so the counter never
414/// collides with pre-existing rows; a value of `0` after seeding never happens
415/// (ids are never 0). The manifest persists `next` only when `seeded`, so a
416/// reopen that reads `auto_inc_next > 0` is authoritative.
417///
418/// `seeded == false` but `next > 0` is a transient recovery-only state: WAL
419/// replay may bump `next` past replayed ids without marking it seeded, so the
420/// scan still runs to cover rows that were already flushed to sorted runs.
421#[derive(Clone, Copy, Debug)]
422struct AutoIncState {
423    column_id: u16,
424    next: i64,
425    seeded: bool,
426}
427
428pub(crate) struct RecoveryMetadataPlan {
429    live_count: u64,
430    auto_inc: Option<AutoIncState>,
431    changed: bool,
432}
433
434type FilledAutoIncRow = (Vec<(u16, Value)>, Option<i64>);
435
436/// Resolve the auto-increment column (if any) from a schema into initial
437/// counter state. Always called after [`crate::schema::Schema::validate_auto_increment`].
438fn resolve_auto_inc(schema: &Schema) -> Option<AutoIncState> {
439    schema.auto_increment_column().map(|c| AutoIncState {
440        column_id: c.id,
441        next: 0,
442        seeded: false,
443    })
444}
445
446/// When a bulk load (`bulk_load` / `bulk_load_columns` / `bulk_load_fast`)
447/// builds the live in-memory indexes.
448///
449/// The engine is correct under either policy: with [`Self::Deferred`] the
450/// indexes are rebuilt lazily by the first `query`/`flush` (Phase 14.7,
451/// `ensure_indexes_complete`), with [`Self::Eager`] they are built — and
452/// checkpointed to `_idx/global.idx` — inside the bulk load itself. The trade
453/// is *where* the build cost lands: `Deferred` keeps the ingest critical path
454/// minimal (write the run, persist the manifest, return); `Eager` gives
455/// predictable first-query latency at the price of a slower load. Serving
456/// deployments that load then immediately serve point queries (e.g. a warm
457/// daemon) may prefer `Eager`; batch/ETL ingest wants `Deferred`.
458#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
459pub enum IndexBuildPolicy {
460    /// Defer index building to the first query/flush — fastest ingest (default).
461    #[default]
462    Deferred,
463    /// Build and checkpoint indexes inside the bulk load — fastest first query.
464    Eager,
465}
466
467#[derive(Clone)]
468struct ReversePkSegment {
469    values: HashMap<RowId, Vec<u8>>,
470    removed: HashSet<RowId>,
471}
472
473#[derive(Clone)]
474struct ReversePkMap {
475    frozen: Arc<Vec<Arc<ReversePkSegment>>>,
476    active: ReversePkSegment,
477}
478
479impl ReversePkMap {
480    fn new() -> Self {
481        Self {
482            frozen: Arc::new(Vec::new()),
483            active: ReversePkSegment {
484                values: HashMap::new(),
485                removed: HashSet::new(),
486            },
487        }
488    }
489
490    fn from_entries(entries: impl IntoIterator<Item = (RowId, Vec<u8>)>) -> Self {
491        let mut map = Self::new();
492        map.active.values.extend(entries);
493        map
494    }
495
496    fn insert(&mut self, row_id: RowId, key: Vec<u8>) {
497        self.active.removed.remove(&row_id);
498        self.active.values.insert(row_id, key);
499    }
500
501    fn get(&self, row_id: &RowId) -> Option<&Vec<u8>> {
502        if let Some(key) = self.active.values.get(row_id) {
503            return Some(key);
504        }
505        if self.active.removed.contains(row_id) {
506            return None;
507        }
508        for segment in self.frozen.iter().rev() {
509            if let Some(key) = segment.values.get(row_id) {
510                return Some(key);
511            }
512            if segment.removed.contains(row_id) {
513                return None;
514            }
515        }
516        None
517    }
518
519    fn remove(&mut self, row_id: &RowId) -> Option<Vec<u8>> {
520        let previous = self.get(row_id).cloned();
521        self.active.values.remove(row_id);
522        self.active.removed.insert(*row_id);
523        previous
524    }
525
526    fn clear(&mut self) {
527        *self = Self::new();
528    }
529
530    fn entries(&self) -> HashMap<RowId, Vec<u8>> {
531        let mut entries = HashMap::new();
532        for segment in self
533            .frozen
534            .iter()
535            .map(Arc::as_ref)
536            .chain(std::iter::once(&self.active))
537        {
538            for row_id in &segment.removed {
539                entries.remove(row_id);
540            }
541            entries.extend(
542                segment
543                    .values
544                    .iter()
545                    .map(|(row_id, key)| (*row_id, key.clone())),
546            );
547        }
548        entries
549    }
550
551    fn seal(&mut self) {
552        if self.active.values.is_empty() && self.active.removed.is_empty() {
553            return;
554        }
555        let active = std::mem::replace(
556            &mut self.active,
557            ReversePkSegment {
558                values: HashMap::new(),
559                removed: HashSet::new(),
560            },
561        );
562        Arc::make_mut(&mut self.frozen).push(Arc::new(active));
563        if self.frozen.len() >= crate::MAX_READ_GENERATION_LAYERS {
564            self.frozen = Arc::new(vec![Arc::new(ReversePkSegment {
565                values: self.entries(),
566                removed: HashSet::new(),
567            })]);
568        }
569    }
570}
571
572/// S1C-001: an immutable, atomically-published table read view — the
573/// engine-layer counterpart of `database::TableReadGeneration`. Readers pin
574/// an `Arc<ReadGeneration>`; writers publish a replacement with a single
575/// `ArcSwap` store ([`Table::publish_read_generation`]) after sealing their
576/// active deltas, so no write ever clones the complete table/index set
577/// merely because readers exist: every captured piece is either an `Arc`
578/// share of immutable frozen layers or a small metadata copy.
579///
580/// `visible_through` is the engine's commit-epoch watermark (the spec's
581/// `HlcTimestamp` maps onto it at the commit-log layer): the view reflects
582/// every commit whose epoch is `<= visible_through`, and later writes are
583/// invisible through it even though they mutate the publishing [`Table`].
584#[derive(Clone)]
585pub struct ReadGeneration {
586    schema: Arc<Schema>,
587    base_runs: Arc<Vec<RunRef>>,
588    deltas: TableDeltas,
589    indexes: Arc<IndexGeneration>,
590    visible_through: Epoch,
591}
592
593/// One fully-built secondary index staged outside the publication barrier.
594/// The variant carries only the target index. Unrelated live indexes remain
595/// structurally shared when this artifact is installed.
596pub(crate) enum SecondaryIndexArtifact {
597    Bitmap(u16, BitmapIndex),
598    LearnedRange(u16, ColumnLearnedRange),
599    Fm(u16, Box<FmIndex>),
600    Ann(u16, Box<AnnIndex>),
601    Sparse(u16, SparseIndex),
602    MinHash(u16, MinHashIndex),
603}
604
605/// The sealed in-memory deltas captured with a [`ReadGeneration`]: memtable,
606/// mutable-run tier, HOT primary-key index, and the reverse primary-key map.
607/// Each is a post-seal clone — frozen layers are `Arc`-shared with the
608/// writer, the active delta is empty — so capturing copies no row data, and
609/// pinning the view keeps exactly the frozen layers it captured alive.
610#[derive(Clone)]
611pub struct TableDeltas {
612    memtable: Memtable,
613    mutable_run: MutableRun,
614    hot: HotIndex,
615    pk_by_row: ReversePkMap,
616}
617
618impl ReadGeneration {
619    /// An empty view over `schema`, used to seed the published cell before
620    /// the first [`Table::publish_read_generation`].
621    fn empty(schema: &Schema) -> Self {
622        Self {
623            schema: Arc::new(schema.clone()),
624            base_runs: Arc::new(Vec::new()),
625            deltas: TableDeltas {
626                memtable: Memtable::new(),
627                mutable_run: MutableRun::new(),
628                hot: HotIndex::new(),
629                pk_by_row: ReversePkMap::new(),
630            },
631            indexes: Arc::new(IndexGeneration::default()),
632            visible_through: Epoch(0),
633        }
634    }
635
636    /// Table schema as of this generation.
637    pub fn schema(&self) -> &Arc<Schema> {
638        &self.schema
639    }
640
641    /// Immutable base sorted runs (`r-*.sr`) visible in this generation.
642    pub fn base_runs(&self) -> &[RunRef] {
643        &self.base_runs
644    }
645
646    /// The published index generation (all six families).
647    pub fn indexes(&self) -> &Arc<IndexGeneration> {
648        &self.indexes
649    }
650
651    /// Highest commit epoch reflected in this view.
652    pub fn visible_through(&self) -> Epoch {
653        self.visible_through
654    }
655
656    /// The sealed in-memory deltas captured with this view. The view owns an
657    /// `Arc` share of every frozen layer, so the layers stay alive (and
658    /// unchanged) for as long as the view is pinned.
659    pub fn deltas(&self) -> &TableDeltas {
660        &self.deltas
661    }
662}
663
664impl TableDeltas {
665    /// Approximate heap bytes held by the captured memtable and mutable-run
666    /// frozen deltas (diagnostics).
667    pub fn approx_bytes(&self) -> u64 {
668        self.memtable
669            .approx_bytes()
670            .saturating_add(self.mutable_run.approx_bytes())
671    }
672
673    /// Row versions held in the captured memtable layers.
674    pub fn memtable_len(&self) -> usize {
675        self.memtable.len()
676    }
677
678    /// Row versions held in the captured mutable-run layers.
679    pub fn mutable_run_len(&self) -> usize {
680        self.mutable_run.len()
681    }
682
683    /// Primary-key entries held in the captured HOT index layers.
684    pub fn hot_len(&self) -> usize {
685        self.hot.len()
686    }
687
688    /// Reverse primary-key entries captured for HOT cleanup on deletes.
689    pub fn reverse_pk_len(&self) -> usize {
690        self.pk_by_row.entries().len()
691    }
692}
693
694/// An open MongrelDB table.
695#[derive(Clone)]
696pub struct Table {
697    dir: PathBuf,
698    _root_guard: Option<Arc<crate::durable_file::DurableRoot>>,
699    runs_root: Option<Arc<crate::durable_file::DurableRoot>>,
700    idx_root: Option<Arc<crate::durable_file::DurableRoot>>,
701    table_id: u64,
702    /// The table's catalog name, set at mount time. Used by the auth
703    /// enforcement layer to check `Select`/`Insert`/`Update`/`Delete`
704    /// permissions against this specific table.
705    name: String,
706    /// Optional auth checker for per-operation enforcement. `None` on
707    /// credentialless databases (the default); `Some` when the database has
708    /// `require_auth = true`. The checker is shared (via `Arc`) so it sees
709    /// live updates to the principal and the `require_auth` flag.
710    auth: Option<Arc<dyn crate::auth_state::TableAuthChecker>>,
711    /// Logical writes are forbidden when this table belongs to a replication
712    /// follower. Replication itself appends through the database WAL API.
713    read_only: bool,
714    /// A WAL commit reached durable storage but its live publication failed.
715    /// Reads may continue for diagnostics, but writes require a clean reopen so
716    /// recovery can rebuild one coherent runtime state from the durable WAL.
717    durable_commit_failed: bool,
718    wal: WalSink,
719    memtable: Memtable,
720    /// PMA-backed mutable-run LSM tier (Phase 11.1). A flush drains the
721    /// memtable into this in-memory sorted tier instead of immediately writing
722    /// a `.sr` run; once it crosses `mutable_run_spill_bytes` it spills to an
723    /// immutable run. Purely in-memory — rebuilt from WAL replay on reopen.
724    mutable_run: MutableRun,
725    /// Byte watermark controlling when `mutable_run` spills to a sorted run.
726    mutable_run_spill_bytes: u64,
727    /// Zstd compression level for compaction output (Phase 18.1: default 3;
728    /// higher = better ratio but slower compaction).
729    compaction_zstd_level: i32,
730    allocator: RowIdAllocator,
731    epoch: Arc<EpochAuthority>,
732    /// Table-local content generation used by authorization caches. Unlike the
733    /// shared MVCC epoch, unrelated table commits do not change this value.
734    data_generation: u64,
735    schema: Schema,
736    hot: HotIndex,
737    /// Table Key-Encryption Key (Argon2id+HKDF from the passphrase). Each run
738    /// stores a fresh DEK wrapped by this KEK (see §7). `None` when plaintext.
739    kek: Option<Arc<Kek>>,
740    /// Per-column indexable-encryption keys + scheme (Phase 10.2) for every
741    /// ENCRYPTED_INDEXABLE column, derived deterministically from the KEK so
742    /// tokens are identical across runs. Empty when the table is plaintext.
743    column_keys: HashMap<u16, ([u8; 32], u8)>,
744    run_refs: Vec<RunRef>,
745    /// Runs superseded by compaction, kept on disk for snapshot retention until
746    /// `gc()` reaps them (spec §6.4). Persisted in the manifest (`retiring`).
747    retiring: Vec<crate::manifest::RetiredRun>,
748    next_run_id: u64,
749    sync_byte_threshold: u64,
750    /// Next transaction id to assign to a single-table auto-commit txn
751    /// (`put`/`delete` then `commit`). 0 is reserved for [`wal::SYSTEM_TXN_ID`].
752    /// The Database transaction layer (P2.5) assigns these globally; the
753    /// single-table path uses this local counter.
754    current_txn_id: u64,
755    /// True after a standalone table appends a private-WAL mutation and until
756    /// `commit_private` has durably sealed and published that transaction.
757    /// Mounted tables use `pending_rows` / `pending_dels` instead.
758    pending_private_mutations: bool,
759    bitmap: HashMap<u16, BitmapIndex>,
760    ann: HashMap<u16, AnnIndex>,
761    fm: HashMap<u16, FmIndex>,
762    sparse: HashMap<u16, SparseIndex>,
763    minhash: HashMap<u16, MinHashIndex>,
764    /// Per-column learned (PGM) range indexes for `IndexKind::LearnedRange`
765    /// columns, built from the single sorted run.
766    learned_range: Arc<HashMap<u16, ColumnLearnedRange>>,
767    /// Reverse primary-key map for HOT cleanup on row-id deletes.
768    pk_by_row: ReversePkMap,
769    /// Refcounted pinned read snapshots (epoch → count); compaction must not GC
770    /// versions an active snapshot still needs.
771    pinned: BTreeMap<Epoch, usize>,
772    /// Live (non-deleted) row count — maintained incrementally for O(1)
773    /// `Table::count()` without a scan.
774    pub(crate) live_count: u64,
775    /// Uniform reservoir sample of row ids for approximate analytics
776    /// (Phase 8.2). Maintained incrementally on insert; repopulated on open.
777    reservoir: crate::reservoir::Reservoir,
778    /// False when `reservoir` needs a full rebuild from `visible_rows` before
779    /// [`Table::approx_aggregate`] can trust it (same lazy pattern as
780    /// [`Table::ensure_indexes_complete`]). Open and WAL-replay leave this
781    /// false instead of eagerly materializing every row — a full-table scan
782    /// no plain insert/update/delete needs — and the first approximate-
783    /// aggregate call pays the rebuild, after which `.offer()` calls maintain
784    /// it incrementally.
785    reservoir_complete: bool,
786    /// True once any row has been deleted. The incremental aggregate cache
787    /// (Phase 8.3) is only valid for append-only tables, so a single delete
788    /// permanently disables incremental maintenance for this table.
789    had_deletes: bool,
790    /// Incremental aggregate cache (Phase 8.3): caller-supplied key → the
791    /// mergeable aggregate state, the row-id watermark it covers, and the
792    /// epoch. A re-query after more inserts processes only the delta and merges.
793    agg_cache: Arc<HashMap<u64, CachedAgg>>,
794    /// The manifest epoch the on-disk `_idx/global.idx` checkpoint covers (0 if
795    /// there is no checkpoint). Updated by [`Table::checkpoint_indexes`]; persisted
796    /// in the manifest so reopen loads the checkpoint instead of rebuilding.
797    global_idx_epoch: u64,
798    /// False when the live in-memory indexes are known to be incomplete (e.g.
799    /// after [`Table::bulk_load_columns`], which bypasses per-row indexing). A
800    /// flush in that state must NOT checkpoint; reopen rebuilds complete indexes
801    /// from the runs and resets this to true.
802    indexes_complete: bool,
803    /// Where bulk loads put the index-build cost (see [`IndexBuildPolicy`]).
804    index_build_policy: IndexBuildPolicy,
805    /// False when `pk_by_row` may be missing entries for rows present in
806    /// `hot`. Fresh tables start false and puts skip the reverse map — pure
807    /// ingest never pays for it. The first delete that needs it rebuilds it
808    /// from `hot` (the same lazy pattern as `ensure_indexes_complete`), after
809    /// which puts maintain it incrementally so a delete-active workload pays
810    /// the build exactly once.
811    pk_by_row_complete: bool,
812    /// Highest epoch whose data is durable in a sorted run (spec §7.1). Recovery
813    /// skips replaying WAL records whose commit epoch is `<= flushed_epoch`.
814    flushed_epoch: u64,
815    /// Shared, MVCC content-addressed page cache (Phase 9.2). Fed by every
816    /// `RunReader::read_page` so all readers share raw (decrypted) page bytes.
817    page_cache: Arc<crate::cache::Sharded<crate::cache::PageCache>>,
818    /// Global snapshot-retention registry shared across all tables in a
819    /// `Database`. Single-table direct opens get a private one.
820    snapshots: Arc<crate::retention::SnapshotRegistry>,
821    /// Cross-table commit serializer (see [`SharedCtx::commit_lock`]).
822    commit_lock: Arc<parking_lot::Mutex<()>>,
823    /// Shared decoded-page cache (Phase 15.4): the post-decompress/decrypt typed
824    /// page, so repeat scans skip decode. Keyed by `(run_id, column_id, page)`.
825    decoded_cache: Arc<crate::cache::Sharded<crate::cache::DecodedPageCache>>,
826    /// `run_id`s whose on-disk footer checksum has already been verified by a
827    /// `RunReader` construction in this process. `.sr` runs are immutable once
828    /// written, so re-hashing an already-verified run's full body on every
829    /// repeat `open_reader` call (every query, every `remove_hot_for_row`) is
830    /// pure waste for a warm/long-lived handle — this cache lets
831    /// `read_header_cached` skip straight to the cheap header+footer-magic
832    /// check after the first open. Scoped per-`Table` (not shared via
833    /// `SharedCtx`) since `run_id` is only unique within one table's own
834    /// manifest.
835    verified_runs: Arc<parking_lot::Mutex<std::collections::HashSet<u128>>>,
836    /// Table-level result cache (Phase 19.1): `canonical_query_key(conditions,
837    /// projection, epoch)` → the survivor columns as typed `NativeColumn`s. Shared
838    /// by the native `Condition` API and (via `query_cached`) the tool-call path,
839    /// which previously had no caching (only the SQL `MongrelSession` cache did).
840    /// Hardening (c): epoch is no longer in the key; instead, a `commit()`
841    /// invalidates only entries whose footprint or condition-columns intersect
842    /// the committed mutations, tracked in `pending_delete_rids` and
843    /// `pending_put_cols`.
844    result_cache: Arc<parking_lot::Mutex<ResultCache>>,
845    /// WAL DEK (for frame-level encryption). None for plaintext tables.
846    wal_dek: Option<Zeroizing<[u8; DEK_LEN]>>,
847    /// RowIds deleted since the last `commit()` — used by fine-grained cache
848    /// invalidation to check footprint intersection.
849    pending_delete_rids: roaring::RoaringBitmap,
850    /// Column IDs touched by `put`/`put_batch` since the last `commit()` — used
851    /// by conservative insert-newly-matches invalidation.
852    pending_put_cols: std::collections::HashSet<u16>,
853    /// B1/B2: rows staged by `put`/`put_batch` on a mounted (shared-WAL) table
854    /// but not yet applied to the memtable. They are re-stamped to the real
855    /// assigned epoch in `commit` (never a speculative `visible+1`), so a
856    /// concurrent reader can never observe them before their commit epoch.
857    /// Always empty on a standalone (private-WAL) table, which applies inline.
858    pending_rows: Vec<Row>,
859    pending_rows_auto_inc: Vec<bool>,
860    /// B1/B2: tombstones staged on a mounted table, applied at the assigned
861    /// epoch in `commit` (mirror of `pending_rows`).
862    pending_dels: Vec<RowId>,
863    /// B1/B2: truncate staged on a mounted table, applied at the assigned epoch
864    /// in `commit`; standalone tables also defer the physical clear until after
865    /// the private WAL is fsynced.
866    pending_truncate: Option<Epoch>,
867    /// Engine-managed `AUTO_INCREMENT` counter (`None` for tables without an
868    /// auto-increment primary key). See [`AutoIncState`].
869    auto_inc: Option<AutoIncState>,
870    /// Manifest-backed timestamp retention policy. Its wall-clock cutoff is
871    /// evaluated once per read/compaction operation, never cached by epoch.
872    ttl: Option<TtlPolicy>,
873    /// Unified version-retention pin registry (S1C-004). Read generations
874    /// register [`crate::retention::PinSource::ReadGeneration`] pins here;
875    /// backup/PITR, replication, and online-index-build wiring from the
876    /// `Database` layer is a follow-up (they can share this registry via
877    /// [`Table::pin_registry`]). Compaction and version GC consult it through
878    /// [`Table::min_active_snapshot`].
879    pins: Arc<crate::retention::PinRegistry>,
880    /// The atomically-published immutable read view (S1C-001). Writers store
881    /// a replacement after sealing their active deltas; readers pin the
882    /// loaded `Arc`. Read-generation clones get their own frozen cell so a
883    /// later writer publish can never mutate a pinned generation's view.
884    published: Arc<ArcSwap<ReadGeneration>>,
885    /// The [`crate::retention::PinGuard`] keeping this generation's epoch
886    /// retained. `None` on writer tables; `Some` on clones produced by
887    /// [`Table::clone_read_generation`], released when the generation drops.
888    /// Shared behind an `Arc` so cloning a generation shares one pin.
889    read_generation_pin: Option<Arc<crate::retention::PinGuard>>,
890}
891
892// `Table` is `Sync`: every field is either plain data, an `Arc`, a `Vec`/`HashMap`
893// of `Sync` data, or a thread-safe interior-mutability cell (`parking_lot::Mutex`,
894// `crossbeam`/`epoch` Arc-shared caches). The only `RefCell`-based type was
895// `FmIndex` (lazy rebuild of the BWT), which now uses a `Mutex`, so a `&Table`
896// can be safely shared across read threads (concurrent mutation still requires
897// the caller's `Mutex<Table>`).
898const _: () = {
899    const fn assert_sync<T: ?Sized + Sync>() {}
900    assert_sync::<Table>();
901};
902
903/// A cached query result — either survivor `Row`s (the tool-call/`query` path)
904/// or typed survivor columns (the pushdown/`query_columns_native` path). One
905/// canonical key maps to exactly one variant (a `query` with no projection vs a
906/// `query_columns_native` with a specific projection produce different keys), so
907/// there is no representation collision.
908enum CachedData {
909    Rows(Arc<Vec<Row>>),
910    Columns(Arc<Vec<(u16, columnar::NativeColumn)>>),
911}
912
913impl CachedData {
914    fn approx_bytes(&self) -> u64 {
915        match self {
916            CachedData::Rows(r) => r.iter().map(|r| r.estimated_bytes()).sum::<u64>(),
917            CachedData::Columns(c) => c
918                .iter()
919                .map(|(_, c)| c.approx_bytes())
920                .sum::<u64>()
921                .saturating_add(c.len() as u64 * 16),
922        }
923    }
924}
925
926/// A cached entry carrying the survivor `RowId` **footprint** (for precise
927/// delete-based invalidation) and the condition column IDs (for conservative
928/// insert-based invalidation). Hardening (c).
929struct CachedEntry {
930    data: CachedData,
931    footprint: roaring::RoaringBitmap,
932    condition_cols: Vec<u16>,
933}
934
935/// Size-bounded **access-order LRU** result cache (Phase 19.1 + hardening (a)).
936/// Every `get_*` promotes the key to the back (most-recently-used); eviction
937/// pops from the front (least-recently-used) — a true LRU, not FIFO.
938///
939/// Hardening (b): an optional on-disk persistent tier (`dir = Some(_)`). On a
940/// memory miss, the cache tries disk before falling through to re-resolution.
941/// On `insert`, the entry is also written to disk atomically (write + fsync +
942/// rename). On `invalidate`/`clear`, the matching disk files are deleted. On
943/// `Table::open`, existing disk entries are pre-loaded so fine-grained invalidation
944/// resumes across restart.
945struct ResultCache {
946    entries: std::collections::HashMap<u64, CachedEntry>,
947    order: std::collections::VecDeque<u64>,
948    bytes: u64,
949    max_bytes: u64,
950    dir: Option<std::path::PathBuf>,
951    #[allow(dead_code)]
952    cache_dek: Option<Zeroizing<[u8; DEK_LEN]>>,
953}
954
955/// Serialised form of a [`CachedEntry`] for the persistent on-disk tier (b).
956#[derive(serde::Serialize, serde::Deserialize)]
957struct SerializedEntry {
958    condition_cols: Vec<u16>,
959    footprint_bits: Vec<u32>,
960    data: SerializedData,
961}
962
963#[derive(serde::Serialize, serde::Deserialize)]
964enum SerializedData {
965    Rows(Vec<Row>),
966    Columns(Vec<(u16, columnar::NativeColumn)>),
967}
968
969impl SerializedEntry {
970    fn from_entry(entry: &CachedEntry) -> Self {
971        let footprint_bits: Vec<u32> = entry.footprint.iter().collect();
972        let data = match &entry.data {
973            CachedData::Rows(r) => SerializedData::Rows((**r).clone()),
974            CachedData::Columns(c) => SerializedData::Columns((**c).clone()),
975        };
976        Self {
977            condition_cols: entry.condition_cols.clone(),
978            footprint_bits,
979            data,
980        }
981    }
982
983    fn into_entry(self) -> Option<CachedEntry> {
984        let footprint: roaring::RoaringBitmap = self.footprint_bits.into_iter().collect();
985        let data = match self.data {
986            SerializedData::Rows(r) => CachedData::Rows(Arc::new(r)),
987            SerializedData::Columns(c) => {
988                // Validate deserialized columns (hardening (b)): reject corrupt
989                // data instead of panicking on access.
990                if !c.iter().all(|(_, col)| col.validate()) {
991                    return None;
992                }
993                CachedData::Columns(Arc::new(c))
994            }
995        };
996        Some(CachedEntry {
997            data,
998            footprint,
999            condition_cols: self.condition_cols,
1000        })
1001    }
1002}
1003
1004impl ResultCache {
1005    const DEFAULT_MAX_BYTES: u64 = 256 * 1024 * 1024;
1006
1007    fn new() -> Self {
1008        Self::with_max_bytes(Self::DEFAULT_MAX_BYTES)
1009    }
1010
1011    fn with_max_bytes(max_bytes: u64) -> Self {
1012        Self {
1013            entries: std::collections::HashMap::new(),
1014            order: std::collections::VecDeque::new(),
1015            bytes: 0,
1016            max_bytes,
1017            dir: None,
1018            cache_dek: None,
1019        }
1020    }
1021
1022    fn with_dir(mut self, dir: std::path::PathBuf) -> Self {
1023        let _ = std::fs::create_dir_all(&dir);
1024        self.dir = Some(dir);
1025        self
1026    }
1027
1028    fn with_cache_dek(mut self, dek: Option<Zeroizing<[u8; DEK_LEN]>>) -> Self {
1029        self.cache_dek = dek;
1030        self
1031    }
1032
1033    fn disk_path(&self, key: u64) -> Option<std::path::PathBuf> {
1034        self.dir.as_ref().map(|d| d.join(format!("{key:016x}.bin")))
1035    }
1036
1037    /// Atomically write `entry` to disk (write + rename). Best-effort: silently
1038    /// ignores I/O errors (the in-memory cache is authoritative; the cache is
1039    /// disposable — missing/stale files fall through to re-resolution).
1040    fn store_to_disk(&self, key: u64, entry: &CachedEntry) {
1041        let Some(path) = self.disk_path(key) else {
1042            return;
1043        };
1044        let serialized = match bincode::serialize(&SerializedEntry::from_entry(entry)) {
1045            Ok(s) => s,
1046            Err(_) => return,
1047        };
1048        // Encrypt if a cache DEK is present.
1049        let on_disk = if let Some(dek) = &self.cache_dek {
1050            match self.encrypt_cache(&serialized, dek) {
1051                Some(b) => b,
1052                None => return,
1053            }
1054        } else {
1055            serialized
1056        };
1057        let tmp = path.with_extension("tmp");
1058        use std::io::Write;
1059        let write = || -> std::io::Result<()> {
1060            let mut f = std::fs::File::create(&tmp)?;
1061            f.write_all(&on_disk)?;
1062            f.flush()?;
1063            Ok(())
1064        };
1065        if write().is_err() {
1066            let _ = std::fs::remove_file(&tmp);
1067            return;
1068        }
1069        let _ = std::fs::rename(&tmp, &path);
1070    }
1071
1072    /// Try loading `key` from disk. Returns `None` on miss or error.
1073    fn load_from_disk(&self, key: u64) -> Option<CachedEntry> {
1074        let path = self.disk_path(key)?;
1075        let bytes = std::fs::read(&path).ok()?;
1076        let plaintext = if let Some(dek) = &self.cache_dek {
1077            self.decrypt_cache(&bytes, dek)?
1078        } else {
1079            bytes
1080        };
1081        let serialized: SerializedEntry = bincode::deserialize(&plaintext).ok()?;
1082        serialized.into_entry()
1083    }
1084
1085    /// Delete the on-disk file for `key` if it exists. Best-effort.
1086    fn remove_from_disk(&self, key: u64) {
1087        if let Some(path) = self.disk_path(key) {
1088            let _ = std::fs::remove_file(&path);
1089        }
1090    }
1091
1092    /// Encrypt cache data: `[nonce: 12B][ciphertext + GCM tag]`.
1093    fn encrypt_cache(&self, plaintext: &[u8], dek: &Zeroizing<[u8; DEK_LEN]>) -> Option<Vec<u8>> {
1094        use crate::encryption::Cipher;
1095        let cipher = crate::encryption::AesCipher::new(&dek[..]).ok()?;
1096        let mut nonce = [0u8; 12];
1097        crate::encryption::fill_random(&mut nonce).ok()?;
1098        let ct = cipher.encrypt_page(&nonce, plaintext).ok()?;
1099        let mut out = Vec::with_capacity(12 + ct.len());
1100        out.extend_from_slice(&nonce);
1101        out.extend_from_slice(&ct);
1102        Some(out)
1103    }
1104
1105    /// Decrypt cache data: reads nonce from first 12 bytes.
1106    fn decrypt_cache(&self, bytes: &[u8], dek: &Zeroizing<[u8; DEK_LEN]>) -> Option<Vec<u8>> {
1107        use crate::encryption::Cipher;
1108        if bytes.len() < 28 {
1109            return None;
1110        }
1111        let cipher = crate::encryption::AesCipher::new(&dek[..]).ok()?;
1112        let nonce: [u8; 12] = bytes[..12].try_into().ok()?;
1113        let ct = &bytes[12..];
1114        cipher.decrypt_page(&nonce, ct).ok()
1115    }
1116
1117    /// Scan the cache directory and pre-load all entries into memory. Called
1118    /// once on `Table::open`. Best-effort: corrupt/unreadable files are deleted.
1119    fn load_persistent(&mut self) {
1120        let Some(dir) = self.dir.as_ref().cloned() else {
1121            return;
1122        };
1123        let entries = match std::fs::read_dir(&dir) {
1124            Ok(e) => e,
1125            Err(_) => return,
1126        };
1127        for entry in entries.flatten() {
1128            let path = entry.path();
1129            // Clean up orphan .tmp files from crashed store_to_disk calls.
1130            if path.extension().and_then(|e| e.to_str()) == Some("tmp") {
1131                let _ = std::fs::remove_file(&path);
1132                continue;
1133            }
1134            if path.extension().and_then(|e| e.to_str()) != Some("bin") {
1135                continue;
1136            }
1137            let stem = match path.file_stem().and_then(|s| s.to_str()) {
1138                Some(s) => s,
1139                None => continue,
1140            };
1141            let key = match u64::from_str_radix(stem, 16) {
1142                Ok(k) => k,
1143                Err(_) => continue,
1144            };
1145            let bytes = match std::fs::read(&path) {
1146                Ok(b) => b,
1147                Err(_) => continue,
1148            };
1149            // Decrypt if cache DEK is present.
1150            let plaintext = if let Some(dek) = &self.cache_dek {
1151                match self.decrypt_cache(&bytes, dek) {
1152                    Some(p) => p,
1153                    None => {
1154                        let _ = std::fs::remove_file(&path);
1155                        continue;
1156                    }
1157                }
1158            } else {
1159                bytes
1160            };
1161            match bincode::deserialize::<SerializedEntry>(&plaintext) {
1162                Ok(serialized) => {
1163                    if let Some(entry) = serialized.into_entry() {
1164                        self.bytes = self.bytes.saturating_add(entry.data.approx_bytes());
1165                        self.entries.insert(key, entry);
1166                        self.order.push_back(key);
1167                    } else {
1168                        let _ = std::fs::remove_file(&path);
1169                    }
1170                }
1171                Err(_) => {
1172                    let _ = std::fs::remove_file(&path);
1173                }
1174            }
1175        }
1176        self.evict();
1177    }
1178
1179    fn set_max_bytes(&mut self, max_bytes: u64) {
1180        self.max_bytes = max_bytes;
1181        self.evict();
1182    }
1183
1184    /// Promote `key` to most-recently-used position (back of the deque).
1185    fn touch(&mut self, key: u64) {
1186        self.order.retain(|k| *k != key);
1187        self.order.push_back(key);
1188    }
1189
1190    fn get_rows(&mut self, key: u64) -> Option<Arc<Vec<Row>>> {
1191        let res = self.entries.get(&key).and_then(|e| match &e.data {
1192            CachedData::Rows(r) => Some(r.clone()),
1193            CachedData::Columns(_) => None,
1194        });
1195        if res.is_some() {
1196            self.touch(key);
1197            return res;
1198        }
1199        // Memory miss → try the persistent tier (b).
1200        if let Some(entry) = self.load_from_disk(key) {
1201            let res = match &entry.data {
1202                CachedData::Rows(r) => Some(r.clone()),
1203                CachedData::Columns(_) => None,
1204            };
1205            if res.is_some() {
1206                let approx = entry.data.approx_bytes();
1207                self.bytes = self.bytes.saturating_add(approx);
1208                self.entries.insert(key, entry);
1209                self.order.push_back(key);
1210                self.evict();
1211                return res;
1212            }
1213        }
1214        None
1215    }
1216
1217    fn get_columns(&mut self, key: u64) -> Option<Arc<Vec<(u16, columnar::NativeColumn)>>> {
1218        let res = self.entries.get(&key).and_then(|e| match &e.data {
1219            CachedData::Columns(c) => Some(c.clone()),
1220            CachedData::Rows(_) => None,
1221        });
1222        if res.is_some() {
1223            self.touch(key);
1224            return res;
1225        }
1226        // Memory miss → try the persistent tier (b).
1227        if let Some(entry) = self.load_from_disk(key) {
1228            let res = match &entry.data {
1229                CachedData::Columns(c) => Some(c.clone()),
1230                CachedData::Rows(_) => None,
1231            };
1232            if res.is_some() {
1233                let approx = entry.data.approx_bytes();
1234                self.bytes = self.bytes.saturating_add(approx);
1235                self.entries.insert(key, entry);
1236                self.order.push_back(key);
1237                self.evict();
1238                return res;
1239            }
1240        }
1241        None
1242    }
1243
1244    fn insert(&mut self, key: u64, entry: CachedEntry) {
1245        let approx = entry.data.approx_bytes();
1246        if self.entries.remove(&key).is_some() {
1247            self.order.retain(|k| *k != key);
1248            self.bytes = self.entries.values().map(|e| e.data.approx_bytes()).sum();
1249        }
1250        // Write to the persistent tier (b) before memory insert.
1251        self.store_to_disk(key, &entry);
1252        self.bytes = self.bytes.saturating_add(approx);
1253        self.entries.insert(key, entry);
1254        self.order.push_back(key);
1255        self.evict();
1256    }
1257
1258    /// Fine-grained invalidation (hardening (c)). Drop only entries that are
1259    /// actually affected by the committed mutations:
1260    /// - **Delete path**: if `delete_rids` intersects an entry's footprint, a
1261    ///   survivor was deleted → stale. If the footprint is empty (multi-run or
1262    ///   non-empty memtable — we couldn't resolve it), **any** delete
1263    ///   conservatively invalidates the entry (correctness over precision).
1264    /// - **Insert path**: if `put_cols` intersects an entry's `condition_cols`,
1265    ///   a newly-inserted row might match the query → conservatively stale.
1266    fn invalidate(
1267        &mut self,
1268        delete_rids: &roaring::RoaringBitmap,
1269        put_cols: &std::collections::HashSet<u16>,
1270    ) {
1271        if self.entries.is_empty() {
1272            return;
1273        }
1274        let has_deletes = !delete_rids.is_empty();
1275        let to_remove: std::collections::HashSet<u64> = self
1276            .entries
1277            .iter()
1278            .filter(|(_, e)| {
1279                let delete_hit = if e.footprint.is_empty() {
1280                    has_deletes
1281                } else {
1282                    e.footprint.intersection_len(delete_rids) > 0
1283                };
1284                let col_hit = e.condition_cols.iter().any(|c| put_cols.contains(c));
1285                delete_hit || col_hit
1286            })
1287            .map(|(&k, _)| k)
1288            .collect();
1289        for key in &to_remove {
1290            if let Some(e) = self.entries.remove(key) {
1291                self.bytes = self.bytes.saturating_sub(e.data.approx_bytes());
1292            }
1293            self.remove_from_disk(*key);
1294        }
1295        if !to_remove.is_empty() {
1296            self.order.retain(|k| !to_remove.contains(k));
1297        }
1298    }
1299
1300    fn clear(&mut self) {
1301        // Delete all persistent files (b).
1302        if let Some(dir) = &self.dir {
1303            if let Ok(entries) = std::fs::read_dir(dir) {
1304                for entry in entries.flatten() {
1305                    let path = entry.path();
1306                    if path.extension().and_then(|e| e.to_str()) == Some("bin") {
1307                        let _ = std::fs::remove_file(&path);
1308                    }
1309                }
1310            }
1311        }
1312        self.entries.clear();
1313        self.order.clear();
1314        self.bytes = 0;
1315    }
1316
1317    fn evict(&mut self) {
1318        while self.bytes > self.max_bytes {
1319            let Some(k) = self.order.pop_front() else {
1320                break;
1321            };
1322            if let Some(e) = self.entries.remove(&k) {
1323                self.bytes = self.bytes.saturating_sub(e.data.approx_bytes());
1324                // Also delete the disk file (hardening (b)): an evicted entry's
1325                // disk file must not survive, or invalidate() — which only scans
1326                // in-memory entries — would miss it and allow a stale disk hit.
1327                self.remove_from_disk(k);
1328            }
1329        }
1330    }
1331}
1332
1333/// Derive per-column indexable-encryption keys (Phase 10.2) for every
1334/// ENCRYPTED_INDEXABLE column from the KEK. Scheme is `OPE_RANGE` if the column
1335/// has a `LearnedRange` index, else `HMAC_EQ` (equality). Keys are derived
1336/// deterministically from the KEK so tokens are stable across runs. Empty when
1337/// the table is plaintext (no KEK).
1338/// Derive WAL and cache DEKs from the KEK (None when no encryption).
1339type DekaOpt = Option<Zeroizing<[u8; DEK_LEN]>>;
1340
1341fn derive_subkeys(kek: Option<&Kek>, _table_id: u64) -> (DekaOpt, DekaOpt) {
1342    let _ = kek;
1343    {
1344        if let Some(k) = kek {
1345            return (
1346                Some(k.derive_table_wal_key(_table_id)),
1347                Some(k.derive_cache_key()),
1348            );
1349        }
1350    }
1351    (None, None)
1352}
1353
1354fn read_table_encryption_salt_root(
1355    root: &crate::durable_file::DurableRoot,
1356) -> Result<[u8; crate::encryption::SALT_LEN]> {
1357    use std::io::Read;
1358
1359    let mut file = root
1360        .open_regular(Path::new(META_DIR).join(KEYS_FILENAME))
1361        .map_err(|error| MongrelError::NotFound(format!("encryption salt file: {error}")))?;
1362    let length = file.metadata()?.len();
1363    if length != crate::encryption::SALT_LEN as u64 {
1364        return Err(MongrelError::InvalidArgument(format!(
1365            "salt file is {length} bytes, expected {}",
1366            crate::encryption::SALT_LEN
1367        )));
1368    }
1369    let mut salt = [0_u8; crate::encryption::SALT_LEN];
1370    file.read_exact(&mut salt)?;
1371    Ok(salt)
1372}
1373
1374/// Create a boxed cipher from a DEK.
1375fn make_cipher(dek: &Zeroizing<[u8; DEK_LEN]>) -> Box<dyn crate::encryption::Cipher> {
1376    Box::new(crate::encryption::AesCipher::new(&dek[..]).expect("DEK is 32 bytes"))
1377}
1378
1379fn build_column_keys(kek: Option<&Kek>, schema: &Schema) -> HashMap<u16, ([u8; 32], u8)> {
1380    let Some(kek) = kek else {
1381        return HashMap::new();
1382    };
1383    {
1384        use crate::encryption::{SCHEME_HMAC_EQ, SCHEME_OPE_RANGE};
1385        schema
1386            .columns
1387            .iter()
1388            .filter(|c| c.flags.contains(ColumnFlags::ENCRYPTED_INDEXABLE))
1389            .map(|c| {
1390                let scheme = if schema
1391                    .indexes
1392                    .iter()
1393                    .any(|i| i.column_id == c.id && i.kind == IndexKind::LearnedRange)
1394                {
1395                    SCHEME_OPE_RANGE
1396                } else {
1397                    SCHEME_HMAC_EQ
1398                };
1399                let key: [u8; 32] = *kek.derive_column_key(c.id);
1400                (c.id, (key, scheme))
1401            })
1402            .collect()
1403    }
1404}
1405
1406/// Shared services injected into every `Table` owned by a `Database`: one epoch
1407/// authority (single commit clock), one raw-page cache, one decoded-page cache,
1408/// one snapshot-retention registry, and the DB-wide KEK. A directly-opened
1409/// single table builds a private `SharedCtx` of its own.
1410pub(crate) struct SharedCtx {
1411    pub root_guard: Option<Arc<crate::durable_file::DurableRoot>>,
1412    pub epoch: Arc<EpochAuthority>,
1413    pub page_cache: Arc<crate::cache::Sharded<crate::cache::PageCache>>,
1414    pub decoded_cache: Arc<crate::cache::Sharded<crate::cache::DecodedPageCache>>,
1415    pub snapshots: Arc<crate::retention::SnapshotRegistry>,
1416    pub kek: Option<Arc<Kek>>,
1417    /// Serializes the commit critical section across all tables sharing this
1418    /// context so the dual-counter's in-order-publish invariant holds: the
1419    /// assigned ticket is reserved, the WAL fsynced, the manifest persisted,
1420    /// and `visible` published as one atomic unit. P3 replaces this with the
1421    /// bounded validate-first sequencer + group commit (overlapping fsync).
1422    pub commit_lock: Arc<parking_lot::Mutex<()>>,
1423    /// B1: when `Some`, the table is mounted in a `Database` and routes every
1424    /// write through the one shared WAL (no private `_wal/` dir is created).
1425    /// `None` for a directly-opened standalone table, which keeps a private WAL.
1426    pub shared: Option<SharedWalCtx>,
1427    /// The table's catalog name (for auth enforcement). `None` on standalone
1428    /// direct-open tables that have no catalog entry.
1429    pub table_name: Option<String>,
1430    /// Auth checker for per-operation enforcement. `None` on credentialless
1431    /// databases; cloned from the `Database`'s `auth_state` wrapper.
1432    pub auth: Option<Arc<dyn crate::auth_state::TableAuthChecker>>,
1433    /// Whether logical writes must be rejected for a replica database.
1434    pub read_only: bool,
1435}
1436
1437/// Handles a mounted table needs to write to the database's single shared WAL
1438/// (B1): the WAL itself, the group-commit coordinator + poison flag (so a
1439/// single-table commit honors the same durability/§9.3e semantics as a cross-
1440/// table txn), and the shared txn-id allocator (so auto-commit ids never alias
1441/// cross-table ones in the merged log).
1442#[derive(Clone)]
1443pub(crate) struct SharedWalCtx {
1444    pub wal: Arc<parking_lot::Mutex<SharedWal>>,
1445    pub group: Arc<GroupCommit>,
1446    pub poisoned: Arc<AtomicBool>,
1447    pub txn_ids: Arc<parking_lot::Mutex<u64>>,
1448    pub change_wake: tokio::sync::broadcast::Sender<()>,
1449    /// S1A-004: the owning core's lifecycle, poisoned at every fsync-error
1450    /// site so the whole core rejects later operations.
1451    pub lifecycle: Arc<crate::core::LifecycleController>,
1452    /// Database HLC clock used to stamp single-table commit row versions (P0.5).
1453    pub hlc: Arc<mongreldb_types::hlc::HlcClock>,
1454}
1455
1456/// Where a table's WAL records go. A standalone table owns a `Private` WAL; a
1457/// `Database`-mounted table writes to the one `Shared` WAL (B1).
1458enum WalSink {
1459    Private(Wal),
1460    Shared(SharedWalCtx),
1461    ReadOnly,
1462}
1463
1464impl Clone for WalSink {
1465    fn clone(&self) -> Self {
1466        match self {
1467            Self::Shared(shared) => Self::Shared(shared.clone()),
1468            Self::Private(_) | Self::ReadOnly => Self::ReadOnly,
1469        }
1470    }
1471}
1472
1473impl SharedCtx {
1474    /// Build a fresh private (standalone) context. `cache_dir = Some(_)` enables
1475    /// on-disk page cache persistence (single-table direct open); `None` keeps
1476    /// it in-memory (shared across tables in a `Database`).
1477    pub(crate) fn new(kek: Option<Arc<Kek>>, cache_dir: Option<PathBuf>) -> Self {
1478        // §5.8: shard the caches to reduce lock contention under parallel
1479        // rayon scans. The persistent (single-table) path uses 1 shard (no
1480        // contention) so its on-disk load/spill stays simple.
1481        let n_shards = if cache_dir.is_some() {
1482            1
1483        } else {
1484            crate::cache::CACHE_SHARDS
1485        };
1486        let per_shard = PAGE_CACHE_CAPACITY / n_shards as u64;
1487        let page_cache = if let Some(d) = cache_dir {
1488            Arc::new(crate::cache::Sharded::new(1, || {
1489                crate::cache::PageCache::new(PAGE_CACHE_CAPACITY).with_persistence(d.clone())
1490            }))
1491        } else {
1492            Arc::new(crate::cache::Sharded::new(n_shards, || {
1493                crate::cache::PageCache::new(per_shard)
1494            }))
1495        };
1496        let decoded_per_shard = DECODED_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64;
1497        let decoded_cache = Arc::new(crate::cache::Sharded::new(
1498            crate::cache::CACHE_SHARDS,
1499            || crate::cache::DecodedPageCache::new(decoded_per_shard),
1500        ));
1501        Self {
1502            root_guard: None,
1503            epoch: Arc::new(EpochAuthority::new(0)),
1504            page_cache,
1505            decoded_cache,
1506            snapshots: Arc::new(crate::retention::SnapshotRegistry::new()),
1507            kek,
1508            commit_lock: Arc::new(parking_lot::Mutex::new(())),
1509            shared: None,
1510            table_name: None,
1511            auth: None,
1512            read_only: false,
1513        }
1514    }
1515}
1516
1517/// §5.5: estimated per-condition resolution cost for cheap-first conjunction
1518/// ordering. Lower is resolved first so a selective O(1) index lookup can
1519/// short-circuit an expensive range/FM/vector scan.
1520fn condition_cost_rank(c: &crate::query::Condition) -> u8 {
1521    use crate::query::Condition;
1522    match c {
1523        // O(1) index lookups — resolve first.
1524        Condition::Pk(_)
1525        | Condition::BitmapEq { .. }
1526        | Condition::BitmapIn { .. }
1527        | Condition::BytesPrefix { .. }
1528        | Condition::IsNull { .. }
1529        | Condition::IsNotNull { .. } => 0,
1530        // Page-pruned scan or LSH candidate lookup.
1531        Condition::Range { .. } | Condition::RangeF64 { .. } | Condition::MinHashSimilar { .. } => {
1532            1
1533        }
1534        // FM locate / vector scans — most expensive, resolve last.
1535        Condition::FmContains { .. }
1536        | Condition::FmContainsAll { .. }
1537        | Condition::Ann { .. }
1538        | Condition::SparseMatch { .. } => 2,
1539    }
1540}
1541
1542impl Table {
1543    /// Build one hidden secondary index from authoritative visible rows.
1544    /// Callers run this against a pinned read generation, outside the final
1545    /// publication barrier. `checkpoint` supplies cooperative cancellation,
1546    /// resource checks, and progress reporting without coupling the engine to
1547    /// the jobs framework.
1548    pub(crate) fn build_secondary_index_artifact<F>(
1549        &self,
1550        definition: &IndexDef,
1551        rows: &[Row],
1552        mut checkpoint: F,
1553    ) -> Result<SecondaryIndexArtifact>
1554    where
1555        F: FnMut(usize, usize) -> Result<()>,
1556    {
1557        let mut build_schema = self.schema.clone();
1558        build_schema.indexes = vec![definition.clone()];
1559        let (mut bitmap, mut ann, mut fm, mut sparse, mut minhash) = empty_indexes(&build_schema);
1560        let name_to_id: HashMap<&str, u16> = self
1561            .schema
1562            .columns
1563            .iter()
1564            .map(|column| (column.name.as_str(), column.id))
1565            .collect();
1566        let mut hot = HotIndex::new();
1567        let mut accepted = Vec::with_capacity(rows.len());
1568
1569        for (offset, row) in rows.iter().enumerate() {
1570            checkpoint(offset, rows.len())?;
1571            if row.deleted {
1572                continue;
1573            }
1574            if let Some(predicate) = &definition.predicate {
1575                let columns: HashMap<u16, &Value> =
1576                    row.columns.iter().map(|(id, value)| (*id, value)).collect();
1577                if !eval_partial_predicate(predicate, &columns, &name_to_id) {
1578                    continue;
1579                }
1580            }
1581            accepted.push(row);
1582            if definition.kind == IndexKind::LearnedRange {
1583                continue;
1584            }
1585            let effective = if self.column_keys.is_empty() {
1586                row.clone()
1587            } else {
1588                self.tokenized_for_indexes(row)
1589            };
1590            if definition.kind == IndexKind::Ann {
1591                if let (Some(index), Some(vector)) = (
1592                    ann.get_mut(&definition.column_id),
1593                    effective
1594                        .columns
1595                        .get(&definition.column_id)
1596                        .and_then(Value::as_embedding),
1597                ) {
1598                    index.insert_validated_with_checkpoint(vector, effective.row_id, || {
1599                        checkpoint(offset, rows.len())
1600                    })?;
1601                }
1602            } else {
1603                index_into_single(
1604                    definition,
1605                    &build_schema,
1606                    &effective,
1607                    &mut hot,
1608                    &mut bitmap,
1609                    &mut ann,
1610                    &mut fm,
1611                    &mut sparse,
1612                    &mut minhash,
1613                );
1614            }
1615        }
1616        checkpoint(rows.len(), rows.len())?;
1617
1618        if let Some(index) = ann.get_mut(&definition.column_id) {
1619            index.seal_with_checkpoint(|| checkpoint(rows.len(), rows.len()))?;
1620        }
1621
1622        let column_id = definition.column_id;
1623        match definition.kind {
1624            IndexKind::Bitmap => bitmap
1625                .remove(&column_id)
1626                .map(|index| SecondaryIndexArtifact::Bitmap(column_id, index)),
1627            IndexKind::Ann => ann
1628                .remove(&column_id)
1629                .map(|index| SecondaryIndexArtifact::Ann(column_id, Box::new(index))),
1630            IndexKind::FmIndex => fm
1631                .remove(&column_id)
1632                .map(|index| SecondaryIndexArtifact::Fm(column_id, Box::new(index))),
1633            IndexKind::Sparse => sparse
1634                .remove(&column_id)
1635                .map(|index| SecondaryIndexArtifact::Sparse(column_id, index)),
1636            IndexKind::MinHash => minhash
1637                .remove(&column_id)
1638                .map(|index| SecondaryIndexArtifact::MinHash(column_id, index)),
1639            IndexKind::LearnedRange => {
1640                let epsilon = definition
1641                    .options
1642                    .learned_range
1643                    .as_ref()
1644                    .map(|options| options.epsilon)
1645                    .unwrap_or(16);
1646                let column = self
1647                    .schema
1648                    .columns
1649                    .iter()
1650                    .find(|column| column.id == column_id)
1651                    .ok_or_else(|| {
1652                        MongrelError::Schema(format!(
1653                            "index {} references unknown column {column_id}",
1654                            definition.name
1655                        ))
1656                    })?;
1657                let index = match column.ty {
1658                    TypeId::Int64 | TypeId::TimestampNanos | TypeId::Date32 => {
1659                        let pairs: Vec<_> = accepted
1660                            .iter()
1661                            .filter_map(|row| match row.columns.get(&column_id) {
1662                                Some(Value::Int64(value)) if !row.deleted => {
1663                                    Some((*value, row.row_id.0))
1664                                }
1665                                _ => None,
1666                            })
1667                            .collect();
1668                        ColumnLearnedRange::build_i64_with_epsilon(&pairs, epsilon)
1669                    }
1670                    TypeId::Float32 | TypeId::Float64 => {
1671                        let pairs: Vec<_> = accepted
1672                            .iter()
1673                            .filter_map(|row| match row.columns.get(&column_id) {
1674                                Some(Value::Float64(value)) if !row.deleted => {
1675                                    Some((*value, row.row_id.0))
1676                                }
1677                                _ => None,
1678                            })
1679                            .collect();
1680                        ColumnLearnedRange::build_f64_with_epsilon(&pairs, epsilon)
1681                    }
1682                    ref ty => {
1683                        return Err(MongrelError::Schema(format!(
1684                            "LearnedRange index {} does not support {ty:?}",
1685                            definition.name
1686                        )));
1687                    }
1688                };
1689                Some(SecondaryIndexArtifact::LearnedRange(column_id, index))
1690            }
1691        }
1692        .ok_or_else(|| {
1693            MongrelError::Other(format!(
1694                "failed to construct hidden index {}",
1695                definition.name
1696            ))
1697        })
1698    }
1699
1700    pub fn create(dir: impl AsRef<Path>, schema: Schema, table_id: u64) -> Result<Self> {
1701        let dir = dir.as_ref().to_path_buf();
1702        crate::durable_file::create_directory_all(&dir)?;
1703        let root = Arc::new(crate::durable_file::DurableRoot::open(&dir)?);
1704        let pinned = root.io_path()?;
1705        let mut ctx = SharedCtx::new(None, Some(pinned.join(CACHE_DIR)));
1706        ctx.root_guard = Some(root);
1707        Self::create_in(&pinned, schema, table_id, ctx)
1708    }
1709
1710    /// Create a new encrypted table, deriving the table Key-Encryption Key
1711    /// (KEK) from `passphrase` via Argon2id + HKDF (§7). A fresh random salt is
1712    /// generated and persisted under `_meta/keys` so the same passphrase
1713    /// recreates the KEK on reopen. Each run gets its own wrapped DEK.
1714    ///
1715    /// **Scope (§7):** encryption is *page-granular* — only sorted-run page
1716    /// payloads are encrypted. The live WAL (`_wal/`) holds rows as plaintext
1717    /// between `put` and `flush`; call `flush()` (which rotates the WAL) before
1718    /// treating sensitive data as fully at-rest-protected. Full WAL encryption
1719    /// is deferred.
1720    pub fn create_encrypted(
1721        dir: impl AsRef<Path>,
1722        schema: Schema,
1723        table_id: u64,
1724        passphrase: &str,
1725    ) -> Result<Self> {
1726        let dir = dir.as_ref().to_path_buf();
1727        crate::durable_file::create_directory_all(&dir)?;
1728        let root = Arc::new(crate::durable_file::DurableRoot::open(&dir)?);
1729        root.create_directory_all(META_DIR)?;
1730        let salt = crate::encryption::random_salt()?;
1731        root.write_atomic(Path::new(META_DIR).join(KEYS_FILENAME), &salt)?;
1732        let kek: Arc<Kek> = Arc::new(Kek::derive(passphrase, &salt)?);
1733        let pinned = root.io_path()?;
1734        let mut ctx = SharedCtx::new(Some(kek), Some(pinned.join(CACHE_DIR)));
1735        ctx.root_guard = Some(root);
1736        Self::create_in(&pinned, schema, table_id, ctx)
1737    }
1738
1739    /// Create a new encrypted table using a raw key (e.g. from a key file)
1740    /// instead of a passphrase. Skips Argon2id — the key must already be
1741    /// high-entropy (>= 32 bytes of random data). ~0.1ms vs ~50ms for the
1742    /// passphrase path.
1743    pub fn create_with_key(
1744        dir: impl AsRef<Path>,
1745        schema: Schema,
1746        table_id: u64,
1747        key: &[u8],
1748    ) -> Result<Self> {
1749        let dir = dir.as_ref().to_path_buf();
1750        crate::durable_file::create_directory_all(&dir)?;
1751        let root = Arc::new(crate::durable_file::DurableRoot::open(&dir)?);
1752        root.create_directory_all(META_DIR)?;
1753        let salt = crate::encryption::random_salt()?;
1754        root.write_atomic(Path::new(META_DIR).join(KEYS_FILENAME), &salt)?;
1755        let kek: Arc<Kek> = Arc::new(Kek::from_raw_key(key, &salt)?);
1756        let pinned = root.io_path()?;
1757        let mut ctx = SharedCtx::new(Some(kek), Some(pinned.join(CACHE_DIR)));
1758        ctx.root_guard = Some(root);
1759        Self::create_in(&pinned, schema, table_id, ctx)
1760    }
1761
1762    /// Open an existing encrypted table using a raw key.
1763    pub fn open_with_key(dir: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
1764        let root = Arc::new(crate::durable_file::DurableRoot::open(dir.as_ref())?);
1765        let salt = read_table_encryption_salt_root(&root)?;
1766        let kek = Arc::new(Kek::from_raw_key(key, &salt)?);
1767        let pinned = root.io_path()?;
1768        let mut ctx = SharedCtx::new(Some(kek), Some(pinned.join(CACHE_DIR)));
1769        ctx.root_guard = Some(root);
1770        Self::open_in(&pinned, ctx)
1771    }
1772
1773    pub(crate) fn create_in(
1774        dir: impl AsRef<Path>,
1775        schema: Schema,
1776        table_id: u64,
1777        ctx: SharedCtx,
1778    ) -> Result<Self> {
1779        schema.validate_auto_increment()?;
1780        schema.validate_defaults()?;
1781        schema.validate_ai()?;
1782        for index in &schema.indexes {
1783            index.validate_options()?;
1784        }
1785        let dir = dir.as_ref().to_path_buf();
1786        let runs_root = match ctx.root_guard.as_ref() {
1787            Some(root) => Some(Arc::new(root.create_directory_all_pinned(RUNS_DIR)?)),
1788            None => {
1789                crate::durable_file::create_directory_all(&dir)?;
1790                crate::durable_file::create_directory_all(&dir.join(RUNS_DIR))?;
1791                None
1792            }
1793        };
1794        match ctx.root_guard.as_deref() {
1795            Some(root) => write_schema_durable(root, &schema)?,
1796            None => write_schema(&dir, &schema)?,
1797        }
1798        let (wal_dek, cache_dek) = derive_subkeys(ctx.kek.as_deref(), table_id);
1799        // B1: a mounted table routes writes through the shared WAL and never
1800        // creates its own `_wal/` dir. A standalone table owns a private WAL.
1801        let (wal, current_txn_id) = match ctx.shared.clone() {
1802            Some(s) => (WalSink::Shared(s), 0),
1803            None => {
1804                let pinned_wal_root = match ctx.root_guard.as_deref() {
1805                    Some(root) => Some(root.create_directory_all_pinned(WAL_DIR)?),
1806                    None => None,
1807                };
1808                let wal_dir = if let Some(root) = pinned_wal_root.as_ref() {
1809                    root.io_path()?
1810                } else {
1811                    let wal_dir = dir.join(WAL_DIR);
1812                    crate::durable_file::create_directory_all(&wal_dir)?;
1813                    wal_dir
1814                };
1815                let mut w = if let Some(ref dk) = wal_dek {
1816                    Wal::create_with_cipher(
1817                        wal_dir.join("seg-000000.wal"),
1818                        Epoch(0),
1819                        Some(make_cipher(dk)),
1820                        0,
1821                    )?
1822                } else {
1823                    Wal::create(wal_dir.join("seg-000000.wal"), Epoch(0))?
1824                };
1825                w.set_sync_byte_threshold(DEFAULT_SYNC_BYTE_THRESHOLD);
1826                (WalSink::Private(w), 1)
1827            }
1828        };
1829        let mut manifest = Manifest::new(table_id, schema.schema_id);
1830        // Seal the create-time manifest with the meta DEK so an encrypted table
1831        // reopens even if no write/flush ever re-persists it (otherwise the
1832        // reopen's encrypted manifest read fails to authenticate a plaintext
1833        // blob — see `manifest_meta_dek`).
1834        let manifest_meta_dek = crate::encryption::meta_dek_for(ctx.kek.as_deref());
1835        match ctx.root_guard.as_deref() {
1836            Some(root) => manifest::write_durable(root, &mut manifest, manifest_meta_dek.as_ref())?,
1837            None => manifest::write_atomic(&dir, &mut manifest, manifest_meta_dek.as_ref())?,
1838        }
1839        let (bitmap, ann, fm, sparse, minhash) = empty_indexes(&schema);
1840        let column_keys = build_column_keys(ctx.kek.as_deref(), &schema);
1841        let auto_inc = resolve_auto_inc(&schema);
1842        let rcache_dir = dir.join(RCACHE_DIR);
1843        let initial_view = ReadGeneration::empty(&schema);
1844        Ok(Self {
1845            dir,
1846            _root_guard: ctx.root_guard,
1847            runs_root,
1848            idx_root: None,
1849            table_id,
1850            name: ctx.table_name.unwrap_or_default(),
1851            auth: ctx.auth,
1852            read_only: ctx.read_only,
1853            durable_commit_failed: false,
1854            wal,
1855            memtable: Memtable::new(),
1856            mutable_run: MutableRun::new(),
1857            mutable_run_spill_bytes: DEFAULT_MUTABLE_RUN_SPILL_BYTES,
1858            compaction_zstd_level: 3,
1859            allocator: RowIdAllocator::new(0),
1860            epoch: ctx.epoch,
1861            data_generation: 0,
1862            schema,
1863            hot: HotIndex::new(),
1864            kek: ctx.kek,
1865            column_keys,
1866            run_refs: Vec::new(),
1867            retiring: Vec::new(),
1868            next_run_id: 1,
1869            sync_byte_threshold: DEFAULT_SYNC_BYTE_THRESHOLD,
1870            current_txn_id,
1871            pending_private_mutations: false,
1872            bitmap,
1873            ann,
1874            fm,
1875            sparse,
1876            minhash,
1877            learned_range: Arc::new(HashMap::new()),
1878            pk_by_row: ReversePkMap::new(),
1879            pinned: BTreeMap::new(),
1880            live_count: 0,
1881            reservoir: crate::reservoir::Reservoir::default(),
1882            reservoir_complete: true,
1883            had_deletes: false,
1884            agg_cache: Arc::new(HashMap::new()),
1885            global_idx_epoch: 0,
1886            indexes_complete: true,
1887            index_build_policy: IndexBuildPolicy::default(),
1888            pk_by_row_complete: false,
1889            flushed_epoch: 0,
1890            page_cache: ctx.page_cache,
1891            decoded_cache: ctx.decoded_cache,
1892            verified_runs: Arc::new(parking_lot::Mutex::new(std::collections::HashSet::new())),
1893            snapshots: ctx.snapshots,
1894            commit_lock: ctx.commit_lock,
1895            result_cache: Arc::new(parking_lot::Mutex::new(
1896                ResultCache::new()
1897                    .with_dir(rcache_dir)
1898                    .with_cache_dek(cache_dek.clone()),
1899            )),
1900            pending_delete_rids: roaring::RoaringBitmap::new(),
1901            pending_put_cols: std::collections::HashSet::new(),
1902            pending_rows: Vec::new(),
1903            pending_rows_auto_inc: Vec::new(),
1904            pending_dels: Vec::new(),
1905            pending_truncate: None,
1906            wal_dek,
1907            auto_inc,
1908            ttl: None,
1909            pins: Arc::new(crate::retention::PinRegistry::new()),
1910            published: Arc::new(ArcSwap::from_pointee(initial_view)),
1911            read_generation_pin: None,
1912        })
1913    }
1914
1915    /// Open an existing table: load the manifest, replay the active WAL segment
1916    /// into the memtable, and rebuild the HOT + secondary indexes from the runs
1917    /// and replayed rows.
1918    pub fn open(dir: impl AsRef<Path>) -> Result<Self> {
1919        let root = Arc::new(crate::durable_file::DurableRoot::open(dir.as_ref())?);
1920        let pinned = root.io_path()?;
1921        let mut ctx = SharedCtx::new(None, Some(pinned.join(CACHE_DIR)));
1922        ctx.root_guard = Some(root);
1923        Self::open_in(&pinned, ctx)
1924    }
1925
1926    /// Open an existing encrypted table. `passphrase` must match the one used at
1927    /// create time (combined with the persisted salt to re-derive the KEK).
1928    pub fn open_encrypted(dir: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
1929        let root = Arc::new(crate::durable_file::DurableRoot::open(dir.as_ref())?);
1930        let salt = read_table_encryption_salt_root(&root)?;
1931        let kek: Arc<Kek> = Arc::new(Kek::derive(passphrase, &salt)?);
1932        let pinned = root.io_path()?;
1933        let mut ctx = SharedCtx::new(Some(kek), Some(pinned.join(CACHE_DIR)));
1934        ctx.root_guard = Some(root);
1935        let t = Self::open_in(&pinned, ctx)?;
1936        Ok(t)
1937    }
1938
1939    pub(crate) fn open_in(dir: impl AsRef<Path>, ctx: SharedCtx) -> Result<Self> {
1940        let dir = dir.as_ref().to_path_buf();
1941        let manifest_meta_dek = crate::encryption::meta_dek_for(ctx.kek.as_deref());
1942        let mut manifest = match ctx.root_guard.as_ref() {
1943            Some(root) => manifest::read_durable(root, "", manifest_meta_dek.as_ref())?,
1944            None => manifest::read(&dir, manifest_meta_dek.as_ref())?,
1945        };
1946        let schema: Schema = match ctx.root_guard.as_ref() {
1947            Some(root) => read_schema_file(root.open_regular(SCHEMA_FILENAME)?)?,
1948            None => read_schema(&dir)?,
1949        };
1950        // A standalone schema change publishes the schema before its matching
1951        // manifest. If the process dies in that narrow window, the newer,
1952        // fully validated schema is authoritative and the manifest identity is
1953        // repaired only after the rest of open has passed preflight. A manifest
1954        // claiming a schema newer than the durable schema remains corruption.
1955        let schema_manifest_repair = manifest.schema_id < schema.schema_id;
1956        let runs_root = match ctx.root_guard.as_ref() {
1957            Some(root) => Some(Arc::new(root.open_directory(RUNS_DIR)?)),
1958            None => None,
1959        };
1960        let idx_root = match ctx.root_guard.as_ref() {
1961            Some(root) => match root.open_directory(global_idx::IDX_DIR) {
1962                Ok(root) => Some(Arc::new(root)),
1963                Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
1964                Err(error) => return Err(error.into()),
1965            },
1966            None => None,
1967        };
1968        schema.validate_auto_increment()?;
1969        schema.validate_defaults()?;
1970        schema.validate_ai()?;
1971        for index in &schema.indexes {
1972            index.validate_options()?;
1973        }
1974        let replay_epoch = Epoch(manifest.current_epoch);
1975        let (wal_dek, cache_dek) = derive_subkeys(ctx.kek.as_deref(), manifest.table_id);
1976        let private_replayed = if ctx.shared.is_none() {
1977            match latest_wal_segment(&dir.join(WAL_DIR))? {
1978                Some(path) => {
1979                    let cipher = wal_dek.as_ref().map(|dk| make_cipher(dk));
1980                    crate::wal::replay_with_cipher(path, cipher)?
1981                }
1982                None => Vec::new(),
1983            }
1984        } else {
1985            Vec::new()
1986        };
1987        if ctx.shared.is_none() {
1988            preflight_standalone_open(
1989                &dir,
1990                runs_root.as_deref(),
1991                idx_root.as_deref(),
1992                &manifest,
1993                &schema,
1994                &private_replayed,
1995                ctx.kek.clone(),
1996            )?;
1997        }
1998        let next_run_id = derive_next_run_id(
1999            &dir,
2000            runs_root.as_deref(),
2001            &manifest.runs,
2002            &manifest.retiring,
2003        )?;
2004        // B1: a mounted table has no private WAL — its committed records live in
2005        // the shared WAL and are replayed by `Database::recover_shared_wal`. A
2006        // standalone table replays + reopens its own `_wal/` segment here.
2007        let (wal, replayed, current_txn_id) = match ctx.shared.clone() {
2008            Some(s) => (WalSink::Shared(s), Vec::new(), 0),
2009            None => {
2010                let replayed = private_replayed;
2011                // Never truncate the only durable recovery source. Re-encode
2012                // every valid frame into a synced staging segment, then publish
2013                // it atomically under the next segment number. A crash before
2014                // publication leaves the old segment authoritative; a crash
2015                // afterward finds the complete replacement as the latest WAL.
2016                let wal_dir = dir.join(WAL_DIR);
2017                crate::durable_file::create_directory_all(&wal_dir)?;
2018                let segment = next_wal_segment(&wal_dir)?;
2019                let segment_no = wal_segment_number(&segment).unwrap_or(0);
2020                let temporary = wal_dir.join(format!(
2021                    ".recovery-{}-{}-{segment_no:06}.tmp",
2022                    std::process::id(),
2023                    std::time::SystemTime::now()
2024                        .duration_since(std::time::UNIX_EPOCH)
2025                        .unwrap_or_default()
2026                        .as_nanos()
2027                ));
2028                let mut w = Wal::create_with_cipher(
2029                    &temporary,
2030                    replay_epoch,
2031                    wal_dek.as_ref().map(|dk| make_cipher(dk)),
2032                    segment_no,
2033                )?;
2034                for record in &replayed {
2035                    w.append_txn(record.txn_id, record.op.clone())?;
2036                }
2037                let mut w = w.publish_as(segment)?;
2038                w.set_sync_byte_threshold(DEFAULT_SYNC_BYTE_THRESHOLD);
2039                let next_txn_id = replayed
2040                    .iter()
2041                    .map(|record| record.txn_id)
2042                    .filter(|txn_id| *txn_id != crate::wal::SYSTEM_TXN_ID)
2043                    .max()
2044                    .map(|txn_id| txn_id.checked_add(1).unwrap_or(0))
2045                    .unwrap_or(1);
2046                (WalSink::Private(w), replayed, next_txn_id)
2047            }
2048        };
2049
2050        let mut memtable = Memtable::new();
2051        let mut allocator = RowIdAllocator::new(manifest.next_row_id);
2052        let persisted_epoch = manifest.current_epoch;
2053        // Seed the auto-increment counter from the manifest. `auto_inc_next == 0`
2054        // means unseeded (fresh table, or a legacy manifest migrated forward) —
2055        // the first allocation scans `max(PK)` to avoid colliding with existing
2056        // rows. WAL replay (below) and `recover_apply` additionally bump `next`
2057        // past replayed ids without marking it seeded, so the scan still covers
2058        // any rows that were already flushed to sorted runs.
2059        let mut auto_inc = resolve_auto_inc(&schema).map(|mut s| {
2060            s.next = manifest.auto_inc_next;
2061            s.seeded = manifest.auto_inc_next > 0;
2062            s
2063        });
2064
2065        // 1. Replay is two-phase and TxnCommit-gated: data records (Put/Delete)
2066        //    are staged per `txn_id` and only applied when a durable
2067        //    `TxnCommit{epoch}` for that txn is seen. Uncommitted / aborted /
2068        //    torn-tail txns are discarded. Indexing happens AFTER loading any
2069        //    checkpoint / run data (below) so the newer replayed versions
2070        //    overwrite the older run versions in the HOT index.
2071        let mut staged_puts: HashMap<u64, Vec<Row>> = HashMap::new();
2072        let mut staged_deletes: HashMap<u64, Vec<RowId>> = HashMap::new();
2073        let mut staged_truncates: std::collections::HashSet<u64> = std::collections::HashSet::new();
2074        let mut replayed_puts: std::collections::BTreeMap<Epoch, Vec<Row>> =
2075            std::collections::BTreeMap::new();
2076        let mut replayed_deletes: Vec<(RowId, Epoch)> = Vec::new();
2077        let mut recovered_epoch = manifest.current_epoch;
2078        let mut recovered_manifest_dirty = schema_manifest_repair;
2079        let mut saw_delete = false;
2080        for record in replayed {
2081            let txn_id = record.txn_id;
2082            match record.op {
2083                Op::Put { rows, .. } => {
2084                    let rows: Vec<Row> = bincode::deserialize(&rows)?;
2085                    for row in &rows {
2086                        allocator.advance_to(row.row_id)?;
2087                        if let Some(ai) = auto_inc.as_mut() {
2088                            if let Some(Value::Int64(n)) = row.columns.get(&ai.column_id) {
2089                                let next = n.checked_add(1).ok_or_else(|| {
2090                                    MongrelError::Full("AUTO_INCREMENT namespace exhausted".into())
2091                                })?;
2092                                if next > ai.next {
2093                                    ai.next = next;
2094                                }
2095                            }
2096                        }
2097                    }
2098                    staged_puts.entry(txn_id).or_default().extend(rows);
2099                }
2100                Op::Delete { row_ids, .. } => {
2101                    staged_deletes.entry(txn_id).or_default().extend(row_ids);
2102                }
2103                Op::TxnCommit { epoch, .. } => {
2104                    let commit_epoch = Epoch(epoch);
2105                    recovered_epoch = recovered_epoch.max(epoch);
2106                    if staged_truncates.remove(&txn_id) && commit_epoch.0 > manifest.flushed_epoch {
2107                        memtable = Memtable::new();
2108                        replayed_puts.clear();
2109                        replayed_deletes.clear();
2110                        manifest.runs.clear();
2111                        manifest.retiring.clear();
2112                        manifest.live_count = 0;
2113                        manifest.global_idx_epoch = 0;
2114                        manifest.current_epoch = manifest.current_epoch.max(epoch);
2115                        recovered_manifest_dirty = true;
2116                        saw_delete = true;
2117                    }
2118                    if let Some(puts) = staged_puts.remove(&txn_id) {
2119                        if commit_epoch.0 > manifest.flushed_epoch {
2120                            for row in &puts {
2121                                memtable.upsert(row.clone());
2122                            }
2123                            replayed_puts.entry(commit_epoch).or_default().extend(puts);
2124                        }
2125                    }
2126                    if let Some(dels) = staged_deletes.remove(&txn_id) {
2127                        saw_delete = true;
2128                        if commit_epoch.0 > manifest.flushed_epoch {
2129                            for rid in dels {
2130                                memtable.tombstone(rid, commit_epoch);
2131                                replayed_deletes.push((rid, commit_epoch));
2132                            }
2133                        }
2134                    }
2135                }
2136                Op::TxnAbort => {
2137                    staged_puts.remove(&txn_id);
2138                    staged_deletes.remove(&txn_id);
2139                    staged_truncates.remove(&txn_id);
2140                }
2141                Op::TruncateTable { .. } => {
2142                    staged_puts.remove(&txn_id);
2143                    staged_deletes.remove(&txn_id);
2144                    staged_truncates.insert(txn_id);
2145                }
2146                Op::ExternalTableState { .. }
2147                | Op::Flush { .. }
2148                | Op::Ddl(_)
2149                | Op::BeforeImage { .. }
2150                | Op::CommitTimestamp { .. }
2151                | Op::SpilledRows { .. } => {}
2152            }
2153        }
2154
2155        let rcache_dir = dir.join(RCACHE_DIR);
2156        let column_keys = build_column_keys(ctx.kek.as_deref(), &schema);
2157        let initial_view = ReadGeneration::empty(&schema);
2158        let mut db = Self {
2159            dir,
2160            _root_guard: ctx.root_guard,
2161            runs_root,
2162            idx_root,
2163            table_id: manifest.table_id,
2164            name: ctx.table_name.unwrap_or_default(),
2165            auth: ctx.auth,
2166            read_only: ctx.read_only,
2167            durable_commit_failed: false,
2168            wal,
2169            memtable,
2170            mutable_run: MutableRun::new(),
2171            mutable_run_spill_bytes: DEFAULT_MUTABLE_RUN_SPILL_BYTES,
2172            compaction_zstd_level: 3,
2173            allocator,
2174            epoch: ctx.epoch,
2175            data_generation: persisted_epoch,
2176            schema,
2177            hot: HotIndex::new(),
2178            kek: ctx.kek,
2179            column_keys,
2180            run_refs: manifest.runs.clone(),
2181            retiring: manifest.retiring.clone(),
2182            next_run_id,
2183            sync_byte_threshold: DEFAULT_SYNC_BYTE_THRESHOLD,
2184            current_txn_id,
2185            pending_private_mutations: false,
2186            bitmap: HashMap::new(),
2187            ann: HashMap::new(),
2188            fm: HashMap::new(),
2189            sparse: HashMap::new(),
2190            minhash: HashMap::new(),
2191            learned_range: Arc::new(HashMap::new()),
2192            pk_by_row: ReversePkMap::new(),
2193            pinned: BTreeMap::new(),
2194            live_count: manifest.live_count,
2195            reservoir: crate::reservoir::Reservoir::default(),
2196            reservoir_complete: false,
2197            had_deletes: saw_delete
2198                || manifest.runs.iter().map(|run| run.row_count).sum::<u64>()
2199                    != manifest.live_count,
2200            agg_cache: Arc::new(HashMap::new()),
2201            global_idx_epoch: manifest.global_idx_epoch,
2202            indexes_complete: true,
2203            index_build_policy: IndexBuildPolicy::default(),
2204            pk_by_row_complete: false,
2205            flushed_epoch: manifest.flushed_epoch,
2206            page_cache: ctx.page_cache,
2207            decoded_cache: ctx.decoded_cache,
2208            verified_runs: Arc::new(parking_lot::Mutex::new(std::collections::HashSet::new())),
2209            snapshots: ctx.snapshots,
2210            commit_lock: ctx.commit_lock,
2211            result_cache: Arc::new(parking_lot::Mutex::new(
2212                ResultCache::new()
2213                    .with_dir(rcache_dir)
2214                    .with_cache_dek(cache_dek.clone()),
2215            )),
2216            pending_delete_rids: roaring::RoaringBitmap::new(),
2217            pending_put_cols: std::collections::HashSet::new(),
2218            pending_rows: Vec::new(),
2219            pending_rows_auto_inc: Vec::new(),
2220            pending_dels: Vec::new(),
2221            pending_truncate: None,
2222            wal_dek,
2223            auto_inc,
2224            ttl: manifest.ttl,
2225            pins: Arc::new(crate::retention::PinRegistry::new()),
2226            published: Arc::new(ArcSwap::from_pointee(initial_view)),
2227            read_generation_pin: None,
2228        };
2229
2230        // Advance the (possibly shared) epoch authority to this table's manifest
2231        // epoch so rebuild/index reads below observe the recovered watermark.
2232        db.epoch.advance_recovered(Epoch(recovered_epoch));
2233
2234        // 2. Fast path: load the persisted global-index checkpoint (Phase 9.1).
2235        //    Valid only when its embedded epoch matches the manifest-endorsed
2236        //    `global_idx_epoch` and every run was created at or before it, so the
2237        //    checkpoint covers all run data. Otherwise rebuild from the runs.
2238        let checkpoint = match db.idx_root.as_deref() {
2239            Some(root) => {
2240                global_idx::read_root(root, db.table_id, &db.schema, db.idx_dek().as_deref())?
2241            }
2242            None => global_idx::read(&db.dir, db.table_id, &db.schema, db.idx_dek().as_deref())?,
2243        };
2244        let checkpoint_valid = checkpoint.as_ref().is_some_and(|c| {
2245            c.epoch_built == manifest.global_idx_epoch
2246                && manifest.global_idx_epoch > 0
2247                && manifest
2248                    .runs
2249                    .iter()
2250                    .all(|r| r.epoch_created <= manifest.global_idx_epoch)
2251        });
2252        if let Some(loaded) = checkpoint {
2253            if checkpoint_valid {
2254                db.hot = loaded.hot;
2255                db.bitmap = loaded.bitmap;
2256                db.ann = loaded.ann;
2257                db.fm = loaded.fm;
2258                db.sparse = loaded.sparse;
2259                db.minhash = loaded.minhash;
2260                db.learned_range = Arc::new(loaded.learned_range);
2261                // Checkpoints omit empty secondary indexes (e.g. ANN with no
2262                // vectors yet). Re-seed any schema-declared maps that were
2263                // skipped so retrievers like ANN do not fail with "has no
2264                // ANN index" after reopen.
2265                let (bitmap0, ann0, fm0, sparse0, minhash0) = empty_indexes(&db.schema);
2266                for (cid, idx) in bitmap0 {
2267                    db.bitmap.entry(cid).or_insert(idx);
2268                }
2269                for (cid, idx) in ann0 {
2270                    db.ann.entry(cid).or_insert(idx);
2271                }
2272                for (cid, idx) in fm0 {
2273                    db.fm.entry(cid).or_insert(idx);
2274                }
2275                for (cid, idx) in sparse0 {
2276                    db.sparse.entry(cid).or_insert(idx);
2277                }
2278                for (cid, idx) in minhash0 {
2279                    db.minhash.entry(cid).or_insert(idx);
2280                }
2281                // `pk_by_row` stays lazy (`pk_by_row_complete == false`): the
2282                // first delete rebuilds it from the loaded HOT.
2283            }
2284        }
2285        if !checkpoint_valid {
2286            let (bitmap, ann, fm, sparse, minhash) = empty_indexes(&db.schema);
2287            db.bitmap = bitmap;
2288            db.ann = ann;
2289            db.fm = fm;
2290            db.sparse = sparse;
2291            db.minhash = minhash;
2292            db.rebuild_indexes_from_runs()?;
2293            db.build_learned_ranges()?;
2294        }
2295
2296        // 3. Index the replayed WAL rows on top so updates overwrite. Within a
2297        //    single transaction epoch duplicate PKs are upserted: only the last
2298        //    winner is indexed, losers are tombstoned in the already-replayed
2299        //    memtable.
2300        for (epoch, group) in replayed_puts {
2301            let (losers, winner_pks) = db.partition_pk_winners(&group);
2302            for (key, &row_id) in &winner_pks {
2303                if let Some(old_rid) = db.hot.get(key) {
2304                    if old_rid != row_id {
2305                        db.tombstone_row(old_rid, epoch, None, false);
2306                    }
2307                }
2308            }
2309            for &loser_rid in &losers {
2310                db.tombstone_row(loser_rid, epoch, None, false);
2311            }
2312            for (key, row_id) in winner_pks {
2313                db.insert_hot_pk(key, row_id);
2314            }
2315            if db.schema.primary_key().is_none() {
2316                for r in &group {
2317                    db.hot.insert(r.row_id.0.to_be_bytes().to_vec(), r.row_id);
2318                }
2319            }
2320            for r in &group {
2321                if !losers.contains(&r.row_id) {
2322                    db.index_row(r);
2323                }
2324            }
2325        }
2326        // Apply replayed deletes after the puts: a delete targets a specific row
2327        // id and only removes the HOT entry if it still points to that id, so a
2328        // newer upsert for the same PK is not accidentally erased.
2329        for (rid, epoch) in &replayed_deletes {
2330            db.remove_hot_for_row(*rid, *epoch);
2331        }
2332
2333        if recovered_manifest_dirty {
2334            let rows = db.visible_rows(Snapshot::unbounded())?;
2335            db.live_count = rows.len() as u64;
2336            db.persist_manifest(Epoch(recovered_epoch))?;
2337        }
2338
2339        // The reservoir stays lazy (`reservoir_complete == false`, set above):
2340        // rebuilding it means materializing every visible row, which no plain
2341        // open/insert/update/delete needs. `ensure_reservoir_complete` pays
2342        // that cost on the first `approx_aggregate` call instead.
2343        // Load the persistent result-cache tier (hardening (b)) so fine-grained
2344        // invalidation resumes across restart.
2345        db.result_cache.lock().load_persistent();
2346        Ok(db)
2347    }
2348
2349    /// Rebuild `reservoir` from every visible row if it isn't already
2350    /// complete (lazy — same pattern as [`Self::ensure_indexes_complete`]).
2351    /// Open and WAL replay leave the reservoir stale rather than eagerly
2352    /// paying a full-table scan; this pays it once, on the first
2353    /// [`Self::approx_aggregate`] call.
2354    fn ensure_reservoir_complete(&mut self) -> Result<()> {
2355        if self.reservoir_complete {
2356            return Ok(());
2357        }
2358        self.rebuild_reservoir()?;
2359        self.reservoir_complete = true;
2360        Ok(())
2361    }
2362
2363    /// Repopulate the reservoir sample from all visible rows (used on open so a
2364    /// reopened table has an analytics sample without further inserts).
2365    fn rebuild_reservoir(&mut self) -> Result<()> {
2366        let snap = self.snapshot();
2367        let rows = self.visible_rows(snap)?;
2368        self.reservoir.reset();
2369        for r in rows {
2370            self.reservoir.offer(r.row_id.0);
2371        }
2372        Ok(())
2373    }
2374
2375    pub(crate) fn rebuild_indexes_from_runs(&mut self) -> Result<()> {
2376        self.rebuild_indexes_from_runs_inner(None)
2377    }
2378
2379    fn rebuild_indexes_from_runs_inner(
2380        &mut self,
2381        control: Option<&crate::ExecutionControl>,
2382    ) -> Result<()> {
2383        // S1C-004: online index rebuild pins the current visible epoch so
2384        // version GC cannot reclaim rows while the rebuild scans them.
2385        let _index_build_pin = Arc::clone(self.pin_registry()).pin(
2386            crate::retention::PinSource::OnlineIndexBuild,
2387            self.current_epoch(),
2388        );
2389        self.hot = HotIndex::new();
2390        self.pk_by_row.clear();
2391        let (bitmap, ann, fm, sparse, minhash) = empty_indexes(&self.schema);
2392        self.bitmap = bitmap;
2393        self.ann = ann;
2394        self.fm = fm;
2395        self.sparse = sparse;
2396        self.minhash = minhash;
2397        let snapshot = Epoch(u64::MAX);
2398        let ttl_now = unix_nanos_now();
2399        let mut scanned = 0_usize;
2400        for rr in self.run_refs.clone() {
2401            if let Some(control) = control {
2402                control.checkpoint()?;
2403            }
2404            let mut reader = self.open_reader(rr.run_id)?;
2405            for row in reader.visible_rows(snapshot)? {
2406                if scanned.is_multiple_of(256) {
2407                    if let Some(control) = control {
2408                        control.checkpoint()?;
2409                    }
2410                }
2411                scanned += 1;
2412                if self.row_expired_at(&row, ttl_now) {
2413                    continue;
2414                }
2415                let tok_row = self.tokenized_for_indexes(&row);
2416                index_into(
2417                    &self.schema,
2418                    &tok_row,
2419                    &mut self.hot,
2420                    &mut self.bitmap,
2421                    &mut self.ann,
2422                    &mut self.fm,
2423                    &mut self.sparse,
2424                    &mut self.minhash,
2425                );
2426            }
2427        }
2428        for row in self.mutable_run.visible_versions(snapshot) {
2429            if scanned.is_multiple_of(256) {
2430                if let Some(control) = control {
2431                    control.checkpoint()?;
2432                }
2433            }
2434            scanned += 1;
2435            if row.deleted {
2436                self.remove_hot_for_row(row.row_id, snapshot);
2437            } else if !self.row_expired_at(&row, ttl_now) {
2438                self.index_row(&row);
2439            }
2440        }
2441        for row in self.memtable.visible_versions(snapshot) {
2442            if scanned.is_multiple_of(256) {
2443                if let Some(control) = control {
2444                    control.checkpoint()?;
2445                }
2446            }
2447            scanned += 1;
2448            if row.deleted {
2449                self.remove_hot_for_row(row.row_id, snapshot);
2450            } else if !self.row_expired_at(&row, ttl_now) {
2451                self.index_row(&row);
2452            }
2453        }
2454        self.refresh_pk_by_row_from_hot();
2455        Ok(())
2456    }
2457
2458    fn refresh_pk_by_row_from_hot(&mut self) {
2459        self.pk_by_row_complete = true;
2460        if self.schema.primary_key().is_none() {
2461            self.pk_by_row.clear();
2462            return;
2463        }
2464        // `.collect()` drives `HashMap`'s bulk-build `FromIterator` (reserves
2465        // once from the exact-size iterator), instead of growing-and-rehashing
2466        // through a one-at-a-time `insert()` loop — same fix as
2467        // `HotIndex::from_entries`, same hot path (first delete after a put
2468        // streak rebuilds this from the full HOT index).
2469        self.pk_by_row = ReversePkMap::from_entries(
2470            self.hot
2471                .entries()
2472                .into_iter()
2473                .map(|(key, row_id)| (row_id, key)),
2474        );
2475    }
2476
2477    fn insert_hot_pk(&mut self, key: Vec<u8>, row_id: RowId) {
2478        if self.schema.primary_key().is_some() {
2479            self.pk_by_row.insert(row_id, key.clone());
2480        }
2481        self.hot.insert(key, row_id);
2482    }
2483
2484    /// (Re)build per-column learned (PGM) range indexes for `LearnedRange`
2485    /// columns from the single sorted run. Serves `Condition::Range` sub-linearly
2486    /// on the fast path; no-op when there isn't exactly one run.
2487    pub(crate) fn build_learned_ranges(&mut self) -> Result<()> {
2488        self.build_learned_ranges_inner(None)
2489    }
2490
2491    fn build_learned_ranges_inner(
2492        &mut self,
2493        control: Option<&crate::ExecutionControl>,
2494    ) -> Result<()> {
2495        self.learned_range = Arc::new(HashMap::new());
2496        if self.run_refs.len() != 1 {
2497            return Ok(());
2498        }
2499        let cols: Vec<(u16, usize)> = self
2500            .schema
2501            .indexes
2502            .iter()
2503            .filter(|i| i.kind == IndexKind::LearnedRange)
2504            .map(|i| {
2505                (
2506                    i.column_id,
2507                    i.options
2508                        .learned_range
2509                        .as_ref()
2510                        .map(|options| options.epsilon)
2511                        .unwrap_or(16),
2512                )
2513            })
2514            .collect();
2515        if cols.is_empty() {
2516            return Ok(());
2517        }
2518        let mut reader = self.open_reader(self.run_refs[0].run_id)?;
2519        let row_ids: Vec<u64> = match reader.column_native(crate::sorted_run::SYS_ROW_ID)? {
2520            columnar::NativeColumn::Int64 { data, .. } => data.iter().map(|x| *x as u64).collect(),
2521            _ => return Ok(()),
2522        };
2523        for (column_index, (cid, epsilon)) in cols.into_iter().enumerate() {
2524            if column_index % 256 == 0 {
2525                if let Some(control) = control {
2526                    control.checkpoint()?;
2527                }
2528            }
2529            let ty = self
2530                .schema
2531                .columns
2532                .iter()
2533                .find(|c| c.id == cid)
2534                .map(|c| c.ty.clone())
2535                .unwrap_or(TypeId::Int64);
2536            match ty {
2537                TypeId::Int64 | TypeId::TimestampNanos | TypeId::Date32 => {
2538                    if let columnar::NativeColumn::Int64 { data, .. } = reader.column_native(cid)? {
2539                        let pairs: Vec<(i64, u64)> = data
2540                            .iter()
2541                            .zip(row_ids.iter())
2542                            .map(|(v, r)| (*v, *r))
2543                            .collect();
2544                        Arc::make_mut(&mut self.learned_range).insert(
2545                            cid,
2546                            ColumnLearnedRange::build_i64_with_epsilon(&pairs, epsilon),
2547                        );
2548                    }
2549                }
2550                TypeId::Float64 => {
2551                    if let columnar::NativeColumn::Float64 { data, .. } =
2552                        reader.column_native(cid)?
2553                    {
2554                        let pairs: Vec<(f64, u64)> = data
2555                            .iter()
2556                            .zip(row_ids.iter())
2557                            .map(|(v, r)| (*v, *r))
2558                            .collect();
2559                        Arc::make_mut(&mut self.learned_range).insert(
2560                            cid,
2561                            ColumnLearnedRange::build_f64_with_epsilon(&pairs, epsilon),
2562                        );
2563                    }
2564                }
2565                _ => {}
2566            }
2567        }
2568        Ok(())
2569    }
2570
2571    /// Phase 14.7: if the live indexes are known incomplete (after a bulk
2572    /// ingest that deferred index building — see [`IndexBuildPolicy`]),
2573    /// rebuild them from the runs now. Called lazily by `query` /
2574    /// `query_columns_native` / `flush`; public so external index consumers
2575    /// (SQL scans, joins, PK point lookups on a shared handle) can pay the
2576    /// one-time build before reading a `&self` index view.
2577    pub fn ensure_indexes_complete(&mut self) -> Result<()> {
2578        if self.indexes_complete {
2579            crate::trace::QueryTrace::record(|t| {
2580                t.index_rebuild = crate::trace::IndexRebuild::AlreadyComplete;
2581            });
2582            return Ok(());
2583        }
2584        crate::trace::QueryTrace::record(|t| {
2585            t.index_rebuild = crate::trace::IndexRebuild::Rebuilt;
2586        });
2587        self.rebuild_indexes_from_runs()?;
2588        self.build_learned_ranges()?;
2589        self.indexes_complete = true;
2590        let epoch = self.current_epoch();
2591        self.checkpoint_indexes(epoch);
2592        Ok(())
2593    }
2594
2595    /// Rebuild derived indexes cooperatively, publishing their checkpoint only
2596    /// after `before_publish` succeeds.
2597    #[doc(hidden)]
2598    pub fn ensure_indexes_complete_controlled<F>(
2599        &mut self,
2600        control: &crate::ExecutionControl,
2601        before_publish: F,
2602    ) -> Result<bool>
2603    where
2604        F: FnOnce() -> bool,
2605    {
2606        self.ensure_indexes_complete_controlled_with_receipt(control, before_publish)
2607            .map(|(changed, _)| changed)
2608    }
2609
2610    /// Rebuild derived indexes cooperatively and return the exact table
2611    /// snapshot used by the rebuild. No receipt is returned for a no-op.
2612    #[doc(hidden)]
2613    pub fn ensure_indexes_complete_controlled_with_receipt<F>(
2614        &mut self,
2615        control: &crate::ExecutionControl,
2616        before_publish: F,
2617    ) -> Result<(bool, Option<MaintenanceReceipt>)>
2618    where
2619        F: FnOnce() -> bool,
2620    {
2621        if self.indexes_complete {
2622            crate::trace::QueryTrace::record(|trace| {
2623                trace.index_rebuild = crate::trace::IndexRebuild::AlreadyComplete;
2624            });
2625            return Ok((false, None));
2626        }
2627        crate::trace::QueryTrace::record(|trace| {
2628            trace.index_rebuild = crate::trace::IndexRebuild::Rebuilt;
2629        });
2630        control.checkpoint()?;
2631        let maintenance_epoch = self.current_epoch();
2632        self.rebuild_indexes_from_runs_inner(Some(control))?;
2633        self.build_learned_ranges_inner(Some(control))?;
2634        control.checkpoint()?;
2635        if !before_publish() {
2636            return Err(MongrelError::Cancelled);
2637        }
2638        self.indexes_complete = true;
2639        self.checkpoint_indexes(maintenance_epoch);
2640        Ok((
2641            true,
2642            Some(MaintenanceReceipt {
2643                epoch: maintenance_epoch,
2644            }),
2645        ))
2646    }
2647
2648    fn pending_epoch(&self) -> Epoch {
2649        Epoch(self.epoch.visible().0 + 1)
2650    }
2651
2652    /// True when this table is mounted in a `Database` (writes route through the
2653    /// shared WAL).
2654    fn is_shared(&self) -> bool {
2655        matches!(self.wal, WalSink::Shared(_))
2656    }
2657
2658    /// Return the current auto-commit txn id, allocating a fresh one from the
2659    /// shared allocator on a mounted table when a new span starts (sentinel 0).
2660    /// A standalone table uses its private monotonic counter (never 0).
2661    fn ensure_txn_id(&mut self) -> Result<u64> {
2662        if self.current_txn_id == 0 {
2663            let id = match &self.wal {
2664                WalSink::Shared(s) => crate::txn::allocate_txn_id(&s.txn_ids)?,
2665                WalSink::Private(_) => {
2666                    return Err(MongrelError::Full(
2667                        "standalone transaction id namespace exhausted".into(),
2668                    ))
2669                }
2670                WalSink::ReadOnly => return Err(MongrelError::ReadOnlyReplica),
2671            };
2672            self.current_txn_id = id;
2673        }
2674        Ok(self.current_txn_id)
2675    }
2676
2677    /// Append a data record (`Put`/`Delete`) for the current auto-commit txn to
2678    /// whichever WAL backs this table.
2679    fn wal_append_data(&mut self, op: Op) -> Result<()> {
2680        self.ensure_writable()?;
2681        let txn_id = self.ensure_txn_id()?;
2682        let table_id = self.table_id;
2683        match &mut self.wal {
2684            WalSink::Private(w) => {
2685                w.append_txn(txn_id, op)?;
2686                self.pending_private_mutations = true;
2687            }
2688            WalSink::Shared(s) => {
2689                s.wal.lock().append(txn_id, table_id, op)?;
2690            }
2691            WalSink::ReadOnly => return Err(MongrelError::ReadOnlyReplica),
2692        }
2693        Ok(())
2694    }
2695
2696    fn ensure_writable(&self) -> Result<()> {
2697        if self.read_only || matches!(self.wal, WalSink::ReadOnly) {
2698            return Err(MongrelError::ReadOnlyReplica);
2699        }
2700        if self.durable_commit_failed {
2701            return Err(MongrelError::Other(
2702                "table poisoned by post-commit failure; reopen required".into(),
2703            ));
2704        }
2705        Ok(())
2706    }
2707
2708    /// Upsert a row. Allocates a [`RowId`], appends a (non-fsynced) WAL record,
2709    /// and updates the memtable + indexes. Returns the new row id. Durability
2710    /// arrives at the next [`Table::commit`] (or [`Table::flush`]).
2711    ///
2712    /// For an `AUTO_INCREMENT` primary key, omit the column (or pass
2713    /// Auth enforcement helpers. Each delegates to the optional
2714    /// [`TableAuthChecker`] (set at mount time from the `Database`'s auth
2715    /// state). On a credentialless database (`auth = None`), these are
2716    /// no-ops. The `name` field provides the table name for the permission
2717    /// check without needing a reference back to `Database`.
2718    fn require(&self, perm: crate::auth_state::RequiredPermission) -> Result<()> {
2719        match &self.auth {
2720            Some(checker) => checker.check(&self.name, perm),
2721            None => Ok(()),
2722        }
2723    }
2724    /// Check `Select` permission on this table. Public so that read entry
2725    /// points that don't go through `Table::query` (e.g. `MongrelProvider::scan`,
2726    /// `Table::count`) can enforce before reading. On a credentialless database
2727    /// this is a no-op.
2728    pub fn require_select(&self) -> Result<()> {
2729        self.require(crate::auth_state::RequiredPermission::Select)
2730    }
2731    fn require_insert(&self) -> Result<()> {
2732        self.require(crate::auth_state::RequiredPermission::Insert)
2733    }
2734    /// Currently unused on `Table` directly (updates go through `Transaction`),
2735    /// but kept for API completeness — the four `require_*` helpers mirror the
2736    /// four table-level permission kinds.
2737    #[allow(dead_code)]
2738    fn require_update(&self) -> Result<()> {
2739        self.require(crate::auth_state::RequiredPermission::Update)
2740    }
2741    fn require_delete(&self) -> Result<()> {
2742        self.require(crate::auth_state::RequiredPermission::Delete)
2743    }
2744
2745    /// [`Value::Null`]) and the engine assigns the next counter value; use
2746    /// [`Table::put_returning`] to learn that assigned value.
2747    pub fn put(&mut self, columns: Vec<(u16, Value)>) -> Result<RowId> {
2748        self.require_insert()?;
2749        Ok(self.put_returning(columns)?.0)
2750    }
2751
2752    /// Like [`Table::put`] but also returns the engine-assigned `AUTO_INCREMENT`
2753    /// value (`Some` only when the column was omitted/null and the engine filled
2754    /// it; `None` when the table has no auto-increment column or the caller
2755    /// supplied an explicit value).
2756    pub fn put_returning(
2757        &mut self,
2758        mut columns: Vec<(u16, Value)>,
2759    ) -> Result<(RowId, Option<i64>)> {
2760        self.require_insert()?;
2761        let assigned = self.fill_auto_inc(&mut columns)?;
2762        self.apply_defaults(&mut columns)?;
2763        self.schema.validate_values(&columns)?;
2764        // For clustered (WITHOUT ROWID) tables, derive RowId deterministically
2765        // from the PK value so the same PK always maps to the same row (no
2766        // allocator waste, idempotent upserts). For standard tables, use the
2767        // monotonic allocator.
2768        let row_id = if self.schema.clustered {
2769            self.derive_clustered_row_id(&columns)?
2770        } else {
2771            self.allocator.alloc()?
2772        };
2773        let epoch = self.pending_epoch();
2774        let mut row = Row::new(row_id, epoch);
2775        for (col_id, val) in columns {
2776            row.columns.insert(col_id, val);
2777        }
2778        self.commit_rows(vec![row], assigned.is_some())?;
2779        Ok((row_id, assigned))
2780    }
2781
2782    /// Bulk upsert: many rows under a single WAL record + one index pass. Far
2783    /// cheaper than `put` in a loop for batch ingest.
2784    pub fn put_batch(&mut self, batch: Vec<Vec<(u16, Value)>>) -> Result<Vec<RowId>> {
2785        self.require_insert()?;
2786        Ok(self
2787            .put_batch_returning(batch)?
2788            .into_iter()
2789            .map(|(r, _)| r)
2790            .collect())
2791    }
2792
2793    /// Like [`Table::put_batch`] but each entry is paired with the engine-
2794    /// assigned `AUTO_INCREMENT` value (`Some` only when filled by the engine).
2795    pub fn put_batch_returning(
2796        &mut self,
2797        batch: Vec<Vec<(u16, Value)>>,
2798    ) -> Result<Vec<(RowId, Option<i64>)>> {
2799        let mut filled: Vec<FilledAutoIncRow> = Vec::with_capacity(batch.len());
2800        for mut cols in batch {
2801            let assigned = self.fill_auto_inc(&mut cols)?;
2802            self.apply_defaults(&mut cols)?;
2803            filled.push((cols, assigned));
2804        }
2805        for (cols, _) in &filled {
2806            self.schema.validate_values(cols)?;
2807        }
2808        let epoch = self.pending_epoch();
2809        let mut rows = Vec::with_capacity(filled.len());
2810        let mut ids = Vec::with_capacity(filled.len());
2811        let first_row_id = if self.schema.clustered {
2812            None
2813        } else {
2814            let count = u64::try_from(filled.len())
2815                .map_err(|_| MongrelError::Full("row-id allocation request is too large".into()))?;
2816            Some(self.allocator.alloc_range(count)?.0)
2817        };
2818        for (row_index, (cols, assigned)) in filled.into_iter().enumerate() {
2819            let row_id = match first_row_id {
2820                Some(first) => RowId(first + row_index as u64),
2821                None => self.derive_clustered_row_id(&cols)?,
2822            };
2823            let mut row = Row::new(row_id, epoch);
2824            for (c, v) in cols {
2825                row.columns.insert(c, v);
2826            }
2827            ids.push((row_id, assigned));
2828            rows.push(row);
2829        }
2830        let all_auto_generated = ids.iter().all(|(_, assigned)| assigned.is_some());
2831        self.commit_rows(rows, all_auto_generated)?;
2832        Ok(ids)
2833    }
2834
2835    /// Fill the `AUTO_INCREMENT` column for an upcoming row. When the column is
2836    /// omitted or [`Value::Null`] the next counter value is allocated and the
2837    /// cell is appended/replaced in `columns`; an explicit `Int64` is honored
2838    /// and advances the counter past it. Returns `Some(value)` when the engine
2839    /// allocated (so the caller can surface it), `None` otherwise.
2840    pub fn fill_auto_inc(&mut self, columns: &mut Vec<(u16, Value)>) -> Result<Option<i64>> {
2841        self.ensure_writable()?;
2842        let Some(cid) = self.auto_inc.as_ref().map(|a| a.column_id) else {
2843            return Ok(None);
2844        };
2845        let pos = columns.iter().position(|(c, _)| *c == cid);
2846        let assigned = match pos {
2847            Some(i) => match &columns[i].1 {
2848                Value::Null => {
2849                    let next = self.alloc_auto_inc_value()?;
2850                    columns[i].1 = Value::Int64(next);
2851                    Some(next)
2852                }
2853                Value::Int64(n) => {
2854                    self.advance_auto_inc_past(*n)?;
2855                    None
2856                }
2857                other => {
2858                    return Err(MongrelError::InvalidArgument(format!(
2859                        "AUTO_INCREMENT column {cid} must be Int64 or NULL, got {:?}",
2860                        other
2861                    )))
2862                }
2863            },
2864            None => {
2865                let next = self.alloc_auto_inc_value()?;
2866                columns.push((cid, Value::Int64(next)));
2867                Some(next)
2868            }
2869        };
2870        Ok(assigned)
2871    }
2872
2873    /// Apply column default expressions to `columns` at stage time (before
2874    /// NOT NULL validation). For each column carrying a `default_value`, if the
2875    /// column is omitted or explicitly `Null`, the default is applied. Explicit
2876    /// values are never overridden. Called after [`fill_auto_inc`](Self::fill_auto_inc)
2877    /// and before `validate_not_null`.
2878    pub fn apply_defaults(&self, columns: &mut Vec<(u16, Value)>) -> Result<()> {
2879        for col in &self.schema.columns {
2880            let Some(expr) = &col.default_value else {
2881                continue;
2882            };
2883            // Skip AUTO_INCREMENT columns — handled by fill_auto_inc.
2884            if col.flags.contains(ColumnFlags::AUTO_INCREMENT) {
2885                continue;
2886            }
2887            let pos = columns.iter().position(|(c, _)| *c == col.id);
2888            let needs_default = match pos {
2889                None => true,
2890                Some(i) => matches!(columns[i].1, Value::Null),
2891            };
2892            if !needs_default {
2893                continue;
2894            }
2895            let v = match expr {
2896                crate::schema::DefaultExpr::Static(v) => v.clone(),
2897                crate::schema::DefaultExpr::Now => Value::Bytes(iso_now_bytes()),
2898                crate::schema::DefaultExpr::Uuid => {
2899                    let mut buf = [0u8; 16];
2900                    getrandom::getrandom(&mut buf)
2901                        .map_err(|e| MongrelError::Other(format!("UUID generation failed: {e}")))?;
2902                    Value::Uuid(buf)
2903                }
2904            };
2905            match pos {
2906                None => columns.push((col.id, v)),
2907                Some(i) => columns[i].1 = v,
2908            }
2909        }
2910        Ok(())
2911    }
2912
2913    /// Allocate the next identity value, seeding the counter first if needed.
2914    fn alloc_auto_inc_value(&mut self) -> Result<i64> {
2915        self.ensure_auto_inc_seeded()?;
2916        // Borrow checker: re-read after the mutable `ensure` call returns.
2917        let ai = self.auto_inc.as_mut().expect("auto-inc column present");
2918        let v = ai.next;
2919        ai.next = ai
2920            .next
2921            .checked_add(1)
2922            .ok_or_else(|| MongrelError::Full("AUTO_INCREMENT namespace exhausted".into()))?;
2923        Ok(v)
2924    }
2925
2926    /// Advance the counter past an explicit id, seeding first if needed so a
2927    /// pre-existing higher id elsewhere is never ignored.
2928    fn advance_auto_inc_past(&mut self, used: i64) -> Result<()> {
2929        self.ensure_auto_inc_seeded()?;
2930        let ai = self.auto_inc.as_mut().expect("auto-inc column present");
2931        let floor = used
2932            .checked_add(1)
2933            .ok_or_else(|| MongrelError::Full("AUTO_INCREMENT namespace exhausted".into()))?
2934            .max(1);
2935        if ai.next < floor {
2936            ai.next = floor;
2937        }
2938        Ok(())
2939    }
2940
2941    /// Seed the counter on first use by scanning `max(PK)` over all visible
2942    /// rows, so an upgraded table (legacy client-assigned ids, or a manifest
2943    /// migrated from `auto_inc_next == 0`) never hands out a colliding id.
2944    /// Idempotent: a no-op once seeded.
2945    fn ensure_auto_inc_seeded(&mut self) -> Result<()> {
2946        let needs_seed = match self.auto_inc {
2947            Some(ai) => !ai.seeded,
2948            None => return Ok(()),
2949        };
2950        if !needs_seed {
2951            return Ok(());
2952        }
2953        if self.seed_empty_auto_inc() {
2954            return Ok(());
2955        }
2956        let cid = self
2957            .auto_inc
2958            .as_ref()
2959            .expect("auto-inc column present")
2960            .column_id;
2961        let max = self.scan_max_int64(cid)?;
2962        let ai = self.auto_inc.as_mut().expect("auto-inc column present");
2963        let floor = max
2964            .checked_add(1)
2965            .ok_or_else(|| MongrelError::Full("AUTO_INCREMENT namespace exhausted".into()))?
2966            .max(1);
2967        if ai.next < floor {
2968            ai.next = floor;
2969        }
2970        ai.seeded = true;
2971        Ok(())
2972    }
2973
2974    fn alloc_auto_inc_range(&mut self, n: usize) -> Result<Option<i64>> {
2975        if n == 0 || self.auto_inc.is_none() {
2976            return Ok(None);
2977        }
2978        self.ensure_auto_inc_seeded()?;
2979        let ai = self.auto_inc.as_mut().expect("auto-inc column present");
2980        let start = ai.next;
2981        let count = i64::try_from(n)
2982            .map_err(|_| MongrelError::Full("AUTO_INCREMENT range is too large".into()))?;
2983        ai.next = ai
2984            .next
2985            .checked_add(count)
2986            .ok_or_else(|| MongrelError::Full("AUTO_INCREMENT namespace exhausted".into()))?;
2987        Ok(Some(start))
2988    }
2989
2990    /// One-time `max(Int64 column)` over all MVCC-visible rows. Used to seed the
2991    /// auto-increment counter. Runs at most once per table (the manifest then
2992    /// checkpoints the seeded counter).
2993    fn scan_max_int64(&mut self, column_id: u16) -> Result<i64> {
2994        let mut max: i64 = 0;
2995        for r in self.memtable.visible_versions_at(Snapshot::unbounded()) {
2996            if let Some(Value::Int64(n)) = r.columns.get(&column_id) {
2997                if *n > max {
2998                    max = *n;
2999                }
3000            }
3001        }
3002        for r in self.mutable_run.visible_versions(Epoch(u64::MAX)) {
3003            if let Some(Value::Int64(n)) = r.columns.get(&column_id) {
3004                if *n > max {
3005                    max = *n;
3006                }
3007            }
3008        }
3009        for rr in self.run_refs.clone() {
3010            let reader = self.open_reader(rr.run_id)?;
3011            if let Some(stats) = reader.column_page_stats(column_id) {
3012                for s in stats {
3013                    if let Some(n) = crate::sorted_run::be_i64(s.max.as_deref()) {
3014                        if n > max {
3015                            max = n;
3016                        }
3017                    }
3018                }
3019            } else if reader.has_column(column_id) {
3020                if let columnar::NativeColumn::Int64 { data, validity } =
3021                    reader.column_native_shared(column_id)?
3022                {
3023                    for (i, n) in data.iter().enumerate() {
3024                        if (validity.is_empty() || columnar::validity_bit(&validity, i)) && *n > max
3025                        {
3026                            max = *n;
3027                        }
3028                    }
3029                }
3030            }
3031        }
3032        Ok(max)
3033    }
3034
3035    fn seed_empty_auto_inc(&mut self) -> bool {
3036        let Some(ai) = self.auto_inc.as_mut() else {
3037            return false;
3038        };
3039        if ai.seeded || self.live_count != 0 {
3040            return false;
3041        }
3042        if ai.next < 1 {
3043            ai.next = 1;
3044        }
3045        ai.seeded = true;
3046        true
3047    }
3048
3049    fn advance_auto_inc_from_native_columns(
3050        &mut self,
3051        columns: &[(u16, columnar::NativeColumn)],
3052        n: usize,
3053        live_before: u64,
3054    ) -> Result<()> {
3055        let Some(ai) = self.auto_inc.as_mut() else {
3056            return Ok(());
3057        };
3058        let Some((_, col)) = columns.iter().find(|(cid, _)| *cid == ai.column_id) else {
3059            return Ok(());
3060        };
3061        let columnar::NativeColumn::Int64 { data, validity } = col else {
3062            return Err(MongrelError::InvalidArgument(format!(
3063                "AUTO_INCREMENT column {} must be Int64",
3064                ai.column_id
3065            )));
3066        };
3067        let max = if native_int64_strictly_increasing(col, n) {
3068            data.get(n.saturating_sub(1)).copied()
3069        } else {
3070            data.iter()
3071                .take(n)
3072                .enumerate()
3073                .filter_map(|(i, v)| {
3074                    if validity.is_empty() || columnar::validity_bit(validity, i) {
3075                        Some(*v)
3076                    } else {
3077                        None
3078                    }
3079                })
3080                .max()
3081        };
3082        if let Some(max) = max {
3083            let floor = max
3084                .checked_add(1)
3085                .ok_or_else(|| MongrelError::Full("AUTO_INCREMENT namespace exhausted".into()))?
3086                .max(1);
3087            if ai.next < floor {
3088                ai.next = floor;
3089            }
3090            if ai.seeded || live_before == 0 {
3091                ai.seeded = true;
3092            }
3093        }
3094        Ok(())
3095    }
3096
3097    fn fill_auto_inc_native_columns(
3098        &mut self,
3099        columns: &mut Vec<(u16, columnar::NativeColumn)>,
3100        n: usize,
3101    ) -> Result<()> {
3102        let Some(cid) = self.auto_inc.as_ref().map(|a| a.column_id) else {
3103            return Ok(());
3104        };
3105        let Some(pos) = columns.iter().position(|(id, _)| *id == cid) else {
3106            if let Some(start) = self.alloc_auto_inc_range(n)? {
3107                columns.push((
3108                    cid,
3109                    columnar::NativeColumn::Int64 {
3110                        data: (start..start.saturating_add(n as i64)).collect(),
3111                        validity: vec![0xFF; n.div_ceil(8)],
3112                    },
3113                ));
3114            }
3115            return Ok(());
3116        };
3117
3118        let columnar::NativeColumn::Int64 { data, validity } = &mut columns[pos].1 else {
3119            return Err(MongrelError::InvalidArgument(format!(
3120                "AUTO_INCREMENT column {cid} must be Int64"
3121            )));
3122        };
3123        if data.len() < n {
3124            return Err(MongrelError::InvalidArgument(format!(
3125                "AUTO_INCREMENT column {cid} has {} rows, expected {n}",
3126                data.len()
3127            )));
3128        }
3129        if columnar::all_non_null(validity, n) {
3130            return Ok(());
3131        }
3132        if validity.iter().all(|b| *b == 0) {
3133            if let Some(start) = self.alloc_auto_inc_range(n)? {
3134                for (i, slot) in data.iter_mut().take(n).enumerate() {
3135                    *slot = start.saturating_add(i as i64);
3136                }
3137                *validity = vec![0xFF; n.div_ceil(8)];
3138            }
3139            return Ok(());
3140        }
3141
3142        let new_validity = vec![0xFF; data.len().div_ceil(8)];
3143        for (i, slot) in data.iter_mut().enumerate().take(n) {
3144            if columnar::validity_bit(validity, i) {
3145                self.advance_auto_inc_past(*slot)?;
3146            } else {
3147                *slot = self.alloc_auto_inc_value()?;
3148            }
3149        }
3150        *validity = new_validity;
3151        Ok(())
3152    }
3153
3154    /// Reserve (but do not insert) the next `AUTO_INCREMENT` value, advancing
3155    /// the in-memory counter. Returns `None` when the table has no
3156    /// auto-increment column.
3157    ///
3158    /// This is the escape hatch for callers that stage the row with an explicit
3159    /// id inside a cross-table [`crate::Transaction`] — where the engine cannot
3160    /// fill the column on the `put` path (the row id + cells are only assembled
3161    /// at commit). Unlike the old Kit `__kit_sequences` sequence row, the
3162    /// reservation is a pure in-memory counter bump: no hot row, no second
3163    /// commit. It becomes durable when a row carrying the reserved id commits
3164    /// (the counter is checkpointed to the manifest in the same commit); an
3165    /// aborted reservation simply leaves a gap, which the never-reuse rule
3166    /// permits.
3167    pub fn reserve_auto_inc(&mut self) -> Result<Option<i64>> {
3168        self.ensure_writable()?;
3169        if self.auto_inc.is_none() {
3170            return Ok(None);
3171        }
3172        Ok(Some(self.alloc_auto_inc_value()?))
3173    }
3174
3175    /// Append `rows` under one WAL record. On a standalone table they are folded
3176    /// into the memtable + indexes immediately (single clock — no speculative-
3177    /// epoch hazard). On a mounted table (B1/B2) they are staged in
3178    /// `pending_rows` and applied at the real assigned epoch in `commit`, so a
3179    /// concurrent reader can never see them before their commit epoch.
3180    fn commit_rows(&mut self, rows: Vec<Row>, auto_inc_generated: bool) -> Result<()> {
3181        let payload = bincode::serialize(&rows)?;
3182        self.wal_append_data(Op::Put {
3183            table_id: self.table_id,
3184            rows: payload,
3185        })?;
3186        if self.is_shared() {
3187            self.pending_rows_auto_inc
3188                .extend(std::iter::repeat_n(auto_inc_generated, rows.len()));
3189            self.pending_rows.extend(rows);
3190        } else {
3191            self.apply_put_rows_inner(rows, !auto_inc_generated)?;
3192        }
3193        Ok(())
3194    }
3195
3196    /// Complete every fallible read/index preparation before a WAL commit can
3197    /// become durable. After this succeeds, row application is in-memory only.
3198    pub(crate) fn prepare_durable_publish(&mut self) -> Result<()> {
3199        self.ensure_indexes_complete()
3200    }
3201
3202    pub(crate) fn prepare_durable_publish_controlled(
3203        &mut self,
3204        control: &crate::ExecutionControl,
3205    ) -> Result<()> {
3206        self.ensure_indexes_complete_controlled(control, || true)?;
3207        Ok(())
3208    }
3209
3210    pub(crate) fn apply_put_rows_prepared(&mut self, rows: Vec<Row>) {
3211        self.apply_put_rows_inner_prepared(rows, true);
3212    }
3213
3214    fn apply_put_rows_inner(&mut self, rows: Vec<Row>, check_existing_pk: bool) -> Result<()> {
3215        if check_existing_pk {
3216            self.ensure_indexes_complete()?;
3217        }
3218        self.apply_put_rows_inner_prepared(rows, check_existing_pk);
3219        Ok(())
3220    }
3221
3222    /// Apply rows after [`Self::ensure_indexes_complete`] has succeeded. Every
3223    /// operation below is in-memory and infallible, so durable publication can
3224    /// never stop halfway through a batch on an I/O error.
3225    fn apply_put_rows_inner_prepared(&mut self, rows: Vec<Row>, check_existing_pk: bool) {
3226        // Single-row puts — the hot operational path — cannot contain an
3227        // intra-batch duplicate, so the winner/loser partition maps are pure
3228        // overhead. Same semantics as the batch path below with `losers = ∅`.
3229        if rows.len() == 1 {
3230            let row = rows.into_iter().next().expect("len checked");
3231            self.apply_put_row_single(row, check_existing_pk);
3232            return;
3233        }
3234        // One pass per row: track mutated columns, tombstone the previous
3235        // owner of the row's PK, index (which places the HOT entry), sample,
3236        // and materialize. Each row is applied completely — including its
3237        // memtable upsert — before the next row processes, so "the last row
3238        // wins" falls out naturally for an intra-batch duplicate PK: the
3239        // earlier row is already materialized and gets tombstoned like any
3240        // other displaced owner (same visible state as pre-partitioning the
3241        // batch into winners and losers, without materializing a winner map
3242        // over the whole batch).
3243        //
3244        // Upsert probing is skipped entirely when no PK owner can be
3245        // displaced: `check_existing_pk == false` means every PK is a fresh
3246        // engine-assigned AUTO_INCREMENT value; an empty HOT index plus
3247        // strictly-increasing batch PKs (the append-style batch, mirroring
3248        // `bulk_pk_winner_indices`' fast path) rules out both pre-existing
3249        // owners and intra-batch duplicates.
3250        let pk_id = self.schema.primary_key().map(|c| c.id);
3251        let probe = match pk_id {
3252            Some(pid) => {
3253                check_existing_pk
3254                    && !(self.hot.is_empty() && rows_pk_strictly_increasing(&rows, pid))
3255            }
3256            None => false,
3257        };
3258        // The PK reverse map is maintained inline only once a delete has built
3259        // it (`pk_by_row_complete`); ingest-only tables never pay for it.
3260        let maintain_pk_by_row = pk_id.is_some() && self.pk_by_row_complete;
3261        for r in rows {
3262            for &cid in r.columns.keys() {
3263                self.pending_put_cols.insert(cid);
3264            }
3265            match pk_id {
3266                Some(pid) if probe || maintain_pk_by_row => {
3267                    if let Some(pk_val) = r.columns.get(&pid) {
3268                        let key = self.index_lookup_key(pid, pk_val);
3269                        if probe {
3270                            if let Some(old_rid) = self.hot.get(&key) {
3271                                if old_rid != r.row_id {
3272                                    self.tombstone_row(
3273                                        old_rid,
3274                                        r.committed_epoch,
3275                                        r.commit_ts,
3276                                        true,
3277                                    );
3278                                }
3279                            }
3280                        }
3281                        if maintain_pk_by_row {
3282                            self.pk_by_row.insert(r.row_id, key);
3283                        }
3284                    }
3285                }
3286                Some(_) => {}
3287                None => {
3288                    self.hot.insert(r.row_id.0.to_be_bytes().to_vec(), r.row_id);
3289                }
3290            }
3291            self.index_row(&r);
3292            self.reservoir.offer(r.row_id.0);
3293            self.memtable.upsert(r);
3294            // Count as each row lands so a later duplicate's tombstone
3295            // decrement (in `tombstone_row`) sees an up-to-date value.
3296            self.live_count = self.live_count.saturating_add(1);
3297        }
3298        self.data_generation = self.data_generation.wrapping_add(1);
3299    }
3300
3301    /// One-row specialization of [`Table::apply_put_rows_inner`]: identical
3302    /// upsert semantics (tombstone the previous PK owner, insert into HOT,
3303    /// index, sample, materialize) without the per-batch winner/loser maps.
3304    fn apply_put_row_single(&mut self, row: Row, check_existing_pk: bool) {
3305        for &cid in row.columns.keys() {
3306            self.pending_put_cols.insert(cid);
3307        }
3308        let epoch = row.committed_epoch;
3309        if let Some(pk_col) = self.schema.primary_key() {
3310            let pk_id = pk_col.id;
3311            if let Some(pk_val) = row.columns.get(&pk_id) {
3312                // `index_row` below writes the HOT entry (`index_into` covers
3313                // the PK). The reverse map is maintained inline only once a
3314                // delete has built it; ingest-only tables never pay for it.
3315                let maintain_pk_by_row = self.pk_by_row_complete;
3316                if check_existing_pk || maintain_pk_by_row {
3317                    let key = self.index_lookup_key(pk_id, pk_val);
3318                    if check_existing_pk {
3319                        if let Some(old_rid) = self.hot.get(&key) {
3320                            if old_rid != row.row_id {
3321                                self.tombstone_row(old_rid, epoch, row.commit_ts, true);
3322                            }
3323                        }
3324                    }
3325                    if maintain_pk_by_row {
3326                        self.pk_by_row.insert(row.row_id, key);
3327                    }
3328                }
3329            }
3330        } else {
3331            self.hot
3332                .insert(row.row_id.0.to_be_bytes().to_vec(), row.row_id);
3333        }
3334        self.index_row(&row);
3335        self.reservoir.offer(row.row_id.0);
3336        self.memtable.upsert(row);
3337        self.live_count = self.live_count.saturating_add(1);
3338        self.data_generation = self.data_generation.wrapping_add(1);
3339    }
3340
3341    /// Allocate a fresh row id (advancing the table's allocator). Used by the
3342    /// cross-table `Transaction` to assign ids before sealing a row.
3343    pub(crate) fn alloc_row_id(&mut self) -> Result<RowId> {
3344        self.allocator.alloc()
3345    }
3346
3347    /// For clustered (WITHOUT ROWID) tables: derive a deterministic `RowId`
3348    /// from the primary-key value so the same PK always maps to the same row.
3349    /// Uses a stable hash of the PK's `encode_key()` bytes, cast to `u64`.
3350    /// This gives WITHOUT ROWID tables idempotent upsert semantics (same PK →
3351    /// same RowId, no allocator waste) without changing the storage format.
3352    fn derive_clustered_row_id(&self, columns: &[(u16, Value)]) -> Result<RowId> {
3353        let pk = self.schema.primary_key().ok_or_else(|| {
3354            MongrelError::Schema("clustered table requires a single-column primary key".into())
3355        })?;
3356        let pk_val = columns
3357            .iter()
3358            .find(|(id, _)| *id == pk.id)
3359            .map(|(_, v)| v)
3360            .ok_or_else(|| {
3361                MongrelError::Schema(format!(
3362                    "clustered table missing primary key column {} ({})",
3363                    pk.id, pk.name
3364                ))
3365            })?;
3366        Ok(clustered_row_id(pk_val))
3367    }
3368
3369    /// Apply the metadata for rows that were spilled to a linked uniform-epoch
3370    /// run (P3.4): update the HOT + secondary indexes, the reservoir, the
3371    /// allocator high-water mark, and `live_count` — but **do NOT** insert the
3372    /// rows into the memtable. The rows are served from the linked run (which the
3373    /// scan/merge path reads at the run's commit epoch), so materializing them in
3374    /// the memtable too would defeat the point of spilling (peak memory stays
3375    /// bounded). Caller must have linked the run before reads can resolve indexes.
3376    pub(crate) fn apply_run_metadata_prepared(&mut self, rows: &[Row]) -> Result<()> {
3377        if rows.iter().any(|row| row.row_id.0 >= u64::MAX - 1) {
3378            return Err(MongrelError::Full("row-id namespace exhausted".into()));
3379        }
3380        let n = rows.len();
3381        for r in rows {
3382            for &cid in r.columns.keys() {
3383                self.pending_put_cols.insert(cid);
3384            }
3385        }
3386        let (losers, winner_pks) = self.partition_pk_winners(rows);
3387        let epoch = rows.first().map(|r| r.committed_epoch).unwrap_or(Epoch(0));
3388        // Tombstone pre-existing rows that conflict with winners.
3389        let group_ts = rows.first().and_then(|r| r.commit_ts);
3390        for (key, &row_id) in &winner_pks {
3391            if let Some(old_rid) = self.hot.get(key) {
3392                if old_rid != row_id {
3393                    self.tombstone_row(old_rid, epoch, group_ts, true);
3394                }
3395            }
3396        }
3397        // Hide duplicate-PK rows inside this uniform-epoch run by tombstoning
3398        // their row ids in the memtable overlay (the overlay wins over the run).
3399        for &loser_rid in &losers {
3400            self.tombstone_row(loser_rid, epoch, group_ts, false);
3401        }
3402        // Insert the winners into HOT.
3403        for (key, row_id) in winner_pks {
3404            self.insert_hot_pk(key, row_id);
3405        }
3406        if self.schema.primary_key().is_none() {
3407            for r in rows {
3408                self.hot.insert(r.row_id.0.to_be_bytes().to_vec(), r.row_id);
3409            }
3410        }
3411        for r in rows {
3412            self.allocator.advance_to(r.row_id)?;
3413            if !losers.contains(&r.row_id) {
3414                self.index_row(r);
3415            }
3416        }
3417        for r in rows {
3418            if !losers.contains(&r.row_id) {
3419                self.reservoir.offer(r.row_id.0);
3420            }
3421        }
3422        self.live_count = self.live_count.saturating_add((n - losers.len()) as u64);
3423        self.data_generation = self.data_generation.wrapping_add(1);
3424        Ok(())
3425    }
3426
3427    /// Apply already-committed puts + tombstones during shared-WAL recovery
3428    /// (spec §15 pass 2). Advances the allocator, upserts/tombstones the
3429    /// memtable, and indexes the rows — but does NOT touch `live_count` (the
3430    /// manifest is authoritative) and does NOT append to the WAL.
3431    pub(crate) fn recover_apply(
3432        &mut self,
3433        rows: Vec<Row>,
3434        deletes: Vec<(RowId, Epoch)>,
3435    ) -> Result<()> {
3436        // Rows from different transactions have different epochs and can be
3437        // upserted sequentially. Rows inside one transaction share an epoch, so
3438        // duplicate PKs within that transaction must keep only the last winner.
3439        let mut by_epoch: std::collections::BTreeMap<Epoch, Vec<Row>> =
3440            std::collections::BTreeMap::new();
3441        for row in rows {
3442            if row.row_id.0 >= u64::MAX - 1 {
3443                return Err(MongrelError::Full("row-id namespace exhausted".into()));
3444            }
3445            self.allocator.advance_to(row.row_id)?;
3446            // Mirror the row-id advance for the AUTO_INCREMENT counter: WAL
3447            // replay must not hand out an id a recovered row already claimed.
3448            // `seeded` is intentionally left untouched so a still-unseeded
3449            // counter still scans `max(PK)` to cover already-flushed rows.
3450            if let Some(ai) = self.auto_inc.as_mut() {
3451                if let Some(Value::Int64(n)) = row.columns.get(&ai.column_id) {
3452                    let next = n.checked_add(1).ok_or_else(|| {
3453                        MongrelError::Full("AUTO_INCREMENT namespace exhausted".into())
3454                    })?;
3455                    if next > ai.next {
3456                        ai.next = next;
3457                    }
3458                }
3459            }
3460            by_epoch.entry(row.committed_epoch).or_default().push(row);
3461        }
3462        for (epoch, group) in by_epoch {
3463            let (losers, winner_pks) = self.partition_pk_winners(&group);
3464            // Tombstone pre-existing PK owners.
3465            let group_ts = group.first().and_then(|r| r.commit_ts);
3466            for (key, &row_id) in &winner_pks {
3467                if let Some(old_rid) = self.hot.get(key) {
3468                    if old_rid != row_id {
3469                        self.tombstone_row(old_rid, epoch, group_ts, false);
3470                    }
3471                }
3472            }
3473            for (key, row_id) in winner_pks {
3474                self.insert_hot_pk(key, row_id);
3475            }
3476            if self.schema.primary_key().is_none() {
3477                for r in &group {
3478                    self.hot.insert(r.row_id.0.to_be_bytes().to_vec(), r.row_id);
3479                }
3480            }
3481            for r in &group {
3482                if !losers.contains(&r.row_id) {
3483                    self.memtable.upsert(r.clone());
3484                    self.index_row(r);
3485                }
3486            }
3487        }
3488        for (rid, epoch) in deletes {
3489            self.memtable.tombstone(rid, epoch);
3490            self.remove_hot_for_row(rid, epoch);
3491        }
3492        // Reservoir stays lazy — see `ensure_reservoir_complete` — rather than
3493        // eagerly materializing every row on every WAL-replay batch.
3494        self.reservoir_complete = false;
3495        Ok(())
3496    }
3497
3498    /// Highest epoch whose data is durable in a sorted run (spec §7.1).
3499    pub(crate) fn flushed_epoch(&self) -> u64 {
3500        self.flushed_epoch
3501    }
3502
3503    pub(crate) fn set_flushed_epoch(&mut self, epoch: Epoch) {
3504        self.flushed_epoch = self.flushed_epoch.max(epoch.0);
3505    }
3506
3507    /// Validate that `cells` satisfy the schema's NOT NULL constraints.
3508    pub(crate) fn validate_cells_not_null(&self, cells: &[(u16, Value)]) -> Result<()> {
3509        self.schema.validate_values(cells)
3510    }
3511
3512    /// Column-major NOT NULL validation for the bulk-load paths. Every schema
3513    /// column that is not marked NULLABLE must be present in `columns` and have
3514    /// no null validity bits over its first `n` rows.
3515    fn validate_columns_not_null(
3516        &self,
3517        columns: &[(u16, columnar::NativeColumn)],
3518        n: usize,
3519    ) -> Result<()> {
3520        let by_id: HashMap<u16, &columnar::NativeColumn> =
3521            columns.iter().map(|(id, c)| (*id, c)).collect();
3522        for col in &self.schema.columns {
3523            if !col.flags.contains(ColumnFlags::NULLABLE) {
3524                match by_id.get(&col.id) {
3525                    None => {
3526                        return Err(MongrelError::InvalidArgument(format!(
3527                            "column '{}' ({}) is NOT NULL but was omitted from the bulk load",
3528                            col.name, col.id
3529                        )));
3530                    }
3531                    Some(c) => {
3532                        if c.null_count(n) != 0 {
3533                            return Err(MongrelError::InvalidArgument(format!(
3534                                "column '{}' ({}) is NOT NULL but the bulk load contains nulls",
3535                                col.name, col.id
3536                            )));
3537                        }
3538                    }
3539                }
3540            }
3541            if let TypeId::Enum { variants } = &col.ty {
3542                let Some(columnar::NativeColumn::Bytes { .. }) = by_id.get(&col.id).copied() else {
3543                    if by_id.contains_key(&col.id) {
3544                        return Err(MongrelError::InvalidArgument(format!(
3545                            "column '{}' ({}) enum requires a bytes column",
3546                            col.name, col.id
3547                        )));
3548                    }
3549                    continue;
3550                };
3551                for index in 0..n {
3552                    let Some(value) = columnar::native_bytes_at(by_id[&col.id], index) else {
3553                        continue;
3554                    };
3555                    if !variants.iter().any(|variant| variant.as_bytes() == value) {
3556                        return Err(MongrelError::InvalidArgument(format!(
3557                            "column '{}' ({}) enum value {:?} is not one of {:?}",
3558                            col.name,
3559                            col.id,
3560                            String::from_utf8_lossy(value),
3561                            variants
3562                        )));
3563                    }
3564                }
3565            }
3566        }
3567        Ok(())
3568    }
3569
3570    /// For a bulk-loaded batch, compute the row indices that survive primary-
3571    /// key upsert: for each PK value the last occurrence wins, earlier
3572    /// duplicates are dropped. Rows with a null PK value are always kept. Returns
3573    /// `None` when there is no primary key or no compaction is needed.
3574    fn bulk_pk_winner_indices(
3575        &self,
3576        columns: &[(u16, columnar::NativeColumn)],
3577        n: usize,
3578    ) -> Option<Vec<usize>> {
3579        let pk_col = self.schema.primary_key()?;
3580        let pk_id = pk_col.id;
3581        let pk_ty = pk_col.ty.clone();
3582        let by_id: HashMap<u16, &columnar::NativeColumn> =
3583            columns.iter().map(|(id, c)| (*id, c)).collect();
3584        let pk_native = by_id.get(&pk_id)?;
3585        if native_int64_strictly_increasing(pk_native, n) {
3586            return None;
3587        }
3588        // key -> index of the last row that carried that PK value.
3589        let mut last: HashMap<Vec<u8>, usize> = HashMap::new();
3590        let mut null_pk_rows: Vec<usize> = Vec::new();
3591        for i in 0..n {
3592            match bulk_index_key(&self.column_keys, pk_id, pk_ty.clone(), pk_native, i) {
3593                Some(key) => {
3594                    last.insert(key, i);
3595                }
3596                None => null_pk_rows.push(i),
3597            }
3598        }
3599        let mut winners: HashSet<usize> = last.values().copied().collect();
3600        for i in null_pk_rows {
3601            winners.insert(i);
3602        }
3603        Some((0..n).filter(|i| winners.contains(i)).collect())
3604    }
3605
3606    /// Logically delete `row_id` (effective at the next commit).
3607    pub fn delete(&mut self, row_id: RowId) -> Result<()> {
3608        self.require_delete()?;
3609        let epoch = self.pending_epoch();
3610        self.wal_append_data(Op::Delete {
3611            table_id: self.table_id,
3612            row_ids: vec![row_id],
3613        })?;
3614        if self.is_shared() {
3615            self.pending_dels.push(row_id);
3616        } else {
3617            self.apply_delete(row_id, epoch);
3618        }
3619        Ok(())
3620    }
3621
3622    pub fn delete_returning(&mut self, row_id: RowId) -> Result<Option<OwnedRow>> {
3623        let pre = self.get(row_id, self.snapshot());
3624        self.delete(row_id)?;
3625        Ok(pre.map(|row| {
3626            let mut columns: Vec<_> = row.columns.into_iter().collect();
3627            columns.sort_by_key(|(id, _)| *id);
3628            OwnedRow { columns }
3629        }))
3630    }
3631
3632    /// Durably remove every row in the table once the current write span commits.
3633    pub fn truncate(&mut self) -> Result<()> {
3634        self.require_delete()?;
3635        let epoch = self.pending_epoch();
3636        self.wal_append_data(Op::TruncateTable {
3637            table_id: self.table_id,
3638        })?;
3639        self.pending_rows.clear();
3640        self.pending_rows_auto_inc.clear();
3641        self.pending_dels.clear();
3642        self.pending_truncate = Some(epoch);
3643        Ok(())
3644    }
3645
3646    /// Apply an already-durable truncate without appending to the WAL.
3647    pub(crate) fn apply_truncate(&mut self, _epoch: Epoch) {
3648        // Unlink active topology in the next manifest before removing any run
3649        // file. A crash before that manifest is durable must still be able to
3650        // open the old manifest and replay the durable truncate from WAL.
3651        // Unreferenced files are safe orphans and `gc()` removes them later.
3652        self.run_refs.clear();
3653        self.retiring.clear();
3654        self.memtable = Memtable::new();
3655        self.mutable_run = MutableRun::new();
3656        self.hot = HotIndex::new();
3657        let (bitmap, ann, fm, sparse, minhash) = empty_indexes(&self.schema);
3658        self.bitmap = bitmap;
3659        self.ann = ann;
3660        self.fm = fm;
3661        self.sparse = sparse;
3662        self.minhash = minhash;
3663        self.learned_range = Arc::new(HashMap::new());
3664        self.pk_by_row.clear();
3665        self.pk_by_row_complete = false;
3666        self.live_count = 0;
3667        self.reservoir = crate::reservoir::Reservoir::default();
3668        self.reservoir_complete = true;
3669        self.had_deletes = true;
3670        self.agg_cache = Arc::new(HashMap::new());
3671        self.global_idx_epoch = 0;
3672        self.indexes_complete = true;
3673        self.pending_delete_rids.clear();
3674        self.pending_put_cols.clear();
3675        self.pending_rows.clear();
3676        self.pending_rows_auto_inc.clear();
3677        self.pending_dels.clear();
3678        self.clear_result_cache();
3679        self.invalidate_index_checkpoint();
3680        self.data_generation = self.data_generation.wrapping_add(1);
3681    }
3682
3683    /// Apply a tombstone (already-durable on the WAL) at `epoch` without
3684    /// appending to the per-table WAL. Used by the cross-table `Transaction`.
3685    pub(crate) fn apply_delete(&mut self, row_id: RowId, epoch: Epoch) {
3686        self.apply_delete_at(row_id, epoch, None);
3687    }
3688
3689    /// Apply a tombstone stamped with an optional HLC commit timestamp (P0.5).
3690    pub(crate) fn apply_delete_at(
3691        &mut self,
3692        row_id: RowId,
3693        epoch: Epoch,
3694        commit_ts: Option<mongreldb_types::hlc::HlcTimestamp>,
3695    ) {
3696        self.remove_hot_for_row(row_id, epoch);
3697        self.tombstone_row(row_id, epoch, commit_ts, true);
3698        self.data_generation = self.data_generation.wrapping_add(1);
3699    }
3700
3701    /// Tombstone `row_id` at `epoch`. When `adjust_live_count` is true the
3702    /// table's `live_count` is decremented (used on the live write path); during
3703    /// recovery the manifest is authoritative so the flag is false.
3704    fn tombstone_row(
3705        &mut self,
3706        row_id: RowId,
3707        epoch: Epoch,
3708        commit_ts: Option<mongreldb_types::hlc::HlcTimestamp>,
3709        adjust_live_count: bool,
3710    ) {
3711        let tombstone = Row {
3712            row_id,
3713            committed_epoch: epoch,
3714            commit_ts,
3715            columns: std::collections::HashMap::new(),
3716            deleted: true,
3717        };
3718        self.memtable.upsert(tombstone);
3719        self.pk_by_row.remove(&row_id);
3720        if adjust_live_count {
3721            self.live_count = self.live_count.saturating_sub(1);
3722        }
3723        // Track for fine-grained cache invalidation (c).
3724        self.pending_delete_rids.insert(row_id.0 as u32);
3725        // A delete makes the incremental aggregate cache (row-id watermark
3726        // delta) unsafe — permanently disable it for this table.
3727        self.had_deletes = true;
3728        self.agg_cache = Arc::new(HashMap::new());
3729    }
3730
3731    /// If `row_id` has a primary-key value and the HOT index currently maps
3732    /// that PK to this row id, remove the entry. Keeps the PK→RowId mapping
3733    /// consistent after deletes and before upserts.
3734    fn remove_hot_for_row(&mut self, row_id: RowId, epoch: Epoch) {
3735        let Some(pk_col) = self.schema.primary_key() else {
3736            return;
3737        };
3738        // Warm path: a prior delete in this process already paid the
3739        // reverse-map rebuild below, so it's kept up to date — O(1).
3740        if self.pk_by_row_complete {
3741            if let Some(key) = self.pk_by_row.remove(&row_id) {
3742                if self.hot.get(&key) == Some(row_id) {
3743                    self.hot.remove(&key);
3744                }
3745            }
3746            return;
3747        }
3748        // Cold path (the common case: a short-lived process — CLI,
3749        // NAPI-per-call — that deletes once and exits): derive the PK
3750        // straight from the row's own pre-delete version via a targeted
3751        // get_version lookup (memtable -> mutable_run -> runs, the same
3752        // page-pruned lookup `Table::get` uses) instead of paying
3753        // `refresh_pk_by_row_from_hot`'s O(table-size) rebuild for a single
3754        // delete. `pk_by_row` is deliberately left incomplete here — same
3755        // "puts leave the reverse map stale" tradeoff, extended to this path.
3756        //
3757        // Look up at `epoch - 1`, not `epoch`: on the live-delete call site
3758        // this delete's own tombstone hasn't landed yet either way, but on
3759        // the WAL-replay call sites (`recover_apply`, `open_in`) the
3760        // memtable tombstone for this exact row/epoch is already applied
3761        // before this runs. Querying `epoch` would see that tombstone
3762        // (empty columns) and fall through to the full rebuild every time a
3763        // replayed delete exists; `epoch - 1` is still >= any real prior
3764        // version's committed_epoch (epochs are unique and monotonic), so it
3765        // finds the same pre-delete row either way.
3766        let lookup_epoch = Epoch(epoch.0.saturating_sub(1));
3767        if self.indexes_complete {
3768            let pk_val = self
3769                .memtable
3770                .get_version(row_id, lookup_epoch)
3771                .and_then(|(_, r)| r.columns.get(&pk_col.id).cloned())
3772                .or_else(|| {
3773                    self.mutable_run
3774                        .get_version(row_id, lookup_epoch)
3775                        .filter(|(_, r)| !r.deleted)
3776                        .and_then(|(_, r)| r.columns.get(&pk_col.id).cloned())
3777                })
3778                .or_else(|| {
3779                    self.run_refs.iter().find_map(|rr| {
3780                        let mut reader = self.open_reader(rr.run_id).ok()?;
3781                        let (_, deleted, val) = reader
3782                            .get_version_column(row_id, lookup_epoch, pk_col.id)
3783                            .ok()??;
3784                        if deleted {
3785                            return None;
3786                        }
3787                        val
3788                    })
3789                });
3790            if let Some(pk_val) = pk_val {
3791                let key = self.index_lookup_key(pk_col.id, &pk_val);
3792                if self.hot.get(&key) == Some(row_id) {
3793                    self.hot.remove(&key);
3794                }
3795                return;
3796            }
3797        }
3798        // Fallback: full reverse-map rebuild, guaranteed correct. Reached
3799        // when indexes aren't complete yet, or the row was already gone by
3800        // the time this ran (e.g. already tombstoned in an overlay ahead of
3801        // this HOT cleanup, as `rebuild_indexes_from_runs` does).
3802        self.refresh_pk_by_row_from_hot();
3803        if let Some(key) = self.pk_by_row.remove(&row_id) {
3804            if self.hot.get(&key) == Some(row_id) {
3805                self.hot.remove(&key);
3806            }
3807        }
3808    }
3809
3810    /// For a batch of rows that share the same commit epoch, decide which rows
3811    /// win for each primary-key value. Returns the set of "loser" row ids that
3812    /// must be skipped/overwritten, and a map from PK lookup key to the winning
3813    /// row id. Rows without a PK value are always winners.
3814    fn partition_pk_winners(
3815        &self,
3816        rows: &[Row],
3817    ) -> (
3818        std::collections::HashSet<RowId>,
3819        std::collections::HashMap<Vec<u8>, RowId>,
3820    ) {
3821        let mut losers = std::collections::HashSet::new();
3822        let Some(pk_col) = self.schema.primary_key() else {
3823            return (losers, std::collections::HashMap::new());
3824        };
3825        let pk_id = pk_col.id;
3826        let mut winners: std::collections::HashMap<Vec<u8>, RowId> =
3827            std::collections::HashMap::new();
3828        for r in rows {
3829            let Some(pk_val) = r.columns.get(&pk_id) else {
3830                continue;
3831            };
3832            let key = self.index_lookup_key(pk_id, pk_val);
3833            if let Some(&old_rid) = winners.get(&key) {
3834                losers.insert(old_rid);
3835            }
3836            winners.insert(key, r.row_id);
3837        }
3838        (losers, winners)
3839    }
3840
3841    fn index_row(&mut self, row: &Row) {
3842        if row.deleted {
3843            return;
3844        }
3845        // Partial index filtering: skip rows that don't match any index's
3846        // predicate. The predicate is a SQL WHERE clause string evaluated
3847        // against the row's column values. For now, we support a simple
3848        // "column_name IS NOT NULL" and "column_name = value" syntax that
3849        // covers the common partial-index patterns (e.g. WHERE deleted_at
3850        // IS NULL). More complex predicates require a full expression
3851        // evaluator in core (future work).
3852        let any_predicate = self
3853            .schema
3854            .indexes
3855            .iter()
3856            .any(|idx| idx.predicate.is_some());
3857        if any_predicate {
3858            let columns_map: HashMap<u16, &Value> =
3859                row.columns.iter().map(|(k, v)| (*k, v)).collect();
3860            let name_to_id: HashMap<&str, u16> = self
3861                .schema
3862                .columns
3863                .iter()
3864                .map(|c| (c.name.as_str(), c.id))
3865                .collect();
3866            for idx in &self.schema.indexes {
3867                if let Some(pred) = &idx.predicate {
3868                    if !eval_partial_predicate(pred, &columns_map, &name_to_id) {
3869                        continue; // skip this index for this row
3870                    }
3871                }
3872                // Index the row into this specific index only.
3873                index_into_single(
3874                    idx,
3875                    &self.schema,
3876                    row,
3877                    &mut self.hot,
3878                    &mut self.bitmap,
3879                    &mut self.ann,
3880                    &mut self.fm,
3881                    &mut self.sparse,
3882                    &mut self.minhash,
3883                );
3884            }
3885            return;
3886        }
3887        // Plaintext tables index the row as-is; only ENCRYPTED_INDEXABLE
3888        // columns need the tokenized copy (`tokenized_for_indexes` clones the
3889        // whole row, which would tax every put on unencrypted tables).
3890        if self.column_keys.is_empty() {
3891            index_into(
3892                &self.schema,
3893                row,
3894                &mut self.hot,
3895                &mut self.bitmap,
3896                &mut self.ann,
3897                &mut self.fm,
3898                &mut self.sparse,
3899                &mut self.minhash,
3900            );
3901            return;
3902        }
3903        let effective_row = self.tokenized_for_indexes(row);
3904        index_into(
3905            &self.schema,
3906            &effective_row,
3907            &mut self.hot,
3908            &mut self.bitmap,
3909            &mut self.ann,
3910            &mut self.fm,
3911            &mut self.sparse,
3912            &mut self.minhash,
3913        );
3914    }
3915
3916    /// Produce the row view that indexes should see. For ENCRYPTED_INDEXABLE
3917    /// equality (HMAC-eq) columns the plaintext value is replaced by its token,
3918    /// so the bitmap/HOT indexes store tokens. OPE-range columns keep their raw
3919    /// value (their range index is rebuilt from runs over plaintext). Plaintext
3920    /// tables return the row unchanged.
3921    fn tokenized_for_indexes(&self, row: &Row) -> Row {
3922        if self.column_keys.is_empty() {
3923            return row.clone();
3924        }
3925        {
3926            use crate::encryption::SCHEME_HMAC_EQ;
3927            let mut tok = row.clone();
3928            for (&cid, &(_, scheme)) in &self.column_keys {
3929                if scheme != SCHEME_HMAC_EQ {
3930                    continue;
3931                }
3932                if let Some(v) = tok.columns.get(&cid).cloned() {
3933                    if let Some(t) = self.tokenize_value(cid, &v) {
3934                        tok.columns.insert(cid, t);
3935                    }
3936                }
3937            }
3938            tok
3939        }
3940    }
3941
3942    /// Group-commit: make all pending writes durable, advance the epoch so they
3943    /// become visible, and persist the manifest. Dispatches on the WAL sink: a
3944    /// standalone table fsyncs its private WAL; a mounted table seals into the
3945    /// shared WAL and defers the fsync to the group-commit coordinator (B1).
3946    pub fn commit(&mut self) -> Result<Epoch> {
3947        self.commit_inner(None)
3948    }
3949
3950    /// Prepare a pending commit cooperatively, then invoke `before_commit`
3951    /// immediately before the durable transaction marker is appended.
3952    #[doc(hidden)]
3953    pub fn commit_controlled<F>(
3954        &mut self,
3955        control: &crate::ExecutionControl,
3956        mut before_commit: F,
3957    ) -> Result<Epoch>
3958    where
3959        F: FnMut() -> Result<()>,
3960    {
3961        self.commit_inner(Some((control, &mut before_commit)))
3962    }
3963
3964    fn commit_inner(
3965        &mut self,
3966        controlled: Option<(&crate::ExecutionControl, &mut dyn FnMut() -> Result<()>)>,
3967    ) -> Result<Epoch> {
3968        self.ensure_writable()?;
3969        if !self.has_pending_mutations() {
3970            if self.current_txn_id == 0 && matches!(&self.wal, WalSink::Private(_)) {
3971                return Err(MongrelError::Full(
3972                    "standalone transaction id namespace exhausted".into(),
3973                ));
3974            }
3975            return Ok(self.epoch.visible());
3976        }
3977        self.commit_new_epoch_inner(controlled)
3978    }
3979
3980    /// Seal a real logical write at a fresh epoch. Bulk-load paths publish
3981    /// their run directly rather than staging rows in the WAL, so they call
3982    /// this after proving the input is non-empty.
3983    fn commit_new_epoch(&mut self) -> Result<Epoch> {
3984        self.commit_new_epoch_inner(None)
3985    }
3986
3987    fn commit_new_epoch_inner(
3988        &mut self,
3989        controlled: Option<(&crate::ExecutionControl, &mut dyn FnMut() -> Result<()>)>,
3990    ) -> Result<Epoch> {
3991        self.ensure_writable()?;
3992        if self.is_shared() {
3993            self.commit_shared(controlled)
3994        } else {
3995            self.commit_private(controlled)
3996        }
3997    }
3998
3999    /// Standalone commit: fsync the private WAL under the commit lock.
4000    fn commit_private(
4001        &mut self,
4002        controlled: Option<(&crate::ExecutionControl, &mut dyn FnMut() -> Result<()>)>,
4003    ) -> Result<Epoch> {
4004        // Serialize the assign→fsync→publish critical section across all tables
4005        // sharing the epoch authority so `visible` is published strictly in
4006        // assigned order (the dual-counter invariant).
4007        let commit_lock = Arc::clone(&self.commit_lock);
4008        let _g = commit_lock.lock();
4009        // Validate the private transaction namespace before allocating an
4010        // epoch or appending any terminal WAL record.
4011        let txn_id = self.ensure_txn_id()?;
4012        if let Some((control, before_commit)) = controlled {
4013            control.checkpoint()?;
4014            before_commit()?;
4015        }
4016        let new_epoch = self.epoch.bump_assigned();
4017        let epoch_authority = Arc::clone(&self.epoch);
4018        let mut epoch_guard = EpochGuard::new(epoch_authority.as_ref(), new_epoch);
4019        // Seal the staged records under a TxnCommit marker carrying the commit
4020        // epoch, then a single group fsync. Recovery applies only records whose
4021        // txn has a durable TxnCommit (uncommitted/torn tails are discarded).
4022        let wal_result = match &mut self.wal {
4023            WalSink::Private(w) => w
4024                .append_txn(
4025                    txn_id,
4026                    Op::TxnCommit {
4027                        epoch: new_epoch.0,
4028                        added_runs: Vec::new(),
4029                    },
4030                )
4031                .and_then(|_| w.sync()),
4032            WalSink::Shared(_) => unreachable!("commit_private on a shared sink"),
4033            WalSink::ReadOnly => Err(MongrelError::ReadOnlyReplica),
4034        };
4035        if let Err(error) = wal_result {
4036            self.durable_commit_failed = true;
4037            return Err(MongrelError::CommitOutcomeUnknown {
4038                epoch: new_epoch.0,
4039                message: error.to_string(),
4040            });
4041        }
4042        // The commit marker is durable. Resolve the assigned epoch even when a
4043        // live publish/checkpoint step fails, and report the exact outcome.
4044        if let Some(epoch) = self.pending_truncate.take() {
4045            self.apply_truncate(epoch);
4046        }
4047        self.invalidate_pending_cache();
4048        let publish_result = self.persist_manifest(new_epoch);
4049        // Publish through the shared in-order gate so a `Table::commit` can never
4050        // advance the watermark past an in-flight cross-table transaction's
4051        // lower assigned epoch whose writes are not yet applied (spec §9.3e).
4052        self.epoch.publish_in_order(new_epoch);
4053        epoch_guard.disarm();
4054        if let Err(error) = publish_result {
4055            self.durable_commit_failed = true;
4056            return Err(MongrelError::DurableCommit {
4057                epoch: new_epoch.0,
4058                message: error.to_string(),
4059            });
4060        }
4061        self.current_txn_id = txn_id.checked_add(1).unwrap_or(0);
4062        self.pending_private_mutations = false;
4063        self.data_generation = self.data_generation.wrapping_add(1);
4064        Ok(new_epoch)
4065    }
4066
4067    /// Mounted commit (B1/B2): mirror the cross-table sequencer. Seal a
4068    /// `TxnCommit` into the shared WAL under the WAL lock (assigning the epoch in
4069    /// WAL-append order), make it durable via the group-commit coordinator (one
4070    /// leader fsync for the whole batch), then apply the staged rows at the
4071    /// assigned epoch and publish in order. Honors the shared poison flag.
4072    fn commit_shared(
4073        &mut self,
4074        controlled: Option<(&crate::ExecutionControl, &mut dyn FnMut() -> Result<()>)>,
4075    ) -> Result<Epoch> {
4076        use std::sync::atomic::Ordering;
4077        let s = match &self.wal {
4078            WalSink::Shared(s) => s.clone(),
4079            WalSink::Private(_) => unreachable!("commit_shared on a private sink"),
4080            WalSink::ReadOnly => return Err(MongrelError::ReadOnlyReplica),
4081        };
4082        if s.poisoned.load(Ordering::Relaxed) {
4083            return Err(MongrelError::Other(
4084                "database poisoned by fsync error".into(),
4085            ));
4086        }
4087        // Serialize the whole single-table commit critical section (assign →
4088        // durable → publish) under the shared commit lock so concurrent
4089        // `Table::commit`s publish strictly in assigned order and each returns
4090        // only once its epoch is visible (read-your-writes after commit). The
4091        // fsync still defers to the group-commit coordinator, which can batch a
4092        // held commit with concurrent cross-table `transaction()` committers.
4093        let commit_lock = Arc::clone(&self.commit_lock);
4094        let _g = commit_lock.lock();
4095        if !self.pending_rows.is_empty() {
4096            match controlled.as_ref() {
4097                Some((control, _)) => self.prepare_durable_publish_controlled(control)?,
4098                None => self.prepare_durable_publish()?,
4099            }
4100        }
4101        // Always seal a txn (allocating an id if this span had no writes) so the
4102        // epoch advances monotonically like the standalone path.
4103        let txn_id = self.ensure_txn_id()?;
4104        let mut wal = s.wal.lock();
4105        if let Some((control, before_commit)) = controlled {
4106            control.checkpoint()?;
4107            before_commit()?;
4108        }
4109        let new_epoch = self.epoch.bump_assigned();
4110        let epoch_authority = Arc::clone(&self.epoch);
4111        let mut epoch_guard = EpochGuard::new(epoch_authority.as_ref(), new_epoch);
4112        // P0.5: stamp every row version with the database HLC so visibility is
4113        // HLC-authoritative on the single-table commit path too.
4114        let commit_ts = s.hlc.now().map_err(|skew| {
4115            MongrelError::Other(format!(
4116                "clock skew rejected commit timestamp allocation: {skew}"
4117            ))
4118        })?;
4119        let commit_seq = match wal.append_commit_at(
4120            txn_id,
4121            new_epoch,
4122            &[],
4123            commit_ts.physical_micros.saturating_mul(1_000),
4124        ) {
4125            Ok(commit_seq) => commit_seq,
4126            Err(error) => {
4127                s.poisoned.store(true, Ordering::Relaxed);
4128                s.lifecycle.poison();
4129                return Err(MongrelError::CommitOutcomeUnknown {
4130                    epoch: new_epoch.0,
4131                    message: error.to_string(),
4132                });
4133            }
4134        };
4135        drop(wal);
4136        if let Err(error) = s.group.await_durable(&s.wal, commit_seq) {
4137            s.poisoned.store(true, Ordering::Relaxed);
4138            s.lifecycle.poison();
4139            return Err(MongrelError::CommitOutcomeUnknown {
4140                epoch: new_epoch.0,
4141                message: error.to_string(),
4142            });
4143        }
4144
4145        // Apply staged state after durability, but never lose the durable
4146        // outcome if a live apply or manifest checkpoint fails.
4147        if self.pending_truncate.take().is_some() {
4148            self.apply_truncate(new_epoch);
4149        }
4150        let mut rows = std::mem::take(&mut self.pending_rows);
4151        if !rows.is_empty() {
4152            for r in &mut rows {
4153                r.committed_epoch = new_epoch;
4154                r.commit_ts = Some(commit_ts);
4155            }
4156            let auto_inc_flags = std::mem::take(&mut self.pending_rows_auto_inc);
4157            let all_auto_generated =
4158                auto_inc_flags.len() == rows.len() && auto_inc_flags.iter().all(|b| *b);
4159            self.apply_put_rows_inner_prepared(rows, !all_auto_generated);
4160        } else {
4161            self.pending_rows_auto_inc.clear();
4162        }
4163        let dels = std::mem::take(&mut self.pending_dels);
4164        for rid in dels {
4165            self.apply_delete_at(rid, new_epoch, Some(commit_ts));
4166        }
4167
4168        self.invalidate_pending_cache();
4169        let publish_result = self.persist_manifest(new_epoch);
4170        self.epoch.publish_in_order(new_epoch);
4171        epoch_guard.disarm();
4172        let _ = s.change_wake.send(());
4173        if let Err(error) = publish_result {
4174            self.durable_commit_failed = true;
4175            s.poisoned.store(true, Ordering::Relaxed);
4176            s.lifecycle.poison();
4177            return Err(MongrelError::DurableCommit {
4178                epoch: new_epoch.0,
4179                message: error.to_string(),
4180            });
4181        }
4182        // Next auto-commit span allocates a fresh shared txn id.
4183        self.current_txn_id = 0;
4184        self.data_generation = self.data_generation.wrapping_add(1);
4185        Ok(new_epoch)
4186    }
4187
4188    /// Commit, then drain the memtable into the mutable-run LSM tier (Phase
4189    /// 11.1). The tier absorbs flushes in place and only spills to an immutable
4190    /// `.sr` sorted run once it crosses the spill watermark — coalescing many
4191    /// small flushes into fewer, larger runs. While the tier holds un-spilled
4192    /// data the WAL is **not** rotated: the Flush marker / WAL rotation is
4193    /// deferred until the data is durably in a run, so crash recovery replays
4194    /// those rows back into the memtable (the tier rebuilds from replay).
4195    pub fn flush(&mut self) -> Result<Epoch> {
4196        self.flush_with_outcome().map(|(epoch, _)| epoch)
4197    }
4198
4199    /// Flush and report whether this call published pending logical mutations.
4200    pub fn flush_with_outcome(&mut self) -> Result<(Epoch, bool)> {
4201        self.flush_with_outcome_inner(None)
4202    }
4203
4204    /// Cooperatively prepare a flush, entering the commit fence immediately
4205    /// before its transaction marker can become durable.
4206    #[doc(hidden)]
4207    pub fn flush_with_outcome_controlled<F>(
4208        &mut self,
4209        control: &crate::ExecutionControl,
4210        mut before_commit: F,
4211    ) -> Result<(Epoch, bool)>
4212    where
4213        F: FnMut() -> Result<()>,
4214    {
4215        self.flush_with_outcome_inner(Some((control, &mut before_commit)))
4216    }
4217
4218    fn flush_with_outcome_inner(
4219        &mut self,
4220        controlled: Option<(&crate::ExecutionControl, &mut dyn FnMut() -> Result<()>)>,
4221    ) -> Result<(Epoch, bool)> {
4222        match controlled.as_ref() {
4223            Some((control, _)) => {
4224                self.ensure_indexes_complete_controlled(control, || true)?;
4225            }
4226            None => self.ensure_indexes_complete()?,
4227        }
4228        let committed = self.has_pending_mutations();
4229        let epoch = self.commit_inner(controlled)?;
4230        let finish: Result<(Epoch, bool)> = (|| {
4231            let rows = self.memtable.drain_sorted();
4232            if !rows.is_empty() {
4233                self.mutable_run.insert_many(rows);
4234            }
4235            if self.mutable_run.approx_bytes() >= self.mutable_run_spill_bytes {
4236                self.spill_mutable_run(epoch)?;
4237                // The tier is now empty and its data is durably in a run → safe to
4238                // mark the WAL flushed (and, for a private WAL, rotate to a fresh
4239                // segment so the flushed records aren't replayed).
4240                self.mark_flushed(epoch)?;
4241                self.persist_manifest(epoch)?;
4242                self.build_learned_ranges()?;
4243                // Memtable is drained and runs are stable → checkpoint the indexes so
4244                // the next open skips the full run scan (Phase 9.1).
4245                self.checkpoint_indexes(epoch);
4246            }
4247            // else: data coalesced in the in-memory tier; the WAL still covers it
4248            // and the manifest epoch was already persisted by `commit`.
4249            Ok((epoch, committed))
4250        })();
4251        let outcome = match finish {
4252            Err(error) if committed => Err(MongrelError::DurableCommit {
4253                epoch: epoch.0,
4254                message: error.to_string(),
4255            }),
4256            result => result,
4257        };
4258        if outcome.is_ok() {
4259            // S1C-001: the base changed (the memtable drained into the
4260            // mutable-run tier and may have spilled to a new run) — publish a
4261            // fresh immutable view for generation readers. Indexes were
4262            // ensured complete above, so publishing cannot fail; if it ever
4263            // did, the previous (still valid) view stays published.
4264            let _ = self.publish_read_generation();
4265        }
4266        outcome
4267    }
4268
4269    fn has_pending_mutations(&self) -> bool {
4270        self.pending_private_mutations
4271            || !self.pending_rows.is_empty()
4272            || !self.pending_dels.is_empty()
4273            || self.pending_truncate.is_some()
4274    }
4275
4276    pub fn has_pending_writes(&self) -> bool {
4277        self.has_pending_mutations()
4278    }
4279
4280    /// Force a full flush to a `.sr` sorted run regardless of the spill
4281    /// threshold. Temporarily lowers `mutable_run_spill_bytes` to 1 so the
4282    /// threshold check in [`Self::flush`] always fires. Used by
4283    /// [`Self::close`] and the Kit's flush-on-close path (§4.4) so a
4284    /// short-lived process (CLI, one-shot script) leaves all pending writes
4285    /// durable in a run — keeping WAL segment count bounded across repeated
4286    /// invocations. Best-effort: errors are propagated but the threshold is
4287    /// always restored.
4288    pub fn force_flush(&mut self) -> Result<Epoch> {
4289        let saved = self.mutable_run_spill_bytes;
4290        self.mutable_run_spill_bytes = 1;
4291        let result = self.flush();
4292        self.mutable_run_spill_bytes = saved;
4293        result
4294    }
4295
4296    /// Best-effort close: force-flush any pending writes to a sorted run so
4297    /// the WAL segments can be reaped on the next open. Never panics — a
4298    /// flush error is logged and returned but the threshold is always
4299    /// restored. Call this as the last action before a short-lived process
4300    /// exits (CLI, one-shot script). Not needed for the daemon (its
4301    /// background auto-compactor handles run management). (§4.4)
4302    pub fn close(&mut self) -> Result<()> {
4303        if self.memtable_len() > 0 || self.mutable_run_len() > 0 {
4304            self.force_flush()?;
4305        }
4306        Ok(())
4307    }
4308
4309    /// Mark `epoch` as flushed: append a `Flush` marker to the WAL, advance
4310    /// `flushed_epoch`, and — for a private WAL only — rotate to a fresh segment
4311    /// so the now-durable-in-a-run records are not replayed. A mounted table's
4312    /// shared WAL is never rotated per-table; recovery skips its already-flushed
4313    /// records via the manifest `flushed_epoch` gate, and segment GC (B3c) reaps
4314    /// them once every table has flushed past them.
4315    fn mark_flushed(&mut self, epoch: Epoch) -> Result<()> {
4316        let op = Op::Flush {
4317            table_id: self.table_id,
4318            flushed_epoch: epoch.0,
4319        };
4320        match &mut self.wal {
4321            WalSink::Private(w) => {
4322                w.append_system(op)?;
4323                w.sync()?;
4324            }
4325            WalSink::Shared(s) => {
4326                // Informational in the shared log (recovery gates on the manifest
4327                // `flushed_epoch`); not separately fsynced — the run + manifest
4328                // are the durability point and the underlying rows were already
4329                // fsynced at their commit.
4330                s.wal.lock().append_system(op)?;
4331            }
4332            WalSink::ReadOnly => return Err(MongrelError::ReadOnlyReplica),
4333        }
4334        self.flushed_epoch = epoch.0;
4335        if matches!(self.wal, WalSink::Private(_)) {
4336            self.rotate_wal(epoch)?;
4337        }
4338        Ok(())
4339    }
4340
4341    /// Spill the mutable-run tier to a new immutable level-0 sorted run. The
4342    /// caller owns the Flush-marker / WAL-rotation / manifest steps (only valid
4343    /// once all in-flight data is in runs). No-op when the tier is empty.
4344    fn spill_mutable_run(&mut self, epoch: Epoch) -> Result<()> {
4345        if self.mutable_run.is_empty() {
4346            return Ok(());
4347        }
4348        let run_id = self.alloc_run_id()?;
4349        let rows = self.mutable_run.drain_sorted();
4350        if rows.is_empty() {
4351            return Ok(());
4352        }
4353        let path = self.run_path(run_id);
4354        let mut writer = RunWriter::new(&self.schema, run_id as u128, epoch, 0);
4355        if let Some(kek) = &self.kek {
4356            writer = writer.with_encryption(kek.as_ref(), self.indexable_column_specs());
4357        }
4358        let header = match self.create_run_file(run_id)? {
4359            Some(file) => writer.write_file(file, &rows)?,
4360            None => writer.write(&path, &rows)?,
4361        };
4362        self.run_refs.push(RunRef {
4363            run_id: run_id as u128,
4364            level: 0,
4365            epoch_created: epoch.0,
4366            row_count: header.row_count,
4367        });
4368        Ok(())
4369    }
4370
4371    /// Tune the mutable-run spill watermark (bytes). A smaller threshold spills
4372    /// sooner (more, smaller runs — closer to the pre-Phase-11.1 behavior); a
4373    /// larger one coalesces more flushes in memory.
4374    pub fn set_mutable_run_spill_bytes(&mut self, bytes: u64) {
4375        self.mutable_run_spill_bytes = bytes.max(1);
4376    }
4377
4378    /// Set the zstd compression level for compaction output (Phase 18.1).
4379    /// Default 3; higher values give better compression ratio at the cost of
4380    /// slower compaction.
4381    pub fn set_compaction_zstd_level(&mut self, level: i32) {
4382        self.compaction_zstd_level = level;
4383    }
4384
4385    /// Set the result-cache byte budget (Phase 19.1 hardening (a)). Entries are
4386    /// evicted in access-order LRU past this limit. Takes effect immediately
4387    /// (may evict entries if the new limit is smaller than the current footprint).
4388    pub fn set_result_cache_max_bytes(&mut self, max_bytes: u64) {
4389        self.result_cache.lock().set_max_bytes(max_bytes);
4390    }
4391
4392    /// Drop every cached result (used by compaction, schema evolution, and bulk
4393    /// load — paths that change run layout or data without going through the
4394    /// fine-grained `pending_*` tracking).
4395    pub(crate) fn clear_result_cache(&mut self) {
4396        self.result_cache.lock().clear();
4397    }
4398
4399    /// Number of versions currently held in the mutable-run tier.
4400    pub fn mutable_run_len(&self) -> usize {
4401        self.mutable_run.len()
4402    }
4403
4404    /// Drain every version from the mutable-run tier (ascending `(RowId,
4405    /// Epoch)` order). Used by compaction to fold the tier into its merge.
4406    pub(crate) fn drain_mutable_run(&mut self) -> Vec<Row> {
4407        self.mutable_run.drain_sorted()
4408    }
4409
4410    /// Snapshot the mutable-run tier without changing live table state.
4411    pub(crate) fn snapshot_mutable_run(&self) -> Vec<Row> {
4412        let mut snapshot = self.mutable_run.clone();
4413        snapshot.drain_sorted()
4414    }
4415
4416    /// Bulk-load: write `batch` directly to a new sorted run, bypassing the WAL
4417    /// and the memtable entirely (no per-row bincode, no skip-list inserts). The
4418    /// run + a rotated WAL + the manifest are fsynced once — the fast ingest
4419    /// path for large analytical loads. Indexes are still maintained.
4420    pub fn bulk_load(&mut self, batch: Vec<Vec<(u16, Value)>>) -> Result<Epoch> {
4421        self.ensure_writable()?;
4422        let n = batch.len();
4423        if n == 0 {
4424            return Ok(self.current_epoch());
4425        }
4426        for row in &batch {
4427            self.schema.validate_values(row)?;
4428        }
4429        let epoch = self.commit_new_epoch()?;
4430        let live_before = self.live_count;
4431        // Spill any pending mutable-run data first: bulk_load writes a Flush
4432        // marker + rotates the WAL below, which is only safe once all in-flight
4433        // data is durably in a run.
4434        self.spill_mutable_run(epoch)?;
4435        let eager_index_build = self.index_build_policy == IndexBuildPolicy::Eager
4436            && self.indexes_complete
4437            && self.run_refs.is_empty()
4438            && self.memtable.is_empty()
4439            && self.mutable_run.is_empty();
4440        // Phase 14.7: route the legacy Value API through the same parallel
4441        // encode + typed batch-index path as `bulk_load_columns`. Transpose the
4442        // row-major sparse batch → column-major typed columns (in parallel),
4443        // then `write_native` + `index_columns_bulk`, instead of per-row
4444        // `Row { HashMap }` + `index_into` + the sequential `Value` writer.
4445        let mut user_columns: Vec<(u16, columnar::NativeColumn)> = {
4446            use rayon::prelude::*;
4447            self.schema
4448                .columns
4449                .par_iter()
4450                .map(|cdef| {
4451                    (
4452                        cdef.id,
4453                        columnar::rows_to_native(cdef.ty.clone(), &batch, cdef.id),
4454                    )
4455                })
4456                .collect::<Vec<_>>()
4457        };
4458        drop(batch);
4459        // Enforce NOT NULL constraints and primary-key upsert semantics before
4460        // any row id is allocated or bytes hit the run file. Losers of a
4461        // duplicate primary key are dropped from the encoded run entirely so
4462        // the dedup survives reopen (no ephemeral memtable tombstone).
4463        self.fill_auto_inc_native_columns(&mut user_columns, n)?;
4464        self.validate_columns_not_null(&user_columns, n)?;
4465        let winner_idx = self
4466            .bulk_pk_winner_indices(&user_columns, n)
4467            .filter(|idx| idx.len() != n);
4468        let (write_columns, write_n): (Vec<(u16, columnar::NativeColumn)>, usize) =
4469            match winner_idx.as_deref() {
4470                Some(idx) => {
4471                    let compacted = user_columns
4472                        .iter()
4473                        .map(|(id, c)| (*id, c.gather(idx)))
4474                        .collect();
4475                    (compacted, idx.len())
4476                }
4477                None => (std::mem::take(&mut user_columns), n),
4478            };
4479        self.advance_auto_inc_from_native_columns(&write_columns, write_n, live_before)?;
4480        let first = self.allocator.alloc_range(write_n as u64)?.0;
4481        for rid in first..first + write_n as u64 {
4482            self.reservoir.offer(rid);
4483        }
4484        let run_id = self.alloc_run_id()?;
4485        let path = self.run_path(run_id);
4486        let mut writer = RunWriter::new(&self.schema, run_id as u128, epoch, 0)
4487            .clean(true)
4488            .with_lz4()
4489            .with_native_endian();
4490        if let Some(kek) = &self.kek {
4491            writer = writer.with_encryption(kek.as_ref(), self.indexable_column_specs());
4492        }
4493        let header = match self.create_run_file(run_id)? {
4494            Some(file) => writer.write_native_file(file, &write_columns, write_n, first)?,
4495            None => writer.write_native(&path, &write_columns, write_n, first)?,
4496        };
4497        self.run_refs.push(RunRef {
4498            run_id: run_id as u128,
4499            level: 0,
4500            epoch_created: epoch.0,
4501            row_count: header.row_count,
4502        });
4503        self.live_count = self.live_count.saturating_add(write_n as u64);
4504        if eager_index_build {
4505            let row_ids: Vec<u64> = (first..first + write_n as u64).collect();
4506            self.index_columns_bulk(&write_columns, &row_ids);
4507            self.indexes_complete = true;
4508            self.build_learned_ranges()?;
4509        } else {
4510            self.indexes_complete = false;
4511        }
4512        self.mark_flushed(epoch)?;
4513        self.persist_manifest(epoch)?;
4514        if eager_index_build {
4515            self.checkpoint_indexes(epoch);
4516        }
4517        self.clear_result_cache();
4518        Ok(epoch)
4519    }
4520
4521    /// Rotate the private WAL to a fresh segment. Only valid for a standalone
4522    /// table — a mounted table never rotates the shared WAL per-table.
4523    fn rotate_wal(&mut self, epoch: Epoch) -> Result<()> {
4524        let segment = next_wal_segment(&self.dir.join(WAL_DIR))?;
4525        let cipher = self.wal_dek.as_ref().map(|dk| make_cipher(dk));
4526        // The segment number (from the filename) namespaces nonces under the
4527        // constant WAL DEK — pass it through to the writer.
4528        let segment_no = segment
4529            .file_stem()
4530            .and_then(|s| s.to_str())
4531            .and_then(|s| s.strip_prefix("seg-"))
4532            .and_then(|s| s.parse::<u64>().ok())
4533            .unwrap_or(0);
4534        let mut wal = Wal::create_with_cipher(segment, epoch, cipher, segment_no)?;
4535        wal.set_sync_byte_threshold(self.sync_byte_threshold);
4536        wal.sync()?;
4537        self.wal = WalSink::Private(wal);
4538        Ok(())
4539    }
4540
4541    /// Fine-grained result-cache invalidation (hardening (c)): drop only
4542    /// entries whose footprint intersects a deleted RowId or whose
4543    /// condition-columns intersect a mutated column, then clear the pending
4544    /// sets. Called by `commit` and the cross-table transaction path.
4545    pub(crate) fn invalidate_pending_cache(&mut self) {
4546        self.result_cache
4547            .lock()
4548            .invalidate(&self.pending_delete_rids, &self.pending_put_cols);
4549        self.pending_delete_rids.clear();
4550        self.pending_put_cols.clear();
4551    }
4552
4553    pub(crate) fn persist_manifest(&self, epoch: Epoch) -> Result<()> {
4554        let mut m = Manifest::new(self.table_id, self.schema.schema_id);
4555        m.current_epoch = epoch.0;
4556        m.next_row_id = self.allocator.current().0;
4557        m.runs = self.run_refs.clone();
4558        m.live_count = self.live_count;
4559        m.global_idx_epoch = self.global_idx_epoch;
4560        m.flushed_epoch = self.flushed_epoch;
4561        m.retiring = self.retiring.clone();
4562        // Persist the authoritative counter only when seeded; otherwise write 0
4563        // so the next open still scans `max(PK)` on first use (an unseeded
4564        // lower bound from WAL replay is not safe to trust across a flush).
4565        m.auto_inc_next = match self.auto_inc {
4566            Some(ai) if ai.seeded => ai.next,
4567            _ => 0,
4568        };
4569        m.ttl = self.ttl;
4570        let meta_dek = self.manifest_meta_dek();
4571        match self._root_guard.as_deref() {
4572            Some(root) => manifest::write_durable(root, &mut m, meta_dek.as_ref())?,
4573            None => manifest::write_atomic(&self.dir, &mut m, meta_dek.as_ref())?,
4574        }
4575        Ok(())
4576    }
4577
4578    pub(crate) fn plan_recovered_metadata(&mut self) -> Result<RecoveryMetadataPlan> {
4579        // `live_count` tracks logical tombstones, not wall-clock TTL expiry.
4580        // Use a time before every representable timestamp so TTL cannot hide a
4581        // row while rebuilding authoritative manifest metadata.
4582        let rows = self.visible_rows_at_time(Snapshot::unbounded(), i64::MIN)?;
4583        let live_count = u64::try_from(rows.len())
4584            .map_err(|_| MongrelError::Full("table live-row count exceeds u64".into()))?;
4585        let auto_inc = match self.auto_inc {
4586            Some(mut state) => {
4587                let maximum = self.scan_max_int64(state.column_id)?;
4588                let after_maximum = maximum.checked_add(1).ok_or_else(|| {
4589                    MongrelError::Full("AUTO_INCREMENT namespace exhausted".into())
4590                })?;
4591                state.next = state.next.max(after_maximum).max(1);
4592                state.seeded = true;
4593                Some(state)
4594            }
4595            None => None,
4596        };
4597        Ok(RecoveryMetadataPlan {
4598            live_count,
4599            auto_inc,
4600            changed: live_count != self.live_count
4601                || auto_inc.is_some_and(|planned| {
4602                    self.auto_inc.is_none_or(|current| {
4603                        current.next != planned.next || current.seeded != planned.seeded
4604                    })
4605                }),
4606        })
4607    }
4608
4609    pub(crate) fn apply_recovered_metadata(
4610        &mut self,
4611        plan: RecoveryMetadataPlan,
4612        epoch: Epoch,
4613    ) -> Result<()> {
4614        if !plan.changed {
4615            return Ok(());
4616        }
4617        self.live_count = plan.live_count;
4618        self.auto_inc = plan.auto_inc;
4619        self.persist_manifest(epoch)
4620    }
4621
4622    /// Checkpoint the in-memory secondary indexes to `_idx/global.idx` and stamp
4623    /// the manifest's `global_idx_epoch` (Phase 9.1). Call after the runs are
4624    /// stable and the memtable is drained (flush/bulk-load/compact) so the
4625    /// checkpoint exactly matches the run data; subsequent [`Table::open`] loads it
4626    /// directly instead of scanning every run.
4627    pub(crate) fn checkpoint_indexes(&mut self, epoch: Epoch) {
4628        // Never persist an incomplete index set (e.g. after bulk_load_columns,
4629        // which bypasses per-row indexing) — reopen rebuilds from the runs.
4630        if !self.indexes_complete {
4631            return;
4632        }
4633        // FND-006: a fired fault behaves like a failed checkpoint — the write
4634        // is best-effort and the next open simply rebuilds from the runs.
4635        if crate::catalog::inject_hook("index.publish.before").is_err() {
4636            return;
4637        }
4638        if self.idx_root.is_none() {
4639            if let Some(root) = self._root_guard.as_ref() {
4640                let Ok(idx_root) = root.create_directory_all_pinned(global_idx::IDX_DIR) else {
4641                    return;
4642                };
4643                self.idx_root = Some(Arc::new(idx_root));
4644            }
4645        }
4646        let snap = global_idx::IndexSnapshot {
4647            hot: &self.hot,
4648            bitmap: &self.bitmap,
4649            ann: &self.ann,
4650            fm: &self.fm,
4651            sparse: &self.sparse,
4652            minhash: &self.minhash,
4653            learned_range: &self.learned_range,
4654        };
4655        // Best-effort: a failed checkpoint just means the next open rebuilds.
4656        let idx_dek = self.idx_dek();
4657        let written = match self.idx_root.as_deref() {
4658            Some(root) => global_idx::write_atomic_root(
4659                root,
4660                self.table_id,
4661                epoch.0,
4662                snap,
4663                idx_dek.as_deref(),
4664            ),
4665            None => global_idx::write_atomic(
4666                &self.dir,
4667                self.table_id,
4668                epoch.0,
4669                snap,
4670                idx_dek.as_deref(),
4671            ),
4672        };
4673        if written.is_ok() {
4674            self.global_idx_epoch = epoch.0;
4675            let _ = self.persist_manifest(epoch);
4676            // FND-006: the index generation is published.
4677            let _ = crate::catalog::inject_hook("index.publish.after");
4678        }
4679    }
4680
4681    /// Drop any on-disk index checkpoint so the next open rebuilds from runs
4682    /// (used when the live indexes are known stale, e.g. compaction to empty).
4683    pub(crate) fn invalidate_index_checkpoint(&mut self) {
4684        self.global_idx_epoch = 0;
4685        if let Some(root) = self.idx_root.as_deref() {
4686            let _ = root.remove_file(global_idx::IDX_FILENAME);
4687        } else {
4688            global_idx::remove(&self.dir);
4689        }
4690        let _ = self.persist_manifest(self.epoch.visible());
4691    }
4692
4693    /// Prepare for replacing every run without publishing a second manifest.
4694    /// The caller persists the replacement topology after this returns.  An
4695    /// older checkpoint may remain on disk if deletion fails, but a manifest
4696    /// with `global_idx_epoch = 0` will never endorse it on reopen.
4697    pub(crate) fn prepare_indexes_for_run_replacement(&mut self) {
4698        self.indexes_complete = false;
4699        self.global_idx_epoch = 0;
4700        if let Some(root) = self.idx_root.as_deref() {
4701            let _ = root.remove_file(global_idx::IDX_FILENAME);
4702        } else {
4703            global_idx::remove(&self.dir);
4704        }
4705    }
4706
4707    pub(crate) fn finish_indexes_for_run_replacement(&mut self) {
4708        self.indexes_complete = true;
4709    }
4710
4711    /// A maintenance operation changed live run topology and could not prove
4712    /// the matching manifest publication.  Fail closed until recovery rebuilds
4713    /// one coherent view from durable state.  Mounted tables also poison their
4714    /// owning database so GC, DDL, and transactions cannot continue around the
4715    /// uncertain topology.
4716    pub(crate) fn poison_after_maintenance_publish_failure(&mut self) {
4717        self.durable_commit_failed = true;
4718        if let WalSink::Shared(shared) = &self.wal {
4719            shared
4720                .poisoned
4721                .store(true, std::sync::atomic::Ordering::Relaxed);
4722        }
4723    }
4724
4725    /// Invalidate a stale handle after DOCTOR has durably dropped its catalog
4726    /// entry. Other tables remain usable, but this handle must never append new
4727    /// writes for the quarantined table id.
4728    pub(crate) fn mark_unavailable_after_quarantine(&mut self) {
4729        self.durable_commit_failed = true;
4730    }
4731
4732    /// Read the row at `row_id` visible to `snapshot`, merging the newest
4733    /// version across the memtable, mutable-run tier, and all sorted runs.
4734    ///
4735    /// In-memory tiers use full-[`Snapshot`] HLC visibility (P0.5-T3). Sorted
4736    /// runs restore optional [`crate::sorted_run::SYS_COMMIT_TS`] when present
4737    /// and fall back to epoch-only for legacy runs; candidates are filtered
4738    /// with [`Snapshot::observes_row`] so HLC-stamped versions never win under
4739    /// an epoch-only pin.
4740    pub fn get(&self, row_id: RowId, snapshot: Snapshot) -> Option<Row> {
4741        let mut best: Option<Row> = None;
4742        let mut consider = |row: Row| {
4743            if !snapshot.observes_row(row.committed_epoch, row.commit_ts) {
4744                return;
4745            }
4746            if best.as_ref().is_none_or(|current| {
4747                Snapshot::version_is_newer(
4748                    row.committed_epoch,
4749                    row.commit_ts,
4750                    current.committed_epoch,
4751                    current.commit_ts,
4752                )
4753            }) {
4754                best = Some(row);
4755            }
4756        };
4757        if let Some((_, row)) = self.memtable.get_version_at(row_id, snapshot) {
4758            consider(row);
4759        }
4760        if let Some((_, row)) = self.mutable_run.get_version_at(row_id, snapshot) {
4761            consider(row);
4762        }
4763        for rr in &self.run_refs {
4764            let Ok(mut reader) = self.open_reader(rr.run_id) else {
4765                continue;
4766            };
4767            // P0.5-T3: run materialisation restores SYS_COMMIT_TS when present;
4768            // legacy runs without the column fall back to epoch visibility.
4769            let Ok(Some((_, row))) = reader.get_version(row_id, snapshot.epoch) else {
4770                continue;
4771            };
4772            consider(row);
4773        }
4774        let now_nanos = unix_nanos_now();
4775        match best {
4776            Some(r) if r.deleted || self.row_expired_at(&r, now_nanos) => None,
4777            Some(r) => Some(r),
4778            None => None,
4779        }
4780    }
4781
4782    /// All rows visible at `snapshot` (newest version per `RowId`, tombstones
4783    /// dropped), merged across the memtable, the mutable-run tier, and all
4784    /// runs. Ascending `RowId`.
4785    pub fn visible_rows(&self, snapshot: Snapshot) -> Result<Vec<Row>> {
4786        self.visible_rows_at_time(snapshot, unix_nanos_now())
4787    }
4788
4789    /// Materialize visible rows with cooperative checkpoints while merging
4790    /// page-bounded, already ordered tier cursors.
4791    #[doc(hidden)]
4792    pub fn visible_rows_controlled(
4793        &self,
4794        snapshot: Snapshot,
4795        control: &crate::ExecutionControl,
4796    ) -> Result<Vec<Row>> {
4797        let mut out = Vec::new();
4798        self.for_each_visible_row_controlled(snapshot, control, |row| {
4799            out.push(row);
4800            Ok(())
4801        })?;
4802        Ok(out)
4803    }
4804
4805    /// Visit visible rows in row-id order with a k-way merge over ordered tier
4806    /// cursors. No full-table merge map or row-id sort is constructed.
4807    #[doc(hidden)]
4808    pub fn for_each_visible_row_controlled<F>(
4809        &self,
4810        snapshot: Snapshot,
4811        control: &crate::ExecutionControl,
4812        mut visit: F,
4813    ) -> Result<()>
4814    where
4815        F: FnMut(Row) -> Result<()>,
4816    {
4817        let mut sources = Vec::with_capacity(self.run_refs.len() + 2);
4818        control.checkpoint()?;
4819        // Pass the full snapshot so HLC-stamped memtable versions are observed.
4820        let memtable = self.memtable.visible_versions_at(snapshot);
4821        if !memtable.is_empty() {
4822            sources.push(ControlledVisibleSource::memory(memtable));
4823        }
4824        control.checkpoint()?;
4825        // Mutable-run is HLC-aware (P0.5-T3); still re-check observes_row for
4826        // epoch-keyed sorted-run materialisation below.
4827        let mutable = self.mutable_run.visible_versions_at(snapshot);
4828        if !mutable.is_empty() {
4829            sources.push(ControlledVisibleSource::memory(mutable));
4830        }
4831        for run in &self.run_refs {
4832            control.checkpoint()?;
4833            let reader = self.open_reader(run.run_id)?;
4834            // Residual: sorted runs are epoch-keyed on disk (no commit_ts column).
4835            sources.push(ControlledVisibleSource::run(
4836                reader.into_visible_version_cursor(snapshot.epoch)?,
4837            ));
4838        }
4839        let now_nanos = unix_nanos_now();
4840        merge_controlled_visible_sources(
4841            &mut sources,
4842            control,
4843            |row| self.row_expired_at(row, now_nanos),
4844            |row| {
4845                // Epoch-keyed sorted runs can surface versions an epoch-only
4846                // snapshot must not observe under dual authority (P0.5-T7).
4847                if !snapshot.observes_row(row.committed_epoch, row.commit_ts) {
4848                    return Ok(());
4849                }
4850                visit(row)
4851            },
4852        )
4853    }
4854
4855    #[doc(hidden)]
4856    pub fn visible_rows_at_time(&self, snapshot: Snapshot, now_nanos: i64) -> Result<Vec<Row>> {
4857        let mut best: HashMap<u64, Row> = HashMap::new();
4858        let mut fold = |row: Row| {
4859            if !snapshot.observes_row(row.committed_epoch, row.commit_ts) {
4860                return;
4861            }
4862            best.entry(row.row_id.0)
4863                .and_modify(|existing| {
4864                    if Snapshot::version_is_newer(
4865                        row.committed_epoch,
4866                        row.commit_ts,
4867                        existing.committed_epoch,
4868                        existing.commit_ts,
4869                    ) {
4870                        *existing = row.clone();
4871                    }
4872                })
4873                .or_insert(row);
4874        };
4875        for row in self.memtable.visible_versions_at(snapshot) {
4876            fold(row);
4877        }
4878        for row in self.mutable_run.visible_versions_at(snapshot) {
4879            fold(row);
4880        }
4881        for rr in &self.run_refs {
4882            let mut reader = self.open_reader(rr.run_id)?;
4883            // P0.5-T3: optional SYS_COMMIT_TS restored when present on the run.
4884            for row in reader.visible_versions(snapshot.epoch)? {
4885                fold(row);
4886            }
4887        }
4888        let mut out: Vec<Row> = best
4889            .into_values()
4890            .filter_map(|r| {
4891                if r.deleted || self.row_expired_at(&r, now_nanos) {
4892                    None
4893                } else {
4894                    Some(r)
4895                }
4896            })
4897            .collect();
4898        out.sort_by_key(|r| r.row_id);
4899        Ok(out)
4900    }
4901
4902    /// Visible data as columns (column_id → values) rather than rows — the
4903    /// vectorized scan path. Fast path: when the memtable is empty and there is
4904    /// exactly one run (the common post-flush analytical case), it computes the
4905    /// visible index set once and gathers each column, with **no per-row
4906    /// `HashMap`/`Row` materialization**. Falls back to [`Self::visible_rows`]
4907    /// pivoted to columns when the memtable is live or runs overlap.
4908    pub fn visible_columns(&self, snapshot: Snapshot) -> Result<Vec<(u16, Vec<Value>)>> {
4909        if self.ttl.is_none()
4910            && self.memtable.is_empty()
4911            && self.mutable_run.is_empty()
4912            && self.run_refs.len() == 1
4913        {
4914            let rr = self.run_refs[0].clone();
4915            let mut reader = self.open_reader(rr.run_id)?;
4916            let idxs = reader.visible_indices(snapshot.epoch)?;
4917            let mut cols = Vec::with_capacity(self.schema.columns.len());
4918            for cdef in &self.schema.columns {
4919                cols.push((cdef.id, reader.gather_column(cdef.id, &idxs)?));
4920            }
4921            return Ok(cols);
4922        }
4923        // Fallback: row merge, then pivot to columns.
4924        let rows = self.visible_rows(snapshot)?;
4925        let mut cols: Vec<(u16, Vec<Value>)> = self
4926            .schema
4927            .columns
4928            .iter()
4929            .map(|c| (c.id, Vec::with_capacity(rows.len())))
4930            .collect();
4931        for r in &rows {
4932            for (cid, vec) in cols.iter_mut() {
4933                vec.push(r.columns.get(cid).cloned().unwrap_or(Value::Null));
4934            }
4935        }
4936        Ok(cols)
4937    }
4938
4939    /// Resolve a primary-key value to a row id (latest version).
4940    pub fn lookup_pk(&self, key: &[u8]) -> Option<RowId> {
4941        let row_id = self.hot.get(key)?;
4942        if self.ttl.is_none() || self.get(row_id, Snapshot::unbounded()).is_some() {
4943            Some(row_id)
4944        } else {
4945            None
4946        }
4947    }
4948
4949    /// Run a conjunctive query over the shared row-id space: each condition
4950    /// yields a candidate row-id set, the sets are intersected, and the
4951    /// survivors are materialized at the current snapshot. This is the AI-native
4952    /// "compose primitives" surface (`semsearch ∩ fm_contains ∩ cat_in`).
4953    pub fn query(&mut self, q: &crate::query::Query) -> Result<Vec<Row>> {
4954        self.query_at_with_allowed(q, self.snapshot(), None)
4955    }
4956
4957    /// Run a native conjunctive query with cooperative cancellation through
4958    /// index resolution, scans, filtering, and row materialization.
4959    pub fn query_controlled(
4960        &mut self,
4961        q: &crate::query::Query,
4962        control: &crate::ExecutionControl,
4963    ) -> Result<Vec<Row>> {
4964        self.query_at_with_allowed_controlled(q, self.snapshot(), None, control)
4965    }
4966
4967    /// Execute a conjunctive query at one snapshot, applying authorization
4968    /// before ranked ANN, Sparse, and MinHash top-k selection.
4969    pub fn query_at_with_allowed(
4970        &mut self,
4971        q: &crate::query::Query,
4972        snapshot: Snapshot,
4973        allowed: Option<&std::collections::HashSet<RowId>>,
4974    ) -> Result<Vec<Row>> {
4975        self.query_at_with_allowed_after(q, snapshot, allowed, None)
4976    }
4977
4978    #[doc(hidden)]
4979    pub fn query_at_with_allowed_controlled(
4980        &mut self,
4981        q: &crate::query::Query,
4982        snapshot: Snapshot,
4983        allowed: Option<&std::collections::HashSet<RowId>>,
4984        control: &crate::ExecutionControl,
4985    ) -> Result<Vec<Row>> {
4986        self.require_select()?;
4987        self.ensure_indexes_complete_controlled(control, || true)?;
4988        self.validate_native_query(q)?;
4989        self.query_conditions_at(
4990            &q.conditions,
4991            snapshot,
4992            allowed,
4993            q.limit,
4994            q.offset,
4995            None,
4996            unix_nanos_now(),
4997            Some(control),
4998        )
4999    }
5000
5001    #[doc(hidden)]
5002    pub fn query_at_with_allowed_after(
5003        &mut self,
5004        q: &crate::query::Query,
5005        snapshot: Snapshot,
5006        allowed: Option<&std::collections::HashSet<RowId>>,
5007        after_row_id: Option<RowId>,
5008    ) -> Result<Vec<Row>> {
5009        self.query_at_with_allowed_after_at_time(
5010            q,
5011            snapshot,
5012            allowed,
5013            after_row_id,
5014            unix_nanos_now(),
5015        )
5016    }
5017
5018    #[doc(hidden)]
5019    pub fn query_at_with_allowed_after_at_time(
5020        &mut self,
5021        q: &crate::query::Query,
5022        snapshot: Snapshot,
5023        allowed: Option<&std::collections::HashSet<RowId>>,
5024        after_row_id: Option<RowId>,
5025        query_time_nanos: i64,
5026    ) -> Result<Vec<Row>> {
5027        self.require_select()?;
5028        self.ensure_indexes_complete()?;
5029        self.validate_native_query(q)?;
5030        self.query_conditions_at(
5031            &q.conditions,
5032            snapshot,
5033            allowed,
5034            q.limit,
5035            q.offset,
5036            after_row_id,
5037            query_time_nanos,
5038            None,
5039        )
5040    }
5041
5042    fn validate_native_query(&self, q: &crate::query::Query) -> Result<()> {
5043        if q.conditions.len() > crate::query::MAX_HARD_CONDITIONS {
5044            return Err(MongrelError::InvalidArgument(format!(
5045                "query exceeds {} conditions",
5046                crate::query::MAX_HARD_CONDITIONS
5047            )));
5048        }
5049        if let Some(limit) = q.limit {
5050            if limit == 0 || limit > crate::query::MAX_FINAL_LIMIT {
5051                return Err(MongrelError::InvalidArgument(format!(
5052                    "query limit must be between 1 and {}",
5053                    crate::query::MAX_FINAL_LIMIT
5054                )));
5055            }
5056        }
5057        if q.offset > crate::query::MAX_QUERY_OFFSET {
5058            return Err(MongrelError::InvalidArgument(format!(
5059                "query offset exceeds {}",
5060                crate::query::MAX_QUERY_OFFSET
5061            )));
5062        }
5063        Ok(())
5064    }
5065
5066    /// Unbounded internal SQL join helper. Public request surfaces must use
5067    /// [`Self::query_at_with_allowed`] and its result ceiling.
5068    #[doc(hidden)]
5069    pub fn query_all_at(
5070        &mut self,
5071        conditions: &[crate::query::Condition],
5072        snapshot: Snapshot,
5073    ) -> Result<Vec<Row>> {
5074        self.require_select()?;
5075        self.ensure_indexes_complete()?;
5076        if conditions.len() > crate::query::MAX_HARD_CONDITIONS {
5077            return Err(MongrelError::InvalidArgument(format!(
5078                "query exceeds {} conditions",
5079                crate::query::MAX_HARD_CONDITIONS
5080            )));
5081        }
5082        self.query_conditions_at(
5083            conditions,
5084            snapshot,
5085            None,
5086            None,
5087            0,
5088            None,
5089            unix_nanos_now(),
5090            None,
5091        )
5092    }
5093
5094    #[allow(clippy::too_many_arguments)]
5095    fn query_conditions_at(
5096        &self,
5097        conditions: &[crate::query::Condition],
5098        snapshot: Snapshot,
5099        allowed: Option<&std::collections::HashSet<RowId>>,
5100        limit: Option<usize>,
5101        offset: usize,
5102        after_row_id: Option<RowId>,
5103        query_time_nanos: i64,
5104        control: Option<&crate::ExecutionControl>,
5105    ) -> Result<Vec<Row>> {
5106        control
5107            .map(crate::ExecutionControl::checkpoint)
5108            .transpose()?;
5109        crate::trace::QueryTrace::record(|t| {
5110            t.run_count = self.run_refs.len();
5111            t.memtable_rows = self.memtable.len();
5112            t.mutable_run_rows = self.mutable_run.len();
5113        });
5114        // A conjunction with no predicates matches every visible row (the
5115        // documented "Empty ⇒ all rows" contract); `intersect_sets` of zero
5116        // sets would otherwise wrongly yield the empty set.
5117        if conditions.is_empty() {
5118            crate::trace::QueryTrace::record(|t| {
5119                t.scan_mode = crate::trace::ScanMode::Materialized;
5120                t.row_materialized = true;
5121            });
5122            let mut rows = match control {
5123                Some(control) => self.visible_rows_controlled(snapshot, control)?,
5124                None => self.visible_rows_at_time(snapshot, query_time_nanos)?,
5125            };
5126            if let Some(allowed) = allowed {
5127                let mut filtered = Vec::with_capacity(rows.len());
5128                for (index, row) in rows.into_iter().enumerate() {
5129                    if index & 255 == 0 {
5130                        control
5131                            .map(crate::ExecutionControl::checkpoint)
5132                            .transpose()?;
5133                    }
5134                    if allowed.contains(&row.row_id) {
5135                        filtered.push(row);
5136                    }
5137                }
5138                rows = filtered;
5139            }
5140            if let Some(after_row_id) = after_row_id {
5141                rows.retain(|row| row.row_id > after_row_id);
5142            }
5143            rows.drain(..offset.min(rows.len()));
5144            if let Some(limit) = limit {
5145                rows.truncate(limit);
5146            }
5147            return Ok(rows);
5148        }
5149        crate::trace::QueryTrace::record(|t| {
5150            t.conditions_pushed = conditions.len();
5151            t.scan_mode = crate::trace::ScanMode::Materialized;
5152            t.row_materialized = true;
5153        });
5154        // §5.5: resolve conditions CHEAP-FIRST and early-exit the moment a
5155        // condition yields an empty survivor set. Previously every condition
5156        // (including an expensive range/FM page scan) was resolved before
5157        // `intersect_many` noticed an empty set; now a selective bitmap/PK that
5158        // eliminates all rows short-circuits the rest. Correctness is unchanged
5159        // (intersection with an empty set is empty either way).
5160        let mut ordered: Vec<&crate::query::Condition> = conditions.iter().collect();
5161        ordered.sort_by_key(|c| condition_cost_rank(c));
5162        let mut sets: Vec<RowIdSet> = Vec::with_capacity(ordered.len());
5163        for c in &ordered {
5164            control
5165                .map(crate::ExecutionControl::checkpoint)
5166                .transpose()?;
5167            let s = self.resolve_condition_with_allowed(c, snapshot, allowed)?;
5168            let empty = s.is_empty();
5169            sets.push(s);
5170            if empty {
5171                break;
5172            }
5173        }
5174        let mut rids = RowIdSet::intersect_many(sets).into_sorted_vec();
5175        if let Some(allowed) = allowed {
5176            rids.retain(|row_id| allowed.contains(&RowId(*row_id)));
5177        }
5178        if let Some(after_row_id) = after_row_id {
5179            let first = rids.partition_point(|row_id| *row_id <= after_row_id.0);
5180            rids.drain(..first);
5181        }
5182        rids.drain(..offset.min(rids.len()));
5183        if let Some(limit) = limit {
5184            rids.truncate(limit);
5185        }
5186        control
5187            .map(crate::ExecutionControl::checkpoint)
5188            .transpose()?;
5189        self.rows_for_rids_at_time(&rids, snapshot, query_time_nanos, control)
5190    }
5191
5192    /// Return an index's ordered candidates without discarding scores.
5193    pub fn retrieve(
5194        &mut self,
5195        retriever: &crate::query::Retriever,
5196    ) -> Result<Vec<crate::query::RetrieverHit>> {
5197        self.retrieve_with_allowed(retriever, None)
5198    }
5199
5200    pub fn retrieve_at(
5201        &mut self,
5202        retriever: &crate::query::Retriever,
5203        snapshot: Snapshot,
5204        allowed: Option<&std::collections::HashSet<RowId>>,
5205    ) -> Result<Vec<crate::query::RetrieverHit>> {
5206        self.retrieve_at_with_allowed(retriever, snapshot, allowed)
5207    }
5208
5209    /// Scored retrieval restricted to caller-authorized row IDs. Core MVCC,
5210    /// tombstone, and TTL eligibility is always applied before ranking.
5211    pub fn retrieve_with_allowed(
5212        &mut self,
5213        retriever: &crate::query::Retriever,
5214        allowed: Option<&std::collections::HashSet<RowId>>,
5215    ) -> Result<Vec<crate::query::RetrieverHit>> {
5216        self.retrieve_at_with_allowed(retriever, self.snapshot(), allowed)
5217    }
5218
5219    pub fn retrieve_at_with_allowed(
5220        &mut self,
5221        retriever: &crate::query::Retriever,
5222        snapshot: Snapshot,
5223        allowed: Option<&std::collections::HashSet<RowId>>,
5224    ) -> Result<Vec<crate::query::RetrieverHit>> {
5225        self.retrieve_at_with_allowed_and_context(retriever, snapshot, allowed, None)
5226    }
5227
5228    pub fn retrieve_at_with_allowed_and_context(
5229        &mut self,
5230        retriever: &crate::query::Retriever,
5231        snapshot: Snapshot,
5232        allowed: Option<&std::collections::HashSet<RowId>>,
5233        context: Option<&crate::query::AiExecutionContext>,
5234    ) -> Result<Vec<crate::query::RetrieverHit>> {
5235        self.require_select()?;
5236        self.ensure_indexes_complete()?;
5237        self.validate_retriever(retriever)?;
5238        self.retrieve_filtered(retriever, snapshot, None, allowed, None, context)
5239    }
5240
5241    pub fn retrieve_at_with_candidate_authorization_and_context(
5242        &mut self,
5243        retriever: &crate::query::Retriever,
5244        snapshot: Snapshot,
5245        authorization: Option<&crate::security::CandidateAuthorization<'_>>,
5246        context: Option<&crate::query::AiExecutionContext>,
5247    ) -> Result<Vec<crate::query::RetrieverHit>> {
5248        self.require_select()?;
5249        self.ensure_indexes_complete()?;
5250        self.retrieve_at_with_candidate_authorization_on_generation(
5251            retriever,
5252            snapshot,
5253            authorization,
5254            context,
5255        )
5256    }
5257
5258    #[doc(hidden)]
5259    pub fn retrieve_at_with_candidate_authorization_on_generation(
5260        &self,
5261        retriever: &crate::query::Retriever,
5262        snapshot: Snapshot,
5263        authorization: Option<&crate::security::CandidateAuthorization<'_>>,
5264        context: Option<&crate::query::AiExecutionContext>,
5265    ) -> Result<Vec<crate::query::RetrieverHit>> {
5266        self.require_select()?;
5267        self.validate_retriever(retriever)?;
5268        self.retrieve_filtered(retriever, snapshot, None, None, authorization, context)
5269    }
5270
5271    fn validate_retriever(&self, retriever: &crate::query::Retriever) -> Result<()> {
5272        use crate::query::{Retriever, MAX_RETRIEVER_K, MAX_SET_MEMBERS, MAX_SPARSE_TERMS};
5273        let (column_id, k) = match retriever {
5274            Retriever::Ann {
5275                column_id,
5276                query,
5277                k,
5278            } => {
5279                let index = self.ann.get(column_id).ok_or_else(|| {
5280                    MongrelError::InvalidArgument(format!("column {column_id} has no ANN index"))
5281                })?;
5282                if query.len() != index.dim() {
5283                    return Err(MongrelError::InvalidArgument(format!(
5284                        "ANN query dimension must be {}, got {}",
5285                        index.dim(),
5286                        query.len()
5287                    )));
5288                }
5289                if query.iter().any(|value| !value.is_finite()) {
5290                    return Err(MongrelError::InvalidArgument(
5291                        "ANN query values must be finite".into(),
5292                    ));
5293                }
5294                (*column_id, *k)
5295            }
5296            Retriever::Sparse {
5297                column_id,
5298                query,
5299                k,
5300            } => {
5301                if !self.sparse.contains_key(column_id) {
5302                    return Err(MongrelError::InvalidArgument(format!(
5303                        "column {column_id} has no Sparse index"
5304                    )));
5305                }
5306                if query.is_empty() || query.iter().any(|(_, weight)| !weight.is_finite()) {
5307                    return Err(MongrelError::InvalidArgument(
5308                        "Sparse query must be non-empty with finite weights".into(),
5309                    ));
5310                }
5311                if query.len() > MAX_SPARSE_TERMS {
5312                    return Err(MongrelError::InvalidArgument(format!(
5313                        "Sparse query exceeds {MAX_SPARSE_TERMS} terms"
5314                    )));
5315                }
5316                (*column_id, *k)
5317            }
5318            Retriever::MinHash {
5319                column_id,
5320                members,
5321                k,
5322            } => {
5323                if !self.minhash.contains_key(column_id) {
5324                    return Err(MongrelError::InvalidArgument(format!(
5325                        "column {column_id} has no MinHash index"
5326                    )));
5327                }
5328                if members.is_empty() {
5329                    return Err(MongrelError::InvalidArgument(
5330                        "MinHash members must not be empty".into(),
5331                    ));
5332                }
5333                if members.len() > MAX_SET_MEMBERS {
5334                    return Err(MongrelError::InvalidArgument(format!(
5335                        "MinHash query exceeds {MAX_SET_MEMBERS} members"
5336                    )));
5337                }
5338                let mut total_bytes = 0usize;
5339                for member in members {
5340                    let bytes = member.encoded_len();
5341                    if bytes > crate::query::MAX_SET_MEMBER_BYTES {
5342                        return Err(MongrelError::InvalidArgument(format!(
5343                            "MinHash member exceeds {} bytes",
5344                            crate::query::MAX_SET_MEMBER_BYTES
5345                        )));
5346                    }
5347                    total_bytes = total_bytes.checked_add(bytes).ok_or_else(|| {
5348                        MongrelError::InvalidArgument("MinHash input size overflow".into())
5349                    })?;
5350                }
5351                if total_bytes > crate::query::MAX_SET_INPUT_BYTES {
5352                    return Err(MongrelError::InvalidArgument(format!(
5353                        "MinHash input exceeds {} bytes",
5354                        crate::query::MAX_SET_INPUT_BYTES
5355                    )));
5356                }
5357                (*column_id, *k)
5358            }
5359        };
5360        if k == 0 {
5361            return Err(MongrelError::InvalidArgument(
5362                "retriever k must be > 0".into(),
5363            ));
5364        }
5365        if k > MAX_RETRIEVER_K {
5366            return Err(MongrelError::InvalidArgument(format!(
5367                "retriever k exceeds {MAX_RETRIEVER_K}"
5368            )));
5369        }
5370        debug_assert!(self
5371            .schema
5372            .columns
5373            .iter()
5374            .any(|column| column.id == column_id));
5375        Ok(())
5376    }
5377
5378    fn validate_condition(&self, condition: &crate::query::Condition) -> Result<()> {
5379        use crate::query::Condition;
5380        match condition {
5381            Condition::Ann {
5382                column_id,
5383                query,
5384                k,
5385            } => self.validate_retriever(&crate::query::Retriever::Ann {
5386                column_id: *column_id,
5387                query: query.clone(),
5388                k: *k,
5389            }),
5390            Condition::SparseMatch {
5391                column_id,
5392                query,
5393                k,
5394            } => self.validate_retriever(&crate::query::Retriever::Sparse {
5395                column_id: *column_id,
5396                query: query.clone(),
5397                k: *k,
5398            }),
5399            Condition::MinHashSimilar {
5400                column_id,
5401                query,
5402                k,
5403            } => {
5404                if !self.minhash.contains_key(column_id) {
5405                    return Err(MongrelError::InvalidArgument(format!(
5406                        "column {column_id} has no MinHash index"
5407                    )));
5408                }
5409                if query.is_empty() || *k == 0 {
5410                    return Err(MongrelError::InvalidArgument(
5411                        "MinHash query must be non-empty and k must be > 0".into(),
5412                    ));
5413                }
5414                if query.len() > crate::query::MAX_SET_MEMBERS || *k > crate::query::MAX_RETRIEVER_K
5415                {
5416                    return Err(MongrelError::InvalidArgument(format!(
5417                        "MinHash query must have <= {} members and k <= {}",
5418                        crate::query::MAX_SET_MEMBERS,
5419                        crate::query::MAX_RETRIEVER_K
5420                    )));
5421                }
5422                Ok(())
5423            }
5424            Condition::BitmapIn { values, .. } if values.len() > crate::query::MAX_SET_MEMBERS => {
5425                Err(MongrelError::InvalidArgument(format!(
5426                    "bitmap IN exceeds {} values",
5427                    crate::query::MAX_SET_MEMBERS
5428                )))
5429            }
5430            Condition::FmContainsAll { patterns, .. }
5431                if patterns.len() > crate::query::MAX_HARD_CONDITIONS =>
5432            {
5433                Err(MongrelError::InvalidArgument(format!(
5434                    "FM query exceeds {} patterns",
5435                    crate::query::MAX_HARD_CONDITIONS
5436                )))
5437            }
5438            _ => Ok(()),
5439        }
5440    }
5441
5442    fn retrieve_filtered(
5443        &self,
5444        retriever: &crate::query::Retriever,
5445        snapshot: Snapshot,
5446        hard_filter: Option<&RowIdSet>,
5447        allowed: Option<&std::collections::HashSet<RowId>>,
5448        candidate_authorization: Option<&crate::security::CandidateAuthorization<'_>>,
5449        context: Option<&crate::query::AiExecutionContext>,
5450    ) -> Result<Vec<crate::query::RetrieverHit>> {
5451        use crate::query::{Retriever, RetrieverHit, RetrieverScore};
5452        let started = std::time::Instant::now();
5453        let scored: Vec<(RowId, RetrieverScore)> = match retriever {
5454            Retriever::Ann {
5455                column_id,
5456                query,
5457                k,
5458            } => {
5459                let Some(index) = self.ann.get(column_id) else {
5460                    return Ok(Vec::new());
5461                };
5462                let cap = ann_candidate_cap(index.len(), context);
5463                if cap == 0 {
5464                    return Ok(Vec::new());
5465                }
5466                let mut breadth = (*k).max(1).min(cap);
5467                let mut eligibility = std::collections::HashMap::new();
5468                let mut filtered = loop {
5469                    let mut seen = std::collections::HashSet::new();
5470                    if let Some(context) = context {
5471                        context.checkpoint()?;
5472                    }
5473                    let raw = index.search_with_context(query, breadth, context)?;
5474                    let unchecked: Vec<_> = raw
5475                        .iter()
5476                        .map(|(row_id, _)| *row_id)
5477                        .filter(|row_id| !eligibility.contains_key(row_id))
5478                        .filter(|row_id| {
5479                            hard_filter.is_none_or(|filter| filter.contains(row_id.0))
5480                                && allowed.is_none_or(|allowed| allowed.contains(row_id))
5481                        })
5482                        .collect();
5483                    let eligible = self.eligible_and_authorized_candidate_ids(
5484                        &unchecked,
5485                        *column_id,
5486                        snapshot,
5487                        candidate_authorization,
5488                        context,
5489                    )?;
5490                    for row_id in unchecked {
5491                        eligibility.insert(row_id, eligible.contains(&row_id));
5492                    }
5493                    let filtered: Vec<_> = raw
5494                        .into_iter()
5495                        .filter(|(row_id, _)| {
5496                            seen.insert(*row_id)
5497                                && eligibility.get(row_id).copied().unwrap_or(false)
5498                        })
5499                        .map(|(row_id, score)| {
5500                            let score = match score {
5501                                crate::index::AnnDistance::Hamming(d) => {
5502                                    RetrieverScore::AnnHammingDistance(d)
5503                                }
5504                                crate::index::AnnDistance::Cosine(d) => {
5505                                    RetrieverScore::AnnCosineDistance(d)
5506                                }
5507                            };
5508                            (row_id, score)
5509                        })
5510                        .collect();
5511                    if filtered.len() >= *k || breadth >= cap {
5512                        if filtered.len() < *k && index.len() > cap && breadth >= cap {
5513                            crate::trace::QueryTrace::record(|trace| {
5514                                trace.ann_candidate_cap_hit = true;
5515                            });
5516                        }
5517                        break filtered;
5518                    }
5519                    breadth = breadth.saturating_mul(2).min(cap);
5520                };
5521                filtered.truncate(*k);
5522                filtered
5523            }
5524            Retriever::Sparse {
5525                column_id,
5526                query,
5527                k,
5528            } => self
5529                .sparse
5530                .get(column_id)
5531                .map(|index| -> Result<Vec<_>> {
5532                    let mut breadth = (*k).max(1);
5533                    let mut eligibility = std::collections::HashMap::new();
5534                    loop {
5535                        if let Some(context) = context {
5536                            context.checkpoint()?;
5537                        }
5538                        let raw = index.search_with_context(query, breadth, context)?;
5539                        let unchecked: Vec<_> = raw
5540                            .iter()
5541                            .map(|(row_id, _)| *row_id)
5542                            .filter(|row_id| !eligibility.contains_key(row_id))
5543                            .filter(|row_id| {
5544                                hard_filter.is_none_or(|filter| filter.contains(row_id.0))
5545                                    && allowed.is_none_or(|allowed| allowed.contains(row_id))
5546                            })
5547                            .collect();
5548                        let eligible = self.eligible_and_authorized_candidate_ids(
5549                            &unchecked,
5550                            *column_id,
5551                            snapshot,
5552                            candidate_authorization,
5553                            context,
5554                        )?;
5555                        for row_id in unchecked {
5556                            eligibility.insert(row_id, eligible.contains(&row_id));
5557                        }
5558                        let filtered: Vec<_> = raw
5559                            .iter()
5560                            .filter(|(row_id, _)| eligibility.get(row_id).copied().unwrap_or(false))
5561                            .take(*k)
5562                            .map(|(row_id, score)| {
5563                                (*row_id, RetrieverScore::SparseDotProduct(*score))
5564                            })
5565                            .collect();
5566                        if filtered.len() >= *k || raw.len() < breadth {
5567                            break Ok(filtered);
5568                        }
5569                        let next = breadth.saturating_mul(2);
5570                        if next == breadth {
5571                            break Ok(filtered);
5572                        }
5573                        breadth = next;
5574                    }
5575                })
5576                .transpose()?
5577                .unwrap_or_default(),
5578            Retriever::MinHash {
5579                column_id,
5580                members,
5581                k,
5582            } => self
5583                .minhash
5584                .get(column_id)
5585                .map(|index| -> Result<Vec<_>> {
5586                    let mut hashes = Vec::with_capacity(members.len());
5587                    for member in members {
5588                        if let Some(context) = context {
5589                            context.consume(crate::query::work_units(
5590                                member.encoded_len(),
5591                                crate::query::PARSE_WORK_QUANTUM,
5592                            ))?;
5593                        }
5594                        hashes.push(member.hash_v1());
5595                    }
5596                    let mut breadth = (*k).max(1);
5597                    let mut eligibility = std::collections::HashMap::new();
5598                    loop {
5599                        if let Some(context) = context {
5600                            context.checkpoint()?;
5601                        }
5602                        let raw = index.search_with_context(&hashes, breadth, context)?;
5603                        let unchecked: Vec<_> = raw
5604                            .iter()
5605                            .map(|(row_id, _)| *row_id)
5606                            .filter(|row_id| !eligibility.contains_key(row_id))
5607                            .filter(|row_id| {
5608                                hard_filter.is_none_or(|filter| filter.contains(row_id.0))
5609                                    && allowed.is_none_or(|allowed| allowed.contains(row_id))
5610                            })
5611                            .collect();
5612                        let eligible = self.eligible_and_authorized_candidate_ids(
5613                            &unchecked,
5614                            *column_id,
5615                            snapshot,
5616                            candidate_authorization,
5617                            context,
5618                        )?;
5619                        for row_id in unchecked {
5620                            eligibility.insert(row_id, eligible.contains(&row_id));
5621                        }
5622                        let filtered: Vec<_> = raw
5623                            .iter()
5624                            .filter(|(row_id, _)| eligibility.get(row_id).copied().unwrap_or(false))
5625                            .take(*k)
5626                            .map(|(row_id, score)| {
5627                                (*row_id, RetrieverScore::MinHashEstimatedJaccard(*score))
5628                            })
5629                            .collect();
5630                        if filtered.len() >= *k || raw.len() < breadth {
5631                            break Ok(filtered);
5632                        }
5633                        let next = breadth.saturating_mul(2);
5634                        if next == breadth {
5635                            break Ok(filtered);
5636                        }
5637                        breadth = next;
5638                    }
5639                })
5640                .transpose()?
5641                .unwrap_or_default(),
5642        };
5643        let elapsed = started.elapsed().as_nanos() as u64;
5644        crate::trace::QueryTrace::record(|trace| {
5645            match retriever {
5646                Retriever::Ann { .. } => {
5647                    trace.ann_candidate_nanos = trace.ann_candidate_nanos.saturating_add(elapsed);
5648                    if let Retriever::Ann { column_id, .. } = retriever {
5649                        if let Some(index) = self.ann.get(column_id) {
5650                            trace.ann_algorithm = Some(index.algorithm());
5651                            trace.ann_quantization = Some(index.quantization());
5652                            trace.ann_backend = Some(index.backend_name());
5653                        }
5654                    }
5655                }
5656                Retriever::Sparse { .. } => {
5657                    trace.sparse_candidate_nanos =
5658                        trace.sparse_candidate_nanos.saturating_add(elapsed)
5659                }
5660                Retriever::MinHash { .. } => {
5661                    trace.minhash_candidate_nanos =
5662                        trace.minhash_candidate_nanos.saturating_add(elapsed)
5663                }
5664            }
5665            trace.candidate_count = trace.candidate_count.saturating_add(scored.len());
5666        });
5667        Ok(scored
5668            .into_iter()
5669            .enumerate()
5670            .map(|(rank, (row_id, score))| RetrieverHit {
5671                row_id,
5672                rank: rank + 1,
5673                score,
5674            })
5675            .collect())
5676    }
5677
5678    fn eligible_candidate_ids(
5679        &self,
5680        candidates: &[RowId],
5681        _column_id: u16,
5682        snapshot: Snapshot,
5683        context: Option<&crate::query::AiExecutionContext>,
5684    ) -> Result<std::collections::HashSet<RowId>> {
5685        if !self.had_deletes
5686            && self.ttl.is_none()
5687            && self.pending_put_cols.is_empty()
5688            && snapshot.epoch == self.snapshot().epoch
5689        {
5690            return Ok(candidates.iter().copied().collect());
5691        }
5692        let mut readers: Vec<_> = self
5693            .run_refs
5694            .iter()
5695            .map(|run| self.open_reader(run.run_id))
5696            .collect::<Result<_>>()?;
5697        let now = context.map_or_else(unix_nanos_now, |context| context.query_time_nanos());
5698        let mut eligible = std::collections::HashSet::with_capacity(candidates.len());
5699        for &row_id in candidates {
5700            if let Some(context) = context {
5701                context.consume(1)?;
5702            }
5703            let mem = self.memtable.get_version_at(row_id, snapshot);
5704            let mutable = self.mutable_run.get_version_at(row_id, snapshot);
5705            let overlay = match (mem, mutable) {
5706                (Some(left), Some(right)) => Some(
5707                    if Snapshot::version_is_newer(
5708                        left.1.committed_epoch,
5709                        left.1.commit_ts,
5710                        right.1.committed_epoch,
5711                        right.1.commit_ts,
5712                    ) {
5713                        left
5714                    } else {
5715                        right
5716                    },
5717                ),
5718                (Some(value), None) | (None, Some(value)) => Some(value),
5719                (None, None) => None,
5720            };
5721            if let Some((_, row)) = overlay {
5722                if !row.deleted && !self.row_expired_at(&row, now) {
5723                    eligible.insert(row_id);
5724                }
5725                continue;
5726            }
5727            let mut best: Option<(Epoch, bool, usize)> = None;
5728            for (index, reader) in readers.iter_mut().enumerate() {
5729                if let Some((epoch, deleted)) =
5730                    reader.get_version_visibility(row_id, snapshot.epoch)?
5731                {
5732                    if best
5733                        .as_ref()
5734                        .map(|(best_epoch, ..)| epoch > *best_epoch)
5735                        .unwrap_or(true)
5736                    {
5737                        best = Some((epoch, deleted, index));
5738                    }
5739                }
5740            }
5741            let Some((_, false, reader_index)) = best else {
5742                continue;
5743            };
5744            if let Some(ttl) = self.ttl {
5745                if let Some((_, _, Some(Value::Int64(timestamp)))) = readers[reader_index]
5746                    .get_version_column(row_id, snapshot.epoch, ttl.column_id)?
5747                {
5748                    if timestamp.saturating_add(ttl.duration_nanos as i64) <= now {
5749                        continue;
5750                    }
5751                }
5752            }
5753            eligible.insert(row_id);
5754        }
5755        Ok(eligible)
5756    }
5757
5758    fn eligible_and_authorized_candidate_ids(
5759        &self,
5760        candidates: &[RowId],
5761        column_id: u16,
5762        snapshot: Snapshot,
5763        authorization: Option<&crate::security::CandidateAuthorization<'_>>,
5764        context: Option<&crate::query::AiExecutionContext>,
5765    ) -> Result<std::collections::HashSet<RowId>> {
5766        let eligible = self.eligible_candidate_ids(candidates, column_id, snapshot, context)?;
5767        let Some(authorization) = authorization else {
5768            return Ok(eligible);
5769        };
5770        let candidates: Vec<_> = eligible.into_iter().collect();
5771        self.policy_allowed_candidate_ids(&candidates, snapshot, authorization, context)
5772    }
5773
5774    fn policy_allowed_candidate_ids(
5775        &self,
5776        candidates: &[RowId],
5777        snapshot: Snapshot,
5778        authorization: &crate::security::CandidateAuthorization<'_>,
5779        context: Option<&crate::query::AiExecutionContext>,
5780    ) -> Result<std::collections::HashSet<RowId>> {
5781        let started = std::time::Instant::now();
5782        if candidates.is_empty()
5783            || authorization.principal.is_admin
5784            || !authorization.security.rls_enabled(authorization.table)
5785        {
5786            return Ok(candidates.iter().copied().collect());
5787        }
5788        if let Some(context) = context {
5789            context.checkpoint()?;
5790        }
5791        let row_ids: Vec<_> = candidates.iter().map(|row_id| row_id.0).collect();
5792        let mut rows: std::collections::HashMap<RowId, Row> = candidates
5793            .iter()
5794            .map(|row_id| {
5795                (
5796                    *row_id,
5797                    Row {
5798                        row_id: *row_id,
5799                        committed_epoch: snapshot.epoch,
5800                        commit_ts: None,
5801                        columns: std::collections::HashMap::new(),
5802                        deleted: false,
5803                    },
5804                )
5805            })
5806            .collect();
5807        let columns = authorization
5808            .security
5809            .select_policy_columns(authorization.table, authorization.principal);
5810        let query_now = context.map_or_else(unix_nanos_now, |context| context.query_time_nanos());
5811        let mut decoded = 0usize;
5812        for column_id in &columns {
5813            if let Some(context) = context {
5814                context.checkpoint()?;
5815            }
5816            for (row_id, value) in self.values_for_rids_batch_at_with_context(
5817                &row_ids, *column_id, snapshot, query_now, context,
5818            )? {
5819                if let Some(row) = rows.get_mut(&row_id) {
5820                    row.columns.insert(*column_id, value);
5821                    decoded = decoded.saturating_add(1);
5822                }
5823            }
5824        }
5825        if let Some(context) = context {
5826            context.consume(candidates.len().saturating_add(decoded))?;
5827        }
5828        let allowed = rows
5829            .into_values()
5830            .filter_map(|row| {
5831                authorization
5832                    .security
5833                    .row_allowed(
5834                        authorization.table,
5835                        crate::security::PolicyCommand::Select,
5836                        &row,
5837                        authorization.principal,
5838                        false,
5839                    )
5840                    .then_some(row.row_id)
5841            })
5842            .collect();
5843        crate::trace::QueryTrace::record(|trace| {
5844            trace.rls_rows_evaluated = trace.rls_rows_evaluated.saturating_add(candidates.len());
5845            trace.rls_policy_columns_decoded =
5846                trace.rls_policy_columns_decoded.saturating_add(decoded);
5847            trace.authorization_nanos = trace
5848                .authorization_nanos
5849                .saturating_add(started.elapsed().as_nanos() as u64);
5850        });
5851        Ok(allowed)
5852    }
5853
5854    /// Filter-aware union and reciprocal-rank fusion over scored retrievers.
5855    pub fn search(
5856        &mut self,
5857        request: &crate::query::SearchRequest,
5858    ) -> Result<Vec<crate::query::SearchHit>> {
5859        self.search_with_allowed(request, None)
5860    }
5861
5862    pub fn search_at(
5863        &mut self,
5864        request: &crate::query::SearchRequest,
5865        snapshot: Snapshot,
5866        authorized: Option<&std::collections::HashSet<RowId>>,
5867    ) -> Result<Vec<crate::query::SearchHit>> {
5868        self.search_at_with_allowed(request, snapshot, authorized)
5869    }
5870
5871    pub fn search_with_allowed(
5872        &mut self,
5873        request: &crate::query::SearchRequest,
5874        authorized: Option<&std::collections::HashSet<RowId>>,
5875    ) -> Result<Vec<crate::query::SearchHit>> {
5876        self.search_at_with_allowed(request, self.snapshot(), authorized)
5877    }
5878
5879    pub fn search_at_with_allowed(
5880        &mut self,
5881        request: &crate::query::SearchRequest,
5882        snapshot: Snapshot,
5883        authorized: Option<&std::collections::HashSet<RowId>>,
5884    ) -> Result<Vec<crate::query::SearchHit>> {
5885        self.search_at_with_allowed_and_context(request, snapshot, authorized, None)
5886    }
5887
5888    pub fn search_at_with_allowed_and_context(
5889        &mut self,
5890        request: &crate::query::SearchRequest,
5891        snapshot: Snapshot,
5892        authorized: Option<&std::collections::HashSet<RowId>>,
5893        context: Option<&crate::query::AiExecutionContext>,
5894    ) -> Result<Vec<crate::query::SearchHit>> {
5895        self.ensure_indexes_complete()?;
5896        self.search_at_with_filters_and_context(request, snapshot, authorized, None, context, None)
5897    }
5898
5899    pub fn search_at_with_candidate_authorization_and_context(
5900        &mut self,
5901        request: &crate::query::SearchRequest,
5902        snapshot: Snapshot,
5903        authorization: Option<&crate::security::CandidateAuthorization<'_>>,
5904        context: Option<&crate::query::AiExecutionContext>,
5905    ) -> Result<Vec<crate::query::SearchHit>> {
5906        self.ensure_indexes_complete()?;
5907        self.search_at_with_filters_and_context(
5908            request,
5909            snapshot,
5910            None,
5911            authorization,
5912            context,
5913            None,
5914        )
5915    }
5916
5917    #[doc(hidden)]
5918    pub fn search_at_with_candidate_authorization_on_generation(
5919        &self,
5920        request: &crate::query::SearchRequest,
5921        snapshot: Snapshot,
5922        authorization: Option<&crate::security::CandidateAuthorization<'_>>,
5923        context: Option<&crate::query::AiExecutionContext>,
5924    ) -> Result<Vec<crate::query::SearchHit>> {
5925        self.search_at_with_filters_and_context(
5926            request,
5927            snapshot,
5928            None,
5929            authorization,
5930            context,
5931            None,
5932        )
5933    }
5934
5935    #[doc(hidden)]
5936    pub fn search_at_with_candidate_authorization_on_generation_after(
5937        &self,
5938        request: &crate::query::SearchRequest,
5939        snapshot: Snapshot,
5940        authorization: Option<&crate::security::CandidateAuthorization<'_>>,
5941        context: Option<&crate::query::AiExecutionContext>,
5942        after: Option<crate::query::SearchAfter>,
5943    ) -> Result<Vec<crate::query::SearchHit>> {
5944        self.search_at_with_filters_and_context(
5945            request,
5946            snapshot,
5947            None,
5948            authorization,
5949            context,
5950            after,
5951        )
5952    }
5953
5954    fn search_at_with_filters_and_context(
5955        &self,
5956        request: &crate::query::SearchRequest,
5957        snapshot: Snapshot,
5958        authorized: Option<&std::collections::HashSet<RowId>>,
5959        candidate_authorization: Option<&crate::security::CandidateAuthorization<'_>>,
5960        context: Option<&crate::query::AiExecutionContext>,
5961        after: Option<crate::query::SearchAfter>,
5962    ) -> Result<Vec<crate::query::SearchHit>> {
5963        use crate::query::{
5964            ComponentScore, Condition, Fusion, SearchHit, MAX_FINAL_LIMIT, MAX_HARD_CONDITIONS,
5965            MAX_PROJECTION_COLUMNS, MAX_RETRIEVERS, MAX_RETRIEVER_WEIGHT,
5966        };
5967        let total_started = std::time::Instant::now();
5968        let rank_offset = after.map_or(0, |after| after.returned_count);
5969        self.require_select()?;
5970        if request.limit == 0 {
5971            return Err(MongrelError::InvalidArgument(
5972                "search limit must be > 0".into(),
5973            ));
5974        }
5975        if request.limit > MAX_FINAL_LIMIT {
5976            return Err(MongrelError::InvalidArgument(format!(
5977                "search limit exceeds {MAX_FINAL_LIMIT}"
5978            )));
5979        }
5980        if after.is_some_and(|cursor| !cursor.final_score.is_finite()) {
5981            return Err(MongrelError::InvalidArgument(
5982                "search-after score must be finite".into(),
5983            ));
5984        }
5985        if request.retrievers.is_empty() {
5986            return Err(MongrelError::InvalidArgument(
5987                "search requires at least one retriever".into(),
5988            ));
5989        }
5990        if request.retrievers.len() > MAX_RETRIEVERS {
5991            return Err(MongrelError::InvalidArgument(format!(
5992                "search exceeds {MAX_RETRIEVERS} retrievers"
5993            )));
5994        }
5995        if request.must.len() > MAX_HARD_CONDITIONS {
5996            return Err(MongrelError::InvalidArgument(format!(
5997                "search exceeds {MAX_HARD_CONDITIONS} hard conditions"
5998            )));
5999        }
6000        for condition in &request.must {
6001            self.validate_condition(condition)?;
6002        }
6003        if request.must.iter().any(|condition| {
6004            matches!(
6005                condition,
6006                Condition::Ann { .. }
6007                    | Condition::SparseMatch { .. }
6008                    | Condition::MinHashSimilar { .. }
6009            )
6010        }) {
6011            return Err(MongrelError::InvalidArgument(
6012                "ranked ANN, Sparse, and MinHash conditions must be retrievers, not must filters"
6013                    .into(),
6014            ));
6015        }
6016        let mut names = std::collections::HashSet::new();
6017        for named in &request.retrievers {
6018            if named.name.is_empty()
6019                || named.name.len() > crate::query::MAX_RETRIEVER_NAME_BYTES
6020                || !names.insert(named.name.as_str())
6021            {
6022                return Err(MongrelError::InvalidArgument(format!(
6023                    "retriever names must be non-empty, unique, and at most {} UTF-8 bytes",
6024                    crate::query::MAX_RETRIEVER_NAME_BYTES
6025                )));
6026            }
6027            if !named.weight.is_finite()
6028                || named.weight < 0.0
6029                || named.weight > MAX_RETRIEVER_WEIGHT
6030            {
6031                return Err(MongrelError::InvalidArgument(format!(
6032                    "retriever weight must be finite, non-negative, and <= {MAX_RETRIEVER_WEIGHT}"
6033                )));
6034            }
6035            self.validate_retriever(&named.retriever)?;
6036        }
6037        let projection = request
6038            .projection
6039            .clone()
6040            .unwrap_or_else(|| self.schema.columns.iter().map(|column| column.id).collect());
6041        if projection.len() > MAX_PROJECTION_COLUMNS {
6042            return Err(MongrelError::InvalidArgument(format!(
6043                "projection exceeds {MAX_PROJECTION_COLUMNS} columns"
6044            )));
6045        }
6046        for column_id in &projection {
6047            if !self
6048                .schema
6049                .columns
6050                .iter()
6051                .any(|column| column.id == *column_id)
6052            {
6053                return Err(MongrelError::ColumnNotFound(column_id.to_string()));
6054            }
6055        }
6056        if let Some(crate::query::Rerank::ExactVector {
6057            embedding_column,
6058            query,
6059            candidate_limit,
6060            weight,
6061            ..
6062        }) = &request.rerank
6063        {
6064            if *candidate_limit < request.limit || *candidate_limit > crate::query::MAX_RETRIEVER_K
6065            {
6066                return Err(MongrelError::InvalidArgument(format!(
6067                    "rerank candidate_limit must be between search limit and {}",
6068                    crate::query::MAX_RETRIEVER_K
6069                )));
6070            }
6071            if !weight.is_finite() || *weight < 0.0 || *weight > MAX_RETRIEVER_WEIGHT {
6072                return Err(MongrelError::InvalidArgument(format!(
6073                    "rerank weight must be finite, non-negative, and <= {MAX_RETRIEVER_WEIGHT}"
6074                )));
6075            }
6076            let column = self
6077                .schema
6078                .columns
6079                .iter()
6080                .find(|column| column.id == *embedding_column)
6081                .ok_or_else(|| MongrelError::ColumnNotFound(embedding_column.to_string()))?;
6082            let crate::schema::TypeId::Embedding { dim } = column.ty else {
6083                return Err(MongrelError::InvalidArgument(format!(
6084                    "rerank column {embedding_column} is not an embedding"
6085                )));
6086            };
6087            if query.len() != dim as usize || query.iter().any(|value| !value.is_finite()) {
6088                return Err(MongrelError::InvalidArgument(format!(
6089                    "rerank query must contain {dim} finite values"
6090                )));
6091            }
6092        }
6093
6094        let hard_filter_started = std::time::Instant::now();
6095        let hard_filter = if request.must.is_empty() {
6096            None
6097        } else {
6098            let mut sets = Vec::with_capacity(request.must.len());
6099            for condition in &request.must {
6100                if let Some(context) = context {
6101                    context.checkpoint()?;
6102                }
6103                sets.push(self.resolve_condition(condition, snapshot)?);
6104            }
6105            Some(RowIdSet::intersect_many(sets))
6106        };
6107        crate::trace::QueryTrace::record(|trace| {
6108            trace.hard_filter_nanos = trace
6109                .hard_filter_nanos
6110                .saturating_add(hard_filter_started.elapsed().as_nanos() as u64);
6111        });
6112        if hard_filter.as_ref().is_some_and(RowIdSet::is_empty) {
6113            return Ok(Vec::new());
6114        }
6115
6116        let constant = match request.fusion {
6117            Fusion::ReciprocalRank { constant } => constant,
6118        };
6119        let mut retrievers: Vec<_> = request.retrievers.iter().collect();
6120        retrievers.sort_by(|a, b| a.name.cmp(&b.name));
6121        let mut fusion_nanos = 0u64;
6122        let mut fused: std::collections::HashMap<RowId, (f64, Vec<ComponentScore>)> =
6123            std::collections::HashMap::new();
6124        for named in retrievers {
6125            if named.weight == 0.0 {
6126                continue;
6127            }
6128            if let Some(context) = context {
6129                context.checkpoint()?;
6130            }
6131            let hits = self.retrieve_filtered(
6132                &named.retriever,
6133                snapshot,
6134                hard_filter.as_ref(),
6135                authorized,
6136                candidate_authorization,
6137                context,
6138            )?;
6139            let retriever_name: std::sync::Arc<str> = named.name.as_str().into();
6140            let fusion_started = std::time::Instant::now();
6141            for hit in hits {
6142                if let Some(context) = context {
6143                    context.consume(1)?;
6144                }
6145                let contribution = named.weight / (constant as f64 + hit.rank as f64);
6146                if !contribution.is_finite() {
6147                    return Err(MongrelError::InvalidArgument(
6148                        "retriever contribution must be finite".into(),
6149                    ));
6150                }
6151                let max_fused_candidates = context.map_or(
6152                    crate::query::MAX_FUSED_CANDIDATES,
6153                    crate::query::AiExecutionContext::max_fused_candidates,
6154                );
6155                if !fused.contains_key(&hit.row_id) && fused.len() >= max_fused_candidates {
6156                    return Err(MongrelError::WorkBudgetExceeded);
6157                }
6158                let entry = fused.entry(hit.row_id).or_default();
6159                entry.0 += contribution;
6160                if !entry.0.is_finite() {
6161                    return Err(MongrelError::InvalidArgument(
6162                        "fused score must be finite".into(),
6163                    ));
6164                }
6165                entry.1.push(ComponentScore {
6166                    retriever_name: retriever_name.clone(),
6167                    rank: hit.rank,
6168                    raw_score: hit.score,
6169                    contribution,
6170                });
6171            }
6172            fusion_nanos = fusion_nanos.saturating_add(fusion_started.elapsed().as_nanos() as u64);
6173        }
6174        let union_size = fused.len();
6175        let mut ranked: Vec<_> = fused
6176            .into_iter()
6177            .map(|(row_id, (fused_score, components))| {
6178                (row_id, fused_score, components, None, fused_score)
6179            })
6180            .collect();
6181        let order = |(a_row, _, _, _, a_score): &(
6182            RowId,
6183            f64,
6184            Vec<ComponentScore>,
6185            Option<f32>,
6186            f64,
6187        ),
6188                     (b_row, _, _, _, b_score): &(
6189            RowId,
6190            f64,
6191            Vec<ComponentScore>,
6192            Option<f32>,
6193            f64,
6194        )| { b_score.total_cmp(a_score).then_with(|| a_row.cmp(b_row)) };
6195        if let Some(crate::query::Rerank::ExactVector {
6196            embedding_column,
6197            query,
6198            metric,
6199            candidate_limit,
6200            weight,
6201        }) = &request.rerank
6202        {
6203            let fused_order = |(a_row, a_score, ..): &(
6204                RowId,
6205                f64,
6206                Vec<ComponentScore>,
6207                Option<f32>,
6208                f64,
6209            ),
6210                               (b_row, b_score, ..): &(
6211                RowId,
6212                f64,
6213                Vec<ComponentScore>,
6214                Option<f32>,
6215                f64,
6216            )| {
6217                b_score.total_cmp(a_score).then_with(|| a_row.cmp(b_row))
6218            };
6219            let selection_started = std::time::Instant::now();
6220            if let Some(context) = context {
6221                context.consume(ranked.len())?;
6222            }
6223            if ranked.len() > *candidate_limit {
6224                let (_, _, _) = ranked.select_nth_unstable_by(*candidate_limit, fused_order);
6225                ranked.truncate(*candidate_limit);
6226            }
6227            ranked.sort_by(fused_order);
6228            fusion_nanos =
6229                fusion_nanos.saturating_add(selection_started.elapsed().as_nanos() as u64);
6230            let row_ids: Vec<_> = ranked.iter().map(|(row_id, ..)| row_id.0).collect();
6231            if let Some(context) = context {
6232                context.consume(row_ids.len())?;
6233            }
6234            let query_now =
6235                context.map_or_else(unix_nanos_now, |context| context.query_time_nanos());
6236            let gather_started = std::time::Instant::now();
6237            let vectors = self.values_for_rids_batch_at_with_context(
6238                &row_ids,
6239                *embedding_column,
6240                snapshot,
6241                query_now,
6242                context,
6243            )?;
6244            let gather_nanos = gather_started.elapsed().as_nanos() as u64;
6245            let vector_work =
6246                crate::query::work_units(query.len(), crate::query::VECTOR_WORK_QUANTUM);
6247            let query_norm = if matches!(metric, crate::query::VectorMetric::Cosine) {
6248                if let Some(context) = context {
6249                    context.consume(vector_work)?;
6250                }
6251                query
6252                    .iter()
6253                    .map(|value| f64::from(*value).powi(2))
6254                    .sum::<f64>()
6255                    .sqrt()
6256            } else {
6257                0.0
6258            };
6259            let score_started = std::time::Instant::now();
6260            let mut scores = std::collections::HashMap::with_capacity(vectors.len());
6261            for (row_id, value) in vectors {
6262                if let Some(meta) = value.generated_embedding_metadata() {
6263                    if !crate::embedding_jobs::embedding_status_is_ann_eligible(meta.status) {
6264                        continue;
6265                    }
6266                }
6267                let Some(vector) = value.as_embedding() else {
6268                    continue;
6269                };
6270                let score = match metric {
6271                    crate::query::VectorMetric::DotProduct => {
6272                        if let Some(context) = context {
6273                            context.consume(vector_work)?;
6274                        }
6275                        query
6276                            .iter()
6277                            .zip(vector)
6278                            .map(|(left, right)| f64::from(*left) * f64::from(*right))
6279                            .sum::<f64>()
6280                    }
6281                    crate::query::VectorMetric::Cosine => {
6282                        if let Some(context) = context {
6283                            context.consume(vector_work.saturating_mul(2))?;
6284                        }
6285                        let dot = query
6286                            .iter()
6287                            .zip(vector)
6288                            .map(|(left, right)| f64::from(*left) * f64::from(*right))
6289                            .sum::<f64>();
6290                        let norm = vector
6291                            .iter()
6292                            .map(|value| f64::from(*value).powi(2))
6293                            .sum::<f64>()
6294                            .sqrt();
6295                        if query_norm == 0.0 || norm == 0.0 {
6296                            0.0
6297                        } else {
6298                            dot / (query_norm * norm)
6299                        }
6300                    }
6301                    crate::query::VectorMetric::Euclidean => {
6302                        if let Some(context) = context {
6303                            context.consume(vector_work)?;
6304                        }
6305                        query
6306                            .iter()
6307                            .zip(vector)
6308                            .map(|(left, right)| (f64::from(*left) - f64::from(*right)).powi(2))
6309                            .sum::<f64>()
6310                            .sqrt()
6311                    }
6312                };
6313                if !score.is_finite() {
6314                    return Err(MongrelError::InvalidArgument(
6315                        "exact rerank score must be finite".into(),
6316                    ));
6317                }
6318                scores.insert(row_id, score as f32);
6319            }
6320            let mut reranked = Vec::with_capacity(ranked.len());
6321            for (row_id, fused_score, components, _, _) in ranked.drain(..) {
6322                let Some(score) = scores.get(&row_id).copied() else {
6323                    continue;
6324                };
6325                let ordering_score = match metric {
6326                    crate::query::VectorMetric::Euclidean => -f64::from(score),
6327                    crate::query::VectorMetric::Cosine | crate::query::VectorMetric::DotProduct => {
6328                        f64::from(score)
6329                    }
6330                };
6331                let final_score = fused_score + *weight * ordering_score;
6332                if !final_score.is_finite() {
6333                    return Err(MongrelError::InvalidArgument(
6334                        "final rerank score must be finite".into(),
6335                    ));
6336                }
6337                reranked.push((row_id, fused_score, components, Some(score), final_score));
6338            }
6339            ranked = reranked;
6340            ranked.sort_by(order);
6341            crate::trace::QueryTrace::record(|trace| {
6342                trace.exact_vector_gather_nanos =
6343                    trace.exact_vector_gather_nanos.saturating_add(gather_nanos);
6344                trace.exact_vector_score_nanos = trace
6345                    .exact_vector_score_nanos
6346                    .saturating_add(score_started.elapsed().as_nanos() as u64);
6347            });
6348        }
6349        if let Some(after) = after {
6350            ranked.retain(|(row_id, _, _, _, final_score)| {
6351                final_score.total_cmp(&after.final_score).is_lt()
6352                    || (final_score.total_cmp(&after.final_score).is_eq() && *row_id > after.row_id)
6353            });
6354        }
6355        let projection_started = std::time::Instant::now();
6356        let sentinel = projection
6357            .first()
6358            .copied()
6359            .or_else(|| self.schema.columns.first().map(|column| column.id));
6360        let query_now = context.map_or_else(unix_nanos_now, |context| context.query_time_nanos());
6361        let mut out = Vec::with_capacity(request.limit.min(ranked.len()));
6362        let mut projection_rows = 0usize;
6363        let mut projection_cells = 0usize;
6364        while out.len() < request.limit && !ranked.is_empty() {
6365            if let Some(context) = context {
6366                context.checkpoint()?;
6367                context.consume(ranked.len())?;
6368            }
6369            let needed = request.limit - out.len();
6370            let window_size = ranked
6371                .len()
6372                .min(needed.saturating_mul(2).max(needed.saturating_add(8)));
6373            let selection_started = std::time::Instant::now();
6374            let mut remainder = if ranked.len() > window_size {
6375                let (_, _, _) = ranked.select_nth_unstable_by(window_size, order);
6376                ranked.split_off(window_size)
6377            } else {
6378                Vec::new()
6379            };
6380            ranked.sort_by(order);
6381            fusion_nanos =
6382                fusion_nanos.saturating_add(selection_started.elapsed().as_nanos() as u64);
6383            let row_ids: Vec<_> = ranked.iter().map(|(row_id, ..)| row_id.0).collect();
6384            let gathered_columns = projection.len().max(usize::from(sentinel.is_some()));
6385            if let Some(context) = context {
6386                context.consume(row_ids.len().saturating_mul(gathered_columns))?;
6387            }
6388            projection_rows = projection_rows.saturating_add(row_ids.len());
6389            projection_cells =
6390                projection_cells.saturating_add(row_ids.len().saturating_mul(gathered_columns));
6391            let mut cells: std::collections::HashMap<RowId, std::collections::HashMap<u16, Value>> =
6392                std::collections::HashMap::new();
6393            if let Some(column_id) = sentinel {
6394                for (row_id, value) in self.values_for_rids_batch_at_with_context(
6395                    &row_ids, column_id, snapshot, query_now, context,
6396                )? {
6397                    cells.entry(row_id).or_default().insert(column_id, value);
6398                }
6399            }
6400            for &column_id in &projection {
6401                if Some(column_id) == sentinel {
6402                    continue;
6403                }
6404                for (row_id, value) in self.values_for_rids_batch_at_with_context(
6405                    &row_ids, column_id, snapshot, query_now, context,
6406                )? {
6407                    cells.entry(row_id).or_default().insert(column_id, value);
6408                }
6409            }
6410            for (row_id, fused_score, mut components, exact_rerank_score, final_score) in
6411                ranked.drain(..)
6412            {
6413                let Some(row_cells) = cells.remove(&row_id) else {
6414                    continue;
6415                };
6416                components.sort_by(|a, b| a.retriever_name.cmp(&b.retriever_name));
6417                let final_rank = rank_offset.saturating_add(out.len()).saturating_add(1);
6418                out.push(SearchHit {
6419                    row_id,
6420                    cells: projection
6421                        .iter()
6422                        .filter_map(|column_id| {
6423                            row_cells
6424                                .get(column_id)
6425                                .cloned()
6426                                .map(|value| (*column_id, value))
6427                        })
6428                        .collect(),
6429                    components,
6430                    fused_score,
6431                    exact_rerank_score,
6432                    final_score,
6433                    final_rank,
6434                });
6435                if out.len() == request.limit {
6436                    break;
6437                }
6438            }
6439            ranked.append(&mut remainder);
6440        }
6441        crate::trace::QueryTrace::record(|trace| {
6442            trace.union_size = union_size;
6443            trace.fusion_nanos = trace.fusion_nanos.saturating_add(fusion_nanos);
6444            trace.projection_nanos = trace
6445                .projection_nanos
6446                .saturating_add(projection_started.elapsed().as_nanos() as u64);
6447            trace.total_nanos = trace
6448                .total_nanos
6449                .saturating_add(total_started.elapsed().as_nanos() as u64);
6450            trace.projection_rows = trace.projection_rows.saturating_add(projection_rows);
6451            trace.projection_cells = trace.projection_cells.saturating_add(projection_cells);
6452            if let Some(context) = context {
6453                trace.work_consumed = trace.work_consumed.saturating_add(context.consumed_work());
6454            }
6455        });
6456        Ok(out)
6457    }
6458
6459    /// MinHash candidate generation followed by exact Jaccard verification.
6460    /// An empty query set returns no hits.
6461    pub fn set_similarity(
6462        &mut self,
6463        request: &crate::query::SetSimilarityRequest,
6464    ) -> Result<Vec<crate::query::SetSimilarityHit>> {
6465        self.set_similarity_with_allowed(request, None)
6466    }
6467
6468    pub fn set_similarity_at(
6469        &mut self,
6470        request: &crate::query::SetSimilarityRequest,
6471        snapshot: Snapshot,
6472        allowed: Option<&std::collections::HashSet<RowId>>,
6473    ) -> Result<Vec<crate::query::SetSimilarityHit>> {
6474        self.set_similarity_explained_at(request, snapshot, allowed)
6475            .map(|(hits, _)| hits)
6476    }
6477
6478    /// Binary ANN candidate generation followed by exact float-vector reranking.
6479    pub fn ann_rerank(
6480        &mut self,
6481        request: &crate::query::AnnRerankRequest,
6482    ) -> Result<Vec<crate::query::AnnRerankHit>> {
6483        self.ann_rerank_with_allowed(request, None)
6484    }
6485
6486    pub fn ann_rerank_with_allowed(
6487        &mut self,
6488        request: &crate::query::AnnRerankRequest,
6489        allowed: Option<&std::collections::HashSet<RowId>>,
6490    ) -> Result<Vec<crate::query::AnnRerankHit>> {
6491        self.ann_rerank_at(request, self.snapshot(), allowed)
6492    }
6493
6494    pub fn ann_rerank_at(
6495        &mut self,
6496        request: &crate::query::AnnRerankRequest,
6497        snapshot: Snapshot,
6498        allowed: Option<&std::collections::HashSet<RowId>>,
6499    ) -> Result<Vec<crate::query::AnnRerankHit>> {
6500        self.ann_rerank_at_with_context(request, snapshot, allowed, None)
6501    }
6502
6503    pub fn ann_rerank_at_with_context(
6504        &mut self,
6505        request: &crate::query::AnnRerankRequest,
6506        snapshot: Snapshot,
6507        allowed: Option<&std::collections::HashSet<RowId>>,
6508        context: Option<&crate::query::AiExecutionContext>,
6509    ) -> Result<Vec<crate::query::AnnRerankHit>> {
6510        self.ensure_indexes_complete()?;
6511        self.ann_rerank_at_with_filters_and_context(request, snapshot, allowed, None, context)
6512    }
6513
6514    pub fn ann_rerank_at_with_candidate_authorization_and_context(
6515        &mut self,
6516        request: &crate::query::AnnRerankRequest,
6517        snapshot: Snapshot,
6518        authorization: Option<&crate::security::CandidateAuthorization<'_>>,
6519        context: Option<&crate::query::AiExecutionContext>,
6520    ) -> Result<Vec<crate::query::AnnRerankHit>> {
6521        self.ensure_indexes_complete()?;
6522        self.ann_rerank_at_with_filters_and_context(request, snapshot, None, authorization, context)
6523    }
6524
6525    #[doc(hidden)]
6526    pub fn ann_rerank_at_with_candidate_authorization_on_generation(
6527        &self,
6528        request: &crate::query::AnnRerankRequest,
6529        snapshot: Snapshot,
6530        authorization: Option<&crate::security::CandidateAuthorization<'_>>,
6531        context: Option<&crate::query::AiExecutionContext>,
6532    ) -> Result<Vec<crate::query::AnnRerankHit>> {
6533        self.ann_rerank_at_with_filters_and_context(request, snapshot, None, authorization, context)
6534    }
6535
6536    fn ann_rerank_at_with_filters_and_context(
6537        &self,
6538        request: &crate::query::AnnRerankRequest,
6539        snapshot: Snapshot,
6540        allowed: Option<&std::collections::HashSet<RowId>>,
6541        candidate_authorization: Option<&crate::security::CandidateAuthorization<'_>>,
6542        context: Option<&crate::query::AiExecutionContext>,
6543    ) -> Result<Vec<crate::query::AnnRerankHit>> {
6544        use crate::query::{
6545            AnnCandidateDistance, AnnRerankHit, Retriever, RetrieverScore, VectorMetric,
6546            MAX_FINAL_LIMIT, MAX_RETRIEVER_K,
6547        };
6548        if request.candidate_k == 0 || request.limit == 0 {
6549            return Err(MongrelError::InvalidArgument(
6550                "candidate_k and limit must be > 0".into(),
6551            ));
6552        }
6553        if request.candidate_k > MAX_RETRIEVER_K || request.limit > MAX_FINAL_LIMIT {
6554            return Err(MongrelError::InvalidArgument(format!(
6555                "candidate_k must be <= {MAX_RETRIEVER_K} and limit <= {MAX_FINAL_LIMIT}"
6556            )));
6557        }
6558        let retriever = Retriever::Ann {
6559            column_id: request.column_id,
6560            query: request.query.clone(),
6561            k: request.candidate_k,
6562        };
6563        self.require_select()?;
6564        self.validate_retriever(&retriever)?;
6565        let hits = self.retrieve_filtered(
6566            &retriever,
6567            snapshot,
6568            None,
6569            allowed,
6570            candidate_authorization,
6571            context,
6572        )?;
6573        let distances: std::collections::HashMap<_, _> = hits
6574            .iter()
6575            .filter_map(|hit| match hit.score {
6576                RetrieverScore::AnnHammingDistance(distance) => {
6577                    Some((hit.row_id, AnnCandidateDistance::Hamming(distance)))
6578                }
6579                RetrieverScore::AnnCosineDistance(distance) => {
6580                    Some((hit.row_id, AnnCandidateDistance::Cosine(distance)))
6581                }
6582                _ => None,
6583            })
6584            .collect();
6585        let row_ids: Vec<_> = hits.iter().map(|hit| hit.row_id.0).collect();
6586        if let Some(context) = context {
6587            context.consume(row_ids.len())?;
6588        }
6589        let gather_started = std::time::Instant::now();
6590        let query_now = context.map_or_else(unix_nanos_now, |context| context.query_time_nanos());
6591        let values = self.values_for_rids_batch_at_with_context(
6592            &row_ids,
6593            request.column_id,
6594            snapshot,
6595            query_now,
6596            context,
6597        )?;
6598        let gather_nanos = gather_started.elapsed().as_nanos() as u64;
6599        let score_started = std::time::Instant::now();
6600        let vector_work =
6601            crate::query::work_units(request.query.len(), crate::query::VECTOR_WORK_QUANTUM);
6602        let query_norm = if matches!(request.metric, VectorMetric::Cosine) {
6603            if let Some(context) = context {
6604                context.consume(vector_work)?;
6605            }
6606            request
6607                .query
6608                .iter()
6609                .map(|value| f64::from(*value).powi(2))
6610                .sum::<f64>()
6611                .sqrt()
6612        } else {
6613            0.0
6614        };
6615        let mut reranked = Vec::with_capacity(values.len().min(request.limit));
6616        for (row_id, value) in values {
6617            if let Some(meta) = value.generated_embedding_metadata() {
6618                if !crate::embedding_jobs::embedding_status_is_ann_eligible(meta.status) {
6619                    continue;
6620                }
6621            }
6622            let Some(vector) = value.as_embedding() else {
6623                continue;
6624            };
6625            let exact_score = match request.metric {
6626                VectorMetric::DotProduct => {
6627                    if let Some(context) = context {
6628                        context.consume(vector_work)?;
6629                    }
6630                    request
6631                        .query
6632                        .iter()
6633                        .zip(vector)
6634                        .map(|(left, right)| f64::from(*left) * f64::from(*right))
6635                        .sum::<f64>()
6636                }
6637                VectorMetric::Cosine => {
6638                    if let Some(context) = context {
6639                        context.consume(vector_work.saturating_mul(2))?;
6640                    }
6641                    let dot = request
6642                        .query
6643                        .iter()
6644                        .zip(vector)
6645                        .map(|(left, right)| f64::from(*left) * f64::from(*right))
6646                        .sum::<f64>();
6647                    let norm = vector
6648                        .iter()
6649                        .map(|value| f64::from(*value).powi(2))
6650                        .sum::<f64>()
6651                        .sqrt();
6652                    if query_norm == 0.0 || norm == 0.0 {
6653                        0.0
6654                    } else {
6655                        dot / (query_norm * norm)
6656                    }
6657                }
6658                VectorMetric::Euclidean => {
6659                    if let Some(context) = context {
6660                        context.consume(vector_work)?;
6661                    }
6662                    request
6663                        .query
6664                        .iter()
6665                        .zip(vector)
6666                        .map(|(left, right)| (f64::from(*left) - f64::from(*right)).powi(2))
6667                        .sum::<f64>()
6668                        .sqrt()
6669                }
6670            };
6671            let exact_score = exact_score as f32;
6672            if !exact_score.is_finite() {
6673                return Err(MongrelError::InvalidArgument(
6674                    "exact ANN score must be finite".into(),
6675                ));
6676            }
6677            let Some(candidate_distance) = distances.get(&row_id).copied() else {
6678                continue;
6679            };
6680            reranked.push(AnnRerankHit {
6681                row_id,
6682                candidate_distance,
6683                exact_score,
6684            });
6685        }
6686        reranked.sort_by(|left, right| {
6687            let score = match request.metric {
6688                VectorMetric::Euclidean => left.exact_score.total_cmp(&right.exact_score),
6689                VectorMetric::Cosine | VectorMetric::DotProduct => {
6690                    right.exact_score.total_cmp(&left.exact_score)
6691                }
6692            };
6693            score.then_with(|| left.row_id.cmp(&right.row_id))
6694        });
6695        reranked.truncate(request.limit);
6696        crate::trace::QueryTrace::record(|trace| {
6697            trace.exact_vector_gather_nanos =
6698                trace.exact_vector_gather_nanos.saturating_add(gather_nanos);
6699            trace.exact_vector_score_nanos = trace
6700                .exact_vector_score_nanos
6701                .saturating_add(score_started.elapsed().as_nanos() as u64);
6702        });
6703        Ok(reranked)
6704    }
6705
6706    pub fn set_similarity_with_allowed(
6707        &mut self,
6708        request: &crate::query::SetSimilarityRequest,
6709        allowed: Option<&std::collections::HashSet<RowId>>,
6710    ) -> Result<Vec<crate::query::SetSimilarityHit>> {
6711        self.set_similarity_explained_at(request, self.snapshot(), allowed)
6712            .map(|(hits, _)| hits)
6713    }
6714
6715    pub fn set_similarity_explained(
6716        &mut self,
6717        request: &crate::query::SetSimilarityRequest,
6718    ) -> Result<(
6719        Vec<crate::query::SetSimilarityHit>,
6720        crate::query::SetSimilarityTrace,
6721    )> {
6722        self.set_similarity_explained_at(request, self.snapshot(), None)
6723    }
6724
6725    fn set_similarity_explained_at(
6726        &mut self,
6727        request: &crate::query::SetSimilarityRequest,
6728        snapshot: Snapshot,
6729        allowed: Option<&std::collections::HashSet<RowId>>,
6730    ) -> Result<(
6731        Vec<crate::query::SetSimilarityHit>,
6732        crate::query::SetSimilarityTrace,
6733    )> {
6734        self.ensure_indexes_complete()?;
6735        self.set_similarity_explained_at_with_context(request, snapshot, allowed, None, None)
6736    }
6737
6738    pub fn set_similarity_at_with_context(
6739        &mut self,
6740        request: &crate::query::SetSimilarityRequest,
6741        snapshot: Snapshot,
6742        allowed: Option<&std::collections::HashSet<RowId>>,
6743        context: Option<&crate::query::AiExecutionContext>,
6744    ) -> Result<Vec<crate::query::SetSimilarityHit>> {
6745        self.ensure_indexes_complete()?;
6746        self.set_similarity_explained_at_with_context(request, snapshot, allowed, None, context)
6747            .map(|(hits, _)| hits)
6748    }
6749
6750    pub fn set_similarity_at_with_candidate_authorization_and_context(
6751        &mut self,
6752        request: &crate::query::SetSimilarityRequest,
6753        snapshot: Snapshot,
6754        authorization: Option<&crate::security::CandidateAuthorization<'_>>,
6755        context: Option<&crate::query::AiExecutionContext>,
6756    ) -> Result<Vec<crate::query::SetSimilarityHit>> {
6757        self.ensure_indexes_complete()?;
6758        self.set_similarity_explained_at_with_context(
6759            request,
6760            snapshot,
6761            None,
6762            authorization,
6763            context,
6764        )
6765        .map(|(hits, _)| hits)
6766    }
6767
6768    #[doc(hidden)]
6769    pub fn set_similarity_at_with_candidate_authorization_on_generation(
6770        &self,
6771        request: &crate::query::SetSimilarityRequest,
6772        snapshot: Snapshot,
6773        authorization: Option<&crate::security::CandidateAuthorization<'_>>,
6774        context: Option<&crate::query::AiExecutionContext>,
6775    ) -> Result<Vec<crate::query::SetSimilarityHit>> {
6776        self.set_similarity_explained_at_with_context(
6777            request,
6778            snapshot,
6779            None,
6780            authorization,
6781            context,
6782        )
6783        .map(|(hits, _)| hits)
6784    }
6785
6786    fn set_similarity_explained_at_with_context(
6787        &self,
6788        request: &crate::query::SetSimilarityRequest,
6789        snapshot: Snapshot,
6790        allowed: Option<&std::collections::HashSet<RowId>>,
6791        candidate_authorization: Option<&crate::security::CandidateAuthorization<'_>>,
6792        context: Option<&crate::query::AiExecutionContext>,
6793    ) -> Result<(
6794        Vec<crate::query::SetSimilarityHit>,
6795        crate::query::SetSimilarityTrace,
6796    )> {
6797        use crate::query::{
6798            Retriever, RetrieverScore, SetSimilarityHit, MAX_FINAL_LIMIT, MAX_RETRIEVER_K,
6799            MAX_SET_MEMBERS,
6800        };
6801        let mut trace = crate::query::SetSimilarityTrace::default();
6802        if request.members.is_empty() {
6803            return Ok((Vec::new(), trace));
6804        }
6805        if request.candidate_k == 0 || request.limit == 0 {
6806            return Err(MongrelError::InvalidArgument(
6807                "candidate_k and limit must be > 0".into(),
6808            ));
6809        }
6810        if request.candidate_k > MAX_RETRIEVER_K
6811            || request.limit > MAX_FINAL_LIMIT
6812            || request.members.len() > MAX_SET_MEMBERS
6813        {
6814            return Err(MongrelError::InvalidArgument(format!(
6815                "candidate_k must be <= {MAX_RETRIEVER_K}, limit <= {MAX_FINAL_LIMIT}, and members <= {MAX_SET_MEMBERS}"
6816            )));
6817        }
6818        if !request.min_jaccard.is_finite() || !(0.0..=1.0).contains(&request.min_jaccard) {
6819            return Err(MongrelError::InvalidArgument(
6820                "min_jaccard must be finite and between 0 and 1".into(),
6821            ));
6822        }
6823        let started = std::time::Instant::now();
6824        let retriever = Retriever::MinHash {
6825            column_id: request.column_id,
6826            members: request.members.clone(),
6827            k: request.candidate_k,
6828        };
6829        self.require_select()?;
6830        self.validate_retriever(&retriever)?;
6831        let hits = self.retrieve_filtered(
6832            &retriever,
6833            snapshot,
6834            None,
6835            allowed,
6836            candidate_authorization,
6837            context,
6838        )?;
6839        trace.candidate_generation_us = started.elapsed().as_micros() as u64;
6840        trace.candidate_count = hits.len();
6841        let row_ids: Vec<_> = hits.iter().map(|hit| hit.row_id.0).collect();
6842        if let Some(context) = context {
6843            context.consume(row_ids.len())?;
6844        }
6845        let started = std::time::Instant::now();
6846        let query_now = context.map_or_else(unix_nanos_now, |context| context.query_time_nanos());
6847        let values = self.values_for_rids_batch_at_with_context(
6848            &row_ids,
6849            request.column_id,
6850            snapshot,
6851            query_now,
6852            context,
6853        )?;
6854        trace.gather_us = started.elapsed().as_micros() as u64;
6855        if let Some(context) = context {
6856            context.consume(request.members.len())?;
6857        }
6858        let query: std::collections::HashSet<_> = request.members.iter().cloned().collect();
6859        let estimates: std::collections::HashMap<_, _> = hits
6860            .into_iter()
6861            .filter_map(|hit| match hit.score {
6862                RetrieverScore::MinHashEstimatedJaccard(score) => Some((hit.row_id, score)),
6863                _ => None,
6864            })
6865            .collect();
6866        let started = std::time::Instant::now();
6867        let mut parsed = Vec::with_capacity(values.len());
6868        for (row_id, value) in values {
6869            let Value::Bytes(bytes) = value else {
6870                continue;
6871            };
6872            if let Some(context) = context {
6873                context.consume(crate::query::work_units(
6874                    bytes.len(),
6875                    crate::query::PARSE_WORK_QUANTUM,
6876                ))?;
6877            }
6878            let Ok(serde_json::Value::Array(members)) = serde_json::from_slice(&bytes) else {
6879                continue;
6880            };
6881            if let Some(context) = context {
6882                context.consume(members.len())?;
6883            }
6884            let stored = members
6885                .into_iter()
6886                .filter_map(|member| match member {
6887                    serde_json::Value::String(value) => {
6888                        Some(crate::query::SetMember::String(value))
6889                    }
6890                    serde_json::Value::Number(value) => {
6891                        Some(crate::query::SetMember::Number(value))
6892                    }
6893                    serde_json::Value::Bool(value) => Some(crate::query::SetMember::Boolean(value)),
6894                    _ => None,
6895                })
6896                .collect::<std::collections::HashSet<_>>();
6897            parsed.push((row_id, stored));
6898        }
6899        trace.parse_us = started.elapsed().as_micros() as u64;
6900        trace.verified_count = parsed.len();
6901        let started = std::time::Instant::now();
6902        let mut exact = Vec::new();
6903        for (row_id, stored) in parsed {
6904            if let Some(context) = context {
6905                context.consume(query.len().saturating_add(stored.len()))?;
6906            }
6907            let union = query.union(&stored).count();
6908            let score = if union == 0 {
6909                1.0
6910            } else {
6911                query.intersection(&stored).count() as f32 / union as f32
6912            };
6913            if score >= request.min_jaccard {
6914                exact.push(SetSimilarityHit {
6915                    row_id,
6916                    estimated_jaccard: estimates.get(&row_id).copied().unwrap_or_default(),
6917                    exact_jaccard: score,
6918                });
6919            }
6920        }
6921        exact.sort_by(|a, b| {
6922            b.exact_jaccard
6923                .total_cmp(&a.exact_jaccard)
6924                .then_with(|| a.row_id.cmp(&b.row_id))
6925        });
6926        exact.truncate(request.limit);
6927        trace.score_us = started.elapsed().as_micros() as u64;
6928        crate::trace::QueryTrace::record(|query_trace| {
6929            query_trace.exact_set_gather_nanos = query_trace
6930                .exact_set_gather_nanos
6931                .saturating_add(trace.gather_us.saturating_mul(1_000));
6932            query_trace.exact_set_parse_nanos = query_trace
6933                .exact_set_parse_nanos
6934                .saturating_add(trace.parse_us.saturating_mul(1_000));
6935            query_trace.exact_set_score_nanos = query_trace
6936                .exact_set_score_nanos
6937                .saturating_add(trace.score_us.saturating_mul(1_000));
6938        });
6939        Ok((exact, trace))
6940    }
6941
6942    /// Fetch one column for visible row ids without decoding unrelated columns.
6943    fn values_for_rids_batch_at(
6944        &self,
6945        row_ids: &[u64],
6946        column_id: u16,
6947        snapshot: Snapshot,
6948        now: i64,
6949    ) -> Result<Vec<(RowId, Value)>> {
6950        if self.ttl.is_none()
6951            && self.memtable.is_empty()
6952            && self.mutable_run.is_empty()
6953            && self.run_refs.len() == 1
6954        {
6955            let mut reader = self.open_reader(self.run_refs[0].run_id)?;
6956            // Small projections should not decode and scan the run's entire
6957            // row-id column. Resolve each requested row through the page-pruned
6958            // point path until a full visibility pass becomes cheaper. Keep
6959            // this crossover aligned with `rows_for_rids_at_time`.
6960            if row_ids.len().saturating_mul(24) < reader.row_count() {
6961                let mut values = Vec::with_capacity(row_ids.len());
6962                for &raw_row_id in row_ids {
6963                    let row_id = RowId(raw_row_id);
6964                    if let Some((_, false, Some(value))) =
6965                        reader.get_version_column(row_id, snapshot.epoch, column_id)?
6966                    {
6967                        values.push((row_id, value));
6968                    }
6969                }
6970                return Ok(values);
6971            }
6972            let (positions, visible_row_ids) =
6973                reader.visible_positions_with_rids(snapshot.epoch)?;
6974            let requested: Vec<(RowId, usize)> = row_ids
6975                .iter()
6976                .filter_map(|raw| {
6977                    visible_row_ids
6978                        .binary_search(&(*raw as i64))
6979                        .ok()
6980                        .map(|index| (RowId(*raw), positions[index]))
6981                })
6982                .collect();
6983            let values = reader.gather_column(
6984                column_id,
6985                &requested
6986                    .iter()
6987                    .map(|(_, position)| *position)
6988                    .collect::<Vec<_>>(),
6989            )?;
6990            return Ok(requested
6991                .into_iter()
6992                .zip(values)
6993                .map(|((row_id, _), value)| (row_id, value))
6994                .collect());
6995        }
6996        self.values_for_rids_at(row_ids, column_id, snapshot, now)
6997    }
6998
6999    fn values_for_rids_batch_at_with_context(
7000        &self,
7001        row_ids: &[u64],
7002        column_id: u16,
7003        snapshot: Snapshot,
7004        now: i64,
7005        context: Option<&crate::query::AiExecutionContext>,
7006    ) -> Result<Vec<(RowId, Value)>> {
7007        let Some(context) = context else {
7008            return self.values_for_rids_batch_at(row_ids, column_id, snapshot, now);
7009        };
7010        let mut values = Vec::with_capacity(row_ids.len());
7011        for chunk in row_ids.chunks(256) {
7012            context.checkpoint()?;
7013            values.extend(self.values_for_rids_batch_at(chunk, column_id, snapshot, now)?);
7014        }
7015        Ok(values)
7016    }
7017
7018    /// Fetch one column for visible row ids without decoding unrelated columns.
7019    fn values_for_rids_at(
7020        &self,
7021        row_ids: &[u64],
7022        column_id: u16,
7023        snapshot: Snapshot,
7024        now: i64,
7025    ) -> Result<Vec<(RowId, Value)>> {
7026        let mut readers: Vec<_> = self
7027            .run_refs
7028            .iter()
7029            .map(|run| self.open_reader(run.run_id))
7030            .collect::<Result<_>>()?;
7031        let mut out = Vec::with_capacity(row_ids.len());
7032        for &raw_row_id in row_ids {
7033            let row_id = RowId(raw_row_id);
7034            let mem = self.memtable.get_version_at(row_id, snapshot);
7035            let mutable = self.mutable_run.get_version_at(row_id, snapshot);
7036            let overlay = match (mem, mutable) {
7037                (Some((_, a)), Some((_, b))) => Some(
7038                    if Snapshot::version_is_newer(
7039                        a.committed_epoch,
7040                        a.commit_ts,
7041                        b.committed_epoch,
7042                        b.commit_ts,
7043                    ) {
7044                        a
7045                    } else {
7046                        b
7047                    },
7048                ),
7049                (Some((_, value)), None) | (None, Some((_, value))) => Some(value),
7050                (None, None) => None,
7051            };
7052            if let Some(row) = overlay {
7053                if !row.deleted && !self.row_expired_at(&row, now) {
7054                    if let Some(value) = row.columns.get(&column_id) {
7055                        out.push((row_id, value.clone()));
7056                    }
7057                }
7058                continue;
7059            }
7060
7061            let mut best: Option<(Epoch, bool, Option<Value>, usize)> = None;
7062            for (index, reader) in readers.iter_mut().enumerate() {
7063                if let Some((epoch, deleted, value)) =
7064                    reader.get_version_column(row_id, snapshot.epoch, column_id)?
7065                {
7066                    if best
7067                        .as_ref()
7068                        .map(|(best_epoch, ..)| epoch > *best_epoch)
7069                        .unwrap_or(true)
7070                    {
7071                        best = Some((epoch, deleted, value, index));
7072                    }
7073                }
7074            }
7075            let Some((_, false, Some(value), reader_index)) = best else {
7076                continue;
7077            };
7078            if let Some(ttl) = self.ttl {
7079                if ttl.column_id != column_id {
7080                    if let Some((_, _, Some(Value::Int64(timestamp)))) = readers[reader_index]
7081                        .get_version_column(row_id, snapshot.epoch, ttl.column_id)?
7082                    {
7083                        if timestamp.saturating_add(ttl.duration_nanos as i64) <= now {
7084                            continue;
7085                        }
7086                    }
7087                } else if let Value::Int64(timestamp) = value {
7088                    if timestamp.saturating_add(ttl.duration_nanos as i64) <= now {
7089                        continue;
7090                    }
7091                }
7092            }
7093            out.push((row_id, value));
7094        }
7095        Ok(out)
7096    }
7097
7098    /// Materialize the MVCC-visible, non-deleted rows for `rids` at `snapshot`,
7099    /// preserving the input order. Rows whose newest visible version is a
7100    /// tombstone, or that no longer exist, are omitted. Shared by index-served
7101    /// [`query`] and the Phase 8.1 FK-join path.
7102    pub fn rows_for_rids(&self, rids: &[u64], snapshot: Snapshot) -> Result<Vec<Row>> {
7103        self.rows_for_rids_at_time(rids, snapshot, unix_nanos_now(), None)
7104    }
7105
7106    pub fn rows_for_rids_with_context(
7107        &self,
7108        rids: &[u64],
7109        snapshot: Snapshot,
7110        context: &crate::query::AiExecutionContext,
7111    ) -> Result<Vec<Row>> {
7112        context.consume(rids.len().saturating_mul(self.schema.columns.len()))?;
7113        self.rows_for_rids_at_time(rids, snapshot, context.query_time_nanos(), None)
7114    }
7115
7116    fn rows_for_rids_at_time(
7117        &self,
7118        rids: &[u64],
7119        snapshot: Snapshot,
7120        ttl_now: i64,
7121        control: Option<&crate::ExecutionControl>,
7122    ) -> Result<Vec<Row>> {
7123        use std::collections::HashMap;
7124        let mut rows = Vec::with_capacity(rids.len());
7125        // Overlay (memtable + mutable-run) newest visible version per rid —
7126        // these shadow any stale version stored in a run. Prefer HLC order via
7127        // version_is_newer when stamps are present (P0.5-T3).
7128        //
7129        // `rids` is already index-resolved (the caller's condition set), so it
7130        // is normally tiny relative to the memtable/mutable-run tiers — a
7131        // single-row PK/unique check feeding insert/update/delete resolves to
7132        // 0 or 1 rid. Materializing every version in both tiers (the old
7133        // behavior) cost O(tier size) regardless, which meant an unrelated
7134        // full-table-sized scan (plus the drop cost of the resulting map) on
7135        // every point lookup once the table grew large. Below the crossover,
7136        // a direct per-rid probe (`get_version_at`) wins; once `rids` approaches
7137        // tier size, one linear materializing pass beats `rids.len()` separate
7138        // probes, so fall back to it.
7139        let tier_size = self.memtable.len() + self.mutable_run.len();
7140        let mut overlay: HashMap<u64, Row> = HashMap::with_capacity(rids.len());
7141        if rids.len().saturating_mul(24) < tier_size {
7142            for &rid in rids {
7143                if overlay.len() & 255 == 0 {
7144                    control
7145                        .map(crate::ExecutionControl::checkpoint)
7146                        .transpose()?;
7147                }
7148                let mem = self.memtable.get_version_at(RowId(rid), snapshot);
7149                let mrun = self.mutable_run.get_version_at(RowId(rid), snapshot);
7150                let newest = match (mem, mrun) {
7151                    (Some((_, mr)), Some((_, rr))) => Some(
7152                        if Snapshot::version_is_newer(
7153                            mr.committed_epoch,
7154                            mr.commit_ts,
7155                            rr.committed_epoch,
7156                            rr.commit_ts,
7157                        ) {
7158                            mr
7159                        } else {
7160                            rr
7161                        },
7162                    ),
7163                    (Some((_, mr)), None) => Some(mr),
7164                    (None, Some((_, rr))) => Some(rr),
7165                    (None, None) => None,
7166                };
7167                if let Some(row) = newest {
7168                    overlay.insert(rid, row);
7169                }
7170            }
7171        } else {
7172            let fold_newest = |row: Row, overlay: &mut HashMap<u64, Row>| {
7173                overlay
7174                    .entry(row.row_id.0)
7175                    .and_modify(|e| {
7176                        if Snapshot::version_is_newer(
7177                            row.committed_epoch,
7178                            row.commit_ts,
7179                            e.committed_epoch,
7180                            e.commit_ts,
7181                        ) {
7182                            *e = row.clone();
7183                        }
7184                    })
7185                    .or_insert(row);
7186            };
7187            for (index, row) in self
7188                .memtable
7189                .visible_versions_at(snapshot)
7190                .into_iter()
7191                .enumerate()
7192            {
7193                if index & 255 == 0 {
7194                    control
7195                        .map(crate::ExecutionControl::checkpoint)
7196                        .transpose()?;
7197                }
7198                fold_newest(row, &mut overlay);
7199            }
7200            for (index, row) in self
7201                .mutable_run
7202                .visible_versions_at(snapshot)
7203                .into_iter()
7204                .enumerate()
7205            {
7206                if index & 255 == 0 {
7207                    control
7208                        .map(crate::ExecutionControl::checkpoint)
7209                        .transpose()?;
7210                }
7211                fold_newest(row, &mut overlay);
7212            }
7213        }
7214        if self.run_refs.len() == 1 {
7215            let mut reader = self.open_reader(self.run_refs[0].run_id)?;
7216            // Same crossover as the overlay above: `visible_positions_with_rids`
7217            // decodes/scans the run's *entire* row-id column regardless of
7218            // `rids.len()`, so a point lookup (0 or 1 rid, the common
7219            // insert/update/delete case) paid an O(run size) tax for a single
7220            // row. Below the crossover, `get_version`'s page-pruned lookup
7221            // (`SYS_ROW_ID` pages carry exact row-id bounds) resolves each rid
7222            // by decoding only its page, no whole-column decode.
7223            if rids.len().saturating_mul(24) < reader.row_count() {
7224                for (index, &rid) in rids.iter().enumerate() {
7225                    if index & 255 == 0 {
7226                        control
7227                            .map(crate::ExecutionControl::checkpoint)
7228                            .transpose()?;
7229                    }
7230                    if let Some(r) = overlay.get(&rid) {
7231                        if !r.deleted {
7232                            rows.push(r.clone());
7233                        }
7234                        continue;
7235                    }
7236                    if let Some((_, row)) = reader.get_version(RowId(rid), snapshot.epoch)? {
7237                        if !row.deleted {
7238                            rows.push(row);
7239                        }
7240                    }
7241                }
7242                rows.retain(|row| !self.row_expired_at(row, ttl_now));
7243                return Ok(rows);
7244            }
7245            // Phase 16.3b: decode the system columns ONCE (via the clean-run-
7246            // shortcut visibility pass) and binary-search each requested rid,
7247            // instead of `get_version`-per-rid which re-decoded + cloned the
7248            // full system columns on every call (the ~350 ms native-query tax).
7249            // Phase 16.3b finish: batch the survivor positions into ONE
7250            // `materialize_batch` call so user columns are decoded once each via
7251            // the typed, page-cached path (not a per-rid `Vec<Value>` decode +
7252            // `.cloned()`).
7253            let (positions, vis_rids) = reader.visible_positions_with_rids(snapshot.epoch)?;
7254            // First pass: classify each input rid (overlay / run position /
7255            // not-found), recording the run positions to fetch in input order.
7256            enum Src {
7257                Overlay,
7258                Run,
7259            }
7260            let mut plan: Vec<Src> = Vec::with_capacity(rids.len());
7261            let mut fetch: Vec<usize> = Vec::with_capacity(rids.len());
7262            for (index, rid) in rids.iter().enumerate() {
7263                if index & 255 == 0 {
7264                    control
7265                        .map(crate::ExecutionControl::checkpoint)
7266                        .transpose()?;
7267                }
7268                if overlay.contains_key(rid) {
7269                    plan.push(Src::Overlay);
7270                    continue;
7271                }
7272                match vis_rids.binary_search(&(*rid as i64)) {
7273                    Ok(i) => {
7274                        plan.push(Src::Run);
7275                        fetch.push(positions[i]);
7276                    }
7277                    Err(_) => { /* not found — omitted from output */ }
7278                }
7279            }
7280            let fetched = reader.materialize_batch(&fetch)?;
7281            let mut fetched_iter = fetched.into_iter();
7282            for (index, (rid, src)) in rids.iter().zip(plan).enumerate() {
7283                if index & 255 == 0 {
7284                    control
7285                        .map(crate::ExecutionControl::checkpoint)
7286                        .transpose()?;
7287                }
7288                match src {
7289                    Src::Overlay => {
7290                        if let Some(r) = overlay.get(rid) {
7291                            if !r.deleted {
7292                                rows.push(r.clone());
7293                            }
7294                        }
7295                    }
7296                    Src::Run => {
7297                        if let Some(row) = fetched_iter.next() {
7298                            if !row.deleted {
7299                                rows.push(row);
7300                            }
7301                        }
7302                    }
7303                }
7304            }
7305            rows.retain(|row| !self.row_expired_at(row, ttl_now));
7306            return Ok(rows);
7307        }
7308        // Multi-run: one reader per run; newest visible version across all runs
7309        // + the overlay. (Per-rid `get_version` here is unavoidable without a
7310        // cross-run merge, but multi-run is the uncommon cold case.)
7311        let mut readers: Vec<_> = self
7312            .run_refs
7313            .iter()
7314            .map(|rr| self.open_reader(rr.run_id))
7315            .collect::<Result<Vec<_>>>()?;
7316        for (index, rid) in rids.iter().enumerate() {
7317            if index & 255 == 0 {
7318                control
7319                    .map(crate::ExecutionControl::checkpoint)
7320                    .transpose()?;
7321            }
7322            if let Some(r) = overlay.get(rid) {
7323                if !r.deleted {
7324                    rows.push(r.clone());
7325                }
7326                continue;
7327            }
7328            let mut best: Option<(Epoch, Row)> = None;
7329            for reader in readers.iter_mut() {
7330                if let Ok(Some((epoch, row))) = reader.get_version(RowId(*rid), snapshot.epoch) {
7331                    if best.as_ref().map(|(be, _)| epoch > *be).unwrap_or(true) {
7332                        best = Some((epoch, row));
7333                    }
7334                }
7335            }
7336            if let Some((_, r)) = best {
7337                if !r.deleted {
7338                    rows.push(r);
7339                }
7340            }
7341        }
7342        rows.retain(|row| !self.row_expired_at(row, ttl_now));
7343        Ok(rows)
7344    }
7345
7346    /// Resolve the referencing (FK) side of a primary-key ↔ foreign-key join as
7347    /// a row-id set (Phase 8.1): union the roaring-bitmap entries of
7348    /// `fk_column_id` for every value in `pk_values` — the surviving
7349    /// primary-key values — then intersect with `fk_conditions`, i.e. any
7350    /// FK-side predicates (`ann_search ∩ fm_contains`, bitmap equality, range,
7351    /// …). Returns the survivor row-ids ascending. Requires a bitmap index on
7352    /// `fk_column_id`; returns an empty set when there is none.
7353    /// Whether live indexes are complete (Phase 14.7 + 17.2: the broadcast
7354    /// join path checks this before using the HOT index).
7355    pub fn indexes_complete(&self) -> bool {
7356        self.indexes_complete
7357    }
7358
7359    /// Where bulk loads put the index-build cost (see [`IndexBuildPolicy`]).
7360    pub fn index_build_policy(&self) -> IndexBuildPolicy {
7361        self.index_build_policy
7362    }
7363
7364    /// Set the bulk-load index-build policy. Takes effect on the next
7365    /// `bulk_load` / `bulk_load_columns` / `bulk_load_fast`; never changes
7366    /// already-built indexes.
7367    pub fn set_index_build_policy(&mut self, policy: IndexBuildPolicy) {
7368        self.index_build_policy = policy;
7369    }
7370
7371    /// Phase 17.2: broadcast join — return the distinct values in this table's
7372    /// bitmap index for `column_id` that also exist as a key in `pk_db`'s HOT
7373    /// index. Avoids loading the entire PK table when the FK column has low
7374    /// cardinality. Returns `None` if no bitmap index exists for the column.
7375    pub fn broadcast_join_values(&self, column_id: u16, pk_db: &Table) -> Option<Vec<Vec<u8>>> {
7376        // A deferred bulk load leaves the bitmap unbuilt — its (empty) key set
7377        // would silently produce an empty join. Decline; the caller falls back
7378        // to the PK-side query path, which completes indexes lazily.
7379        if !self.indexes_complete {
7380            return None;
7381        }
7382        let b = self.bitmap.get(&column_id)?;
7383        let result: Vec<Vec<u8>> = b
7384            .keys()
7385            .into_iter()
7386            .filter(|k| pk_db.hot.get(k.as_slice()).is_some())
7387            .collect();
7388        Some(result)
7389    }
7390
7391    pub fn fk_join_row_ids(
7392        &self,
7393        fk_column_id: u16,
7394        pk_values: &[Vec<u8>],
7395        fk_conditions: &[crate::query::Condition],
7396        snapshot: Snapshot,
7397    ) -> Result<Vec<u64>> {
7398        let Some(b) = self.bitmap.get(&fk_column_id) else {
7399            return Ok(Vec::new());
7400        };
7401        let mut join_set = {
7402            let mut acc = roaring::RoaringBitmap::new();
7403            for v in pk_values {
7404                acc |= b.get(v);
7405            }
7406            RowIdSet::from_roaring(acc)
7407        };
7408        if !fk_conditions.is_empty() {
7409            let mut sets: Vec<RowIdSet> = Vec::with_capacity(fk_conditions.len() + 1);
7410            sets.push(join_set);
7411            for c in fk_conditions {
7412                sets.push(self.resolve_condition(c, snapshot)?);
7413            }
7414            join_set = RowIdSet::intersect_many(sets);
7415        }
7416        Ok(join_set.into_sorted_vec())
7417    }
7418
7419    /// Like [`fk_join_row_ids`] but returns only the **cardinality** of the FK
7420    /// survivor set — without materializing or sorting it. For a bare
7421    /// `COUNT(*)` join with no FK-side filter this is O(1) on the bitmap union
7422    /// (Phase 17.4): the prior path built a `HashSet<u64>` + `Vec<u64>` +
7423    /// `sort_unstable` over up to N rows only to read `.len()`.
7424    pub fn fk_join_count(
7425        &self,
7426        fk_column_id: u16,
7427        pk_values: &[Vec<u8>],
7428        fk_conditions: &[crate::query::Condition],
7429        snapshot: Snapshot,
7430    ) -> Result<u64> {
7431        let Some(b) = self.bitmap.get(&fk_column_id) else {
7432            return Ok(0);
7433        };
7434        let mut acc = roaring::RoaringBitmap::new();
7435        for v in pk_values {
7436            acc |= b.get(v);
7437        }
7438        if fk_conditions.is_empty() {
7439            return Ok(acc.len());
7440        }
7441        let mut sets: Vec<RowIdSet> = Vec::with_capacity(fk_conditions.len() + 1);
7442        sets.push(RowIdSet::from_roaring(acc));
7443        for c in fk_conditions {
7444            sets.push(self.resolve_condition(c, snapshot)?);
7445        }
7446        Ok(RowIdSet::intersect_many(sets).len() as u64)
7447    }
7448
7449    /// Resolve a single condition to its row-id set. Index-served conditions use
7450    /// the in-memory indexes; `Range`/`RangeF64` prefer the learned (PGM) index
7451    /// or the reader's page-index-skipping path on the single-run fast path, and
7452    /// only fall back to a `visible_rows` scan off the fast path (multi-run).
7453    fn resolve_condition(
7454        &self,
7455        c: &crate::query::Condition,
7456        snapshot: Snapshot,
7457    ) -> Result<RowIdSet> {
7458        self.resolve_condition_with_allowed(c, snapshot, None)
7459    }
7460
7461    fn resolve_condition_with_allowed(
7462        &self,
7463        c: &crate::query::Condition,
7464        snapshot: Snapshot,
7465        allowed: Option<&std::collections::HashSet<RowId>>,
7466    ) -> Result<RowIdSet> {
7467        use crate::query::Condition;
7468        self.validate_condition(c)?;
7469        Ok(match c {
7470            Condition::Pk(key) => {
7471                let lookup = self
7472                    .schema
7473                    .primary_key()
7474                    .map(|pk| self.index_lookup_key_bytes(pk.id, key))
7475                    .unwrap_or_else(|| key.clone());
7476                self.hot
7477                    .get(&lookup)
7478                    .map(|r| RowIdSet::one(r.0))
7479                    .unwrap_or_else(RowIdSet::empty)
7480            }
7481            Condition::BitmapEq { column_id, value } => {
7482                let lookup = self.index_lookup_key_bytes(*column_id, value);
7483                self.bitmap
7484                    .get(column_id)
7485                    .map(|b| RowIdSet::from_roaring(b.get(&lookup)))
7486                    .unwrap_or_else(RowIdSet::empty)
7487            }
7488            Condition::BitmapIn { column_id, values } => {
7489                let bm = self.bitmap.get(column_id);
7490                let mut acc = roaring::RoaringBitmap::new();
7491                if let Some(b) = bm {
7492                    for v in values {
7493                        let lookup = self.index_lookup_key_bytes(*column_id, v);
7494                        acc |= b.get(&lookup);
7495                    }
7496                }
7497                RowIdSet::from_roaring(acc)
7498            }
7499            Condition::BytesPrefix { column_id, prefix } => {
7500                // §5.6: enumerate bitmap keys sharing the prefix for an exact
7501                // prefix match (anchored `LIKE 'prefix%'`), tighter than the
7502                // FM substring superset. The caller only emits this when the
7503                // column has a bitmap index.
7504                if let Some(b) = self.bitmap.get(column_id) {
7505                    let lookup_prefix = self.index_lookup_key_bytes(*column_id, prefix);
7506                    let mut acc = roaring::RoaringBitmap::new();
7507                    for key in b.keys() {
7508                        if key.starts_with(&lookup_prefix) {
7509                            acc |= b.get(&key);
7510                        }
7511                    }
7512                    RowIdSet::from_roaring(acc)
7513                } else {
7514                    RowIdSet::empty()
7515                }
7516            }
7517            Condition::FmContains { column_id, pattern } => self
7518                .fm
7519                .get(column_id)
7520                .map(|f| {
7521                    RowIdSet::from_unsorted(f.locate(pattern).into_iter().map(|r| r.0).collect())
7522                })
7523                .unwrap_or_else(RowIdSet::empty),
7524            Condition::FmContainsAll {
7525                column_id,
7526                patterns,
7527            } => {
7528                // Multi-segment intersection (Priority 12): resolve each segment
7529                // via FM and intersect — much tighter than the single longest.
7530                if let Some(f) = self.fm.get(column_id) {
7531                    let sets: Vec<RowIdSet> = patterns
7532                        .iter()
7533                        .map(|pat| {
7534                            RowIdSet::from_unsorted(
7535                                f.locate(pat).into_iter().map(|r| r.0).collect(),
7536                            )
7537                        })
7538                        .collect();
7539                    RowIdSet::intersect_many(sets)
7540                } else {
7541                    RowIdSet::empty()
7542                }
7543            }
7544            Condition::Ann {
7545                column_id,
7546                query,
7547                k,
7548            } => RowIdSet::from_unsorted(
7549                self.retrieve_filtered(
7550                    &crate::query::Retriever::Ann {
7551                        column_id: *column_id,
7552                        query: query.clone(),
7553                        k: *k,
7554                    },
7555                    snapshot,
7556                    None,
7557                    allowed,
7558                    None,
7559                    None,
7560                )?
7561                .into_iter()
7562                .map(|hit| hit.row_id.0)
7563                .collect(),
7564            ),
7565            Condition::SparseMatch {
7566                column_id,
7567                query,
7568                k,
7569            } => RowIdSet::from_unsorted(
7570                self.retrieve_filtered(
7571                    &crate::query::Retriever::Sparse {
7572                        column_id: *column_id,
7573                        query: query.clone(),
7574                        k: *k,
7575                    },
7576                    snapshot,
7577                    None,
7578                    allowed,
7579                    None,
7580                    None,
7581                )?
7582                .into_iter()
7583                .map(|hit| hit.row_id.0)
7584                .collect(),
7585            ),
7586            Condition::MinHashSimilar {
7587                column_id,
7588                query,
7589                k,
7590            } => match self.minhash.get(column_id) {
7591                Some(index) => {
7592                    let candidates = index.candidate_row_ids(query);
7593                    let eligible =
7594                        self.eligible_candidate_ids(&candidates, *column_id, snapshot, None)?;
7595                    RowIdSet::from_unsorted(
7596                        index
7597                            .search_filtered(query, *k, |row_id| {
7598                                eligible.contains(&row_id)
7599                                    && allowed.is_none_or(|allowed| allowed.contains(&row_id))
7600                            })
7601                            .into_iter()
7602                            .map(|(row_id, _)| row_id.0)
7603                            .collect(),
7604                    )
7605                }
7606                None => RowIdSet::empty(),
7607            },
7608            Condition::Range { column_id, lo, hi } => {
7609                // Build the candidate set from the durable tier — the learned
7610                // index (built from sorted runs) or a single page-pruned run —
7611                // then merge the memtable/mutable-run overlay. An overlay row
7612                // supersedes its run version (it may have been updated out of
7613                // range or deleted), so overlay rids are dropped from the run
7614                // set and re-evaluated from the overlay directly. Without this
7615                // merge, rows still in the memtable are invisible to a ranged
7616                // read whenever a LearnedRange index is present.
7617                let mut set = if let Some(li) = self.learned_range.get(column_id) {
7618                    RowIdSet::from_unsorted(li.range(*lo, *hi).into_iter().collect())
7619                } else if self.run_refs.len() == 1 {
7620                    let mut r = self.open_reader(self.run_refs[0].run_id)?;
7621                    r.range_row_id_set_i64(*column_id, *lo, *hi)?
7622                } else {
7623                    return self.range_scan_i64(*column_id, *lo, *hi, snapshot);
7624                };
7625                set.remove_many(self.overlay_rid_set(snapshot));
7626                self.range_scan_overlay_i64(&mut set, *column_id, *lo, *hi, snapshot);
7627                set
7628            }
7629            Condition::RangeF64 {
7630                column_id,
7631                lo,
7632                lo_inclusive,
7633                hi,
7634                hi_inclusive,
7635            } => {
7636                // See the `Range` arm: merge the overlay over the durable
7637                // candidate set so memtable/mutable-run rows are visible.
7638                let mut set = if let Some(li) = self.learned_range.get(column_id) {
7639                    RowIdSet::from_unsorted(
7640                        li.range_f64(*lo, *lo_inclusive, *hi, *hi_inclusive)
7641                            .into_iter()
7642                            .collect(),
7643                    )
7644                } else if self.run_refs.len() == 1 {
7645                    let mut r = self.open_reader(self.run_refs[0].run_id)?;
7646                    r.range_row_id_set_f64(*column_id, *lo, *lo_inclusive, *hi, *hi_inclusive)?
7647                } else {
7648                    return self.range_scan_f64(
7649                        *column_id,
7650                        *lo,
7651                        *lo_inclusive,
7652                        *hi,
7653                        *hi_inclusive,
7654                        snapshot,
7655                    );
7656                };
7657                set.remove_many(self.overlay_rid_set(snapshot));
7658                self.range_scan_overlay_f64(
7659                    &mut set,
7660                    *column_id,
7661                    *lo,
7662                    *lo_inclusive,
7663                    *hi,
7664                    *hi_inclusive,
7665                    snapshot,
7666                );
7667                set
7668            }
7669            Condition::IsNull { column_id } => {
7670                let mut set = if self.run_refs.len() == 1 {
7671                    let mut r = self.open_reader(self.run_refs[0].run_id)?;
7672                    r.null_row_id_set(*column_id, true)?
7673                } else {
7674                    return self.null_scan(*column_id, true, snapshot);
7675                };
7676                set.remove_many(self.overlay_rid_set(snapshot));
7677                self.null_scan_overlay(&mut set, *column_id, true, snapshot);
7678                set
7679            }
7680            Condition::IsNotNull { column_id } => {
7681                let mut set = if self.run_refs.len() == 1 {
7682                    let mut r = self.open_reader(self.run_refs[0].run_id)?;
7683                    r.null_row_id_set(*column_id, false)?
7684                } else {
7685                    return self.null_scan(*column_id, false, snapshot);
7686                };
7687                set.remove_many(self.overlay_rid_set(snapshot));
7688                self.null_scan_overlay(&mut set, *column_id, false, snapshot);
7689                set
7690            }
7691        })
7692    }
7693
7694    /// Vectorized range scan for Int64 columns (Phase 13.2 / 16.3). Resolves the
7695    /// survivor set via the reader's **page-pruned** path — pages whose `[min,max]`
7696    /// excludes `[lo,hi]` are never decoded — restricted to MVCC-visible rows.
7697    /// This is layout-independent: correct under any memtable / multi-run state,
7698    /// so it is always safe to call (no "single clean run" gate). Overlay rows
7699    /// (memtable / mutable-run) are excluded from the run portion and checked
7700    /// directly via [`Self::range_scan_overlay_i64`].
7701    fn range_scan_i64(
7702        &self,
7703        column_id: u16,
7704        lo: i64,
7705        hi: i64,
7706        snapshot: Snapshot,
7707    ) -> Result<RowIdSet> {
7708        let mut row_ids = Vec::new();
7709        let overlay_rids = self.overlay_rid_set(snapshot);
7710        for rr in &self.run_refs {
7711            let mut reader = self.open_reader(rr.run_id)?;
7712            let matched = reader.range_row_ids_visible_i64(column_id, lo, hi, snapshot.epoch)?;
7713            for rid in matched {
7714                if !overlay_rids.contains(&rid) {
7715                    row_ids.push(rid);
7716                }
7717            }
7718        }
7719        let mut s = RowIdSet::from_unsorted(row_ids);
7720        self.range_scan_overlay_i64(&mut s, column_id, lo, hi, snapshot);
7721        Ok(s)
7722    }
7723
7724    /// Float64 analogue of [`Self::range_scan_i64`] with per-bound inclusivity
7725    /// (Phase 13.2 / 16.3).
7726    fn range_scan_f64(
7727        &self,
7728        column_id: u16,
7729        lo: f64,
7730        lo_inclusive: bool,
7731        hi: f64,
7732        hi_inclusive: bool,
7733        snapshot: Snapshot,
7734    ) -> Result<RowIdSet> {
7735        let mut row_ids = Vec::new();
7736        let overlay_rids = self.overlay_rid_set(snapshot);
7737        for rr in &self.run_refs {
7738            let mut reader = self.open_reader(rr.run_id)?;
7739            let matched = reader.range_row_ids_visible_f64(
7740                column_id,
7741                lo,
7742                lo_inclusive,
7743                hi,
7744                hi_inclusive,
7745                snapshot.epoch,
7746            )?;
7747            for rid in matched {
7748                if !overlay_rids.contains(&rid) {
7749                    row_ids.push(rid);
7750                }
7751            }
7752        }
7753        let mut s = RowIdSet::from_unsorted(row_ids);
7754        self.range_scan_overlay_f64(
7755            &mut s,
7756            column_id,
7757            lo,
7758            lo_inclusive,
7759            hi,
7760            hi_inclusive,
7761            snapshot,
7762        );
7763        Ok(s)
7764    }
7765
7766    /// Collect the set of row-ids visible in the memtable / mutable-run overlay.
7767    fn overlay_rid_set(&self, snapshot: Snapshot) -> HashSet<u64> {
7768        let mut s = HashSet::new();
7769        for row in self.memtable.visible_versions_at(snapshot) {
7770            s.insert(row.row_id.0);
7771        }
7772        for row in self.mutable_run.visible_versions_at(snapshot) {
7773            s.insert(row.row_id.0);
7774        }
7775        s
7776    }
7777
7778    fn range_scan_overlay_i64(
7779        &self,
7780        s: &mut RowIdSet,
7781        column_id: u16,
7782        lo: i64,
7783        hi: i64,
7784        snapshot: Snapshot,
7785    ) {
7786        // Collapse both overlay tiers to the newest visible version per row id
7787        // (HLC-aware when stamped; P0.5-T3) before range-checking, so a stale
7788        // in-range mutable-run version cannot shadow a newer out-of-range
7789        // memtable version of the same row.
7790        // Both tiers already applied version_is_newer within themselves; when
7791        // both report a rid, prefer the HLC-newer of the two.
7792        let mut newest: HashMap<u64, Row> = HashMap::new();
7793        for r in self.mutable_run.visible_versions_at(snapshot) {
7794            newest.insert(r.row_id.0, r);
7795        }
7796        for r in self.memtable.visible_versions_at(snapshot) {
7797            newest
7798                .entry(r.row_id.0)
7799                .and_modify(|cur| {
7800                    if Snapshot::version_is_newer(
7801                        r.committed_epoch,
7802                        r.commit_ts,
7803                        cur.committed_epoch,
7804                        cur.commit_ts,
7805                    ) {
7806                        *cur = r.clone();
7807                    }
7808                })
7809                .or_insert(r);
7810        }
7811        for row in newest.values() {
7812            if !row.deleted {
7813                if let Some(Value::Int64(v)) = row.columns.get(&column_id) {
7814                    if *v >= lo && *v <= hi {
7815                        s.insert(row.row_id.0);
7816                    }
7817                }
7818            }
7819        }
7820    }
7821
7822    #[allow(clippy::too_many_arguments)]
7823    fn range_scan_overlay_f64(
7824        &self,
7825        s: &mut RowIdSet,
7826        column_id: u16,
7827        lo: f64,
7828        lo_inclusive: bool,
7829        hi: f64,
7830        hi_inclusive: bool,
7831        snapshot: Snapshot,
7832    ) {
7833        // See `range_scan_overlay_i64`: dedup to the newest version per row id
7834        // across the memtable + mutable run before range-checking.
7835        let mut newest: HashMap<u64, Row> = HashMap::new();
7836        for r in self.mutable_run.visible_versions_at(snapshot) {
7837            newest.insert(r.row_id.0, r);
7838        }
7839        for r in self.memtable.visible_versions_at(snapshot) {
7840            newest
7841                .entry(r.row_id.0)
7842                .and_modify(|cur| {
7843                    if Snapshot::version_is_newer(
7844                        r.committed_epoch,
7845                        r.commit_ts,
7846                        cur.committed_epoch,
7847                        cur.commit_ts,
7848                    ) {
7849                        *cur = r.clone();
7850                    }
7851                })
7852                .or_insert(r);
7853        }
7854        for row in newest.values() {
7855            if !row.deleted {
7856                if let Some(Value::Float64(v)) = row.columns.get(&column_id) {
7857                    let ok_lo = if lo_inclusive { *v >= lo } else { *v > lo };
7858                    let ok_hi = if hi_inclusive { *v <= hi } else { *v < hi };
7859                    if ok_lo && ok_hi {
7860                        s.insert(row.row_id.0);
7861                    }
7862                }
7863            }
7864        }
7865    }
7866
7867    /// Multi-run fallback for `IS NULL` / `IS NOT NULL`. Calls each run's
7868    /// MVCC-aware null scan and merges with the overlay.
7869    fn null_scan(&self, column_id: u16, want_nulls: bool, snapshot: Snapshot) -> Result<RowIdSet> {
7870        let mut row_ids = Vec::new();
7871        let overlay_rids = self.overlay_rid_set(snapshot);
7872        for rr in &self.run_refs {
7873            let mut reader = self.open_reader(rr.run_id)?;
7874            let matched = reader.null_row_ids_visible(column_id, want_nulls, snapshot.epoch)?;
7875            for rid in matched {
7876                if !overlay_rids.contains(&rid) {
7877                    row_ids.push(rid);
7878                }
7879            }
7880        }
7881        let mut s = RowIdSet::from_unsorted(row_ids);
7882        self.null_scan_overlay(&mut s, column_id, want_nulls, snapshot);
7883        Ok(s)
7884    }
7885
7886    /// Merge overlay rows for `IS NULL` / `IS NOT NULL`. An overlay row
7887    /// supersedes its run version, so overlay rids are removed from the run
7888    /// set and re-evaluated from the overlay values directly.
7889    fn null_scan_overlay(
7890        &self,
7891        s: &mut RowIdSet,
7892        column_id: u16,
7893        want_nulls: bool,
7894        snapshot: Snapshot,
7895    ) {
7896        let mut newest: HashMap<u64, Row> = HashMap::new();
7897        for r in self.mutable_run.visible_versions_at(snapshot) {
7898            newest.insert(r.row_id.0, r);
7899        }
7900        for r in self.memtable.visible_versions_at(snapshot) {
7901            newest
7902                .entry(r.row_id.0)
7903                .and_modify(|cur| {
7904                    if Snapshot::version_is_newer(
7905                        r.committed_epoch,
7906                        r.commit_ts,
7907                        cur.committed_epoch,
7908                        cur.commit_ts,
7909                    ) {
7910                        *cur = r.clone();
7911                    }
7912                })
7913                .or_insert(r);
7914        }
7915        for row in newest.values() {
7916            if row.deleted {
7917                continue;
7918            }
7919            let is_null = !row.columns.contains_key(&column_id)
7920                || matches!(row.columns.get(&column_id), Some(Value::Null) | None);
7921            if is_null == want_nulls {
7922                s.insert(row.row_id.0);
7923            }
7924        }
7925    }
7926
7927    pub fn snapshot(&self) -> Snapshot {
7928        let epoch = self.epoch.visible();
7929        // P0.5: mounted tables pin the shared HLC so HLC-stamped row versions
7930        // remain visible under at_hlc. Standalone private-WAL tables fall back
7931        // to epoch-only (private commits do not stamp HLC today).
7932        match &self.wal {
7933            WalSink::Shared(shared) => match shared.hlc.now() {
7934                Ok(commit_ts) => Snapshot::at_hlc(epoch, commit_ts),
7935                // Clock skew must not hide committed HLC rows from product reads.
7936                Err(_) => Snapshot::at_hlc(epoch, mongreldb_types::hlc::HlcTimestamp::MAX),
7937            },
7938            WalSink::Private(_) | WalSink::ReadOnly => Snapshot::at(epoch),
7939        }
7940    }
7941
7942    /// Generation of this table's row contents for table-local caches.
7943    pub fn data_generation(&self) -> u64 {
7944        self.data_generation
7945    }
7946
7947    pub(crate) fn bump_data_generation(&mut self) {
7948        self.data_generation = self.data_generation.wrapping_add(1);
7949    }
7950
7951    /// Stable catalog table id for this mounted table.
7952    pub fn table_id(&self) -> u64 {
7953        self.table_id
7954    }
7955
7956    /// Seal every active delta (memtable, mutable-run tier, HOT, reverse-PK
7957    /// map, and every secondary index) so the current state can be captured
7958    /// as an immutable generation. Sealing moves the active delta behind the
7959    /// shared frozen `Arc` without copying row data; the writer keeps
7960    /// appending to a fresh, empty active delta (S1C-001).
7961    fn seal_generations(&mut self) {
7962        self.memtable.seal();
7963        self.mutable_run.seal();
7964        self.hot.seal();
7965        for index in self.bitmap.values_mut() {
7966            index.seal();
7967        }
7968        for index in self.ann.values_mut() {
7969            index.seal();
7970        }
7971        for index in self.fm.values_mut() {
7972            index.seal();
7973        }
7974        for index in self.sparse.values_mut() {
7975            index.seal();
7976        }
7977        for index in self.minhash.values_mut() {
7978            index.seal();
7979        }
7980        self.pk_by_row.seal();
7981    }
7982
7983    /// Capture the current (freshly sealed) state as an immutable
7984    /// [`ReadGeneration`]. Cheap by construction: frozen layers are
7985    /// `Arc`-shared, schema/run-refs are small metadata copies, and every
7986    /// active delta is empty post-seal.
7987    fn capture_read_generation(&self) -> ReadGeneration {
7988        let visible_through = self.current_epoch();
7989        ReadGeneration {
7990            schema: Arc::new(self.schema.clone()),
7991            base_runs: Arc::new(self.run_refs.clone()),
7992            deltas: TableDeltas {
7993                memtable: self.memtable.clone(),
7994                mutable_run: self.mutable_run.clone(),
7995                hot: self.hot.clone(),
7996                pk_by_row: self.pk_by_row.clone(),
7997            },
7998            indexes: Arc::new(IndexGeneration::capture(
7999                &self.bitmap,
8000                &self.learned_range,
8001                &self.fm,
8002                &self.ann,
8003                &self.sparse,
8004                &self.minhash,
8005                visible_through,
8006                // P0.5-T5: authoritative HLC readiness watermark.
8007                match &self.wal {
8008                    WalSink::Shared(shared) => shared
8009                        .hlc
8010                        .now()
8011                        .unwrap_or(mongreldb_types::hlc::HlcTimestamp::MAX),
8012                    _ => mongreldb_types::hlc::HlcTimestamp::MAX,
8013                },
8014            )),
8015            visible_through,
8016        }
8017    }
8018
8019    /// Seal the active deltas and atomically publish a replacement
8020    /// [`ReadGeneration`] (S1C-001/S1C-002). The publish is a single
8021    /// `ArcSwap` store: readers that pinned the previous `Arc` keep their
8022    /// stable view, new readers see this one. Returns the published view.
8023    pub fn publish_read_generation(&mut self) -> Result<Arc<ReadGeneration>> {
8024        self.ensure_indexes_complete()?;
8025        self.seal_generations();
8026        let view = Arc::new(self.capture_read_generation());
8027        self.published.store(Arc::clone(&view));
8028        Ok(view)
8029    }
8030
8031    /// The most recently published immutable read view. Pinning the returned
8032    /// `Arc` keeps its structurally-shared frozen layers alive. The view is
8033    /// seeded empty at open/create and refreshed by
8034    /// [`Table::publish_read_generation`], [`Table::flush`], and read-
8035    /// generation creation.
8036    pub fn published_read_generation(&self) -> Arc<ReadGeneration> {
8037        self.published.load_full()
8038    }
8039
8040    /// The table's unified version-retention pin registry (S1C-004).
8041    pub fn pin_registry(&self) -> &Arc<crate::retention::PinRegistry> {
8042        &self.pins
8043    }
8044
8045    /// S1C-004: the epoch floor for version reclamation — a version may be
8046    /// reclaimed only when older than every pin source. Equals
8047    /// [`Table::min_active_snapshot`], or the current visible epoch when
8048    /// nothing is pinned (nothing older than the floor can still be needed).
8049    pub fn version_gc_floor(&self) -> Epoch {
8050        self.min_active_snapshot()
8051            .unwrap_or_else(|| self.current_epoch())
8052    }
8053
8054    /// S1C-004 diagnostics: every active version-retention pin source.
8055    /// Registered pins (read generations, and later backup/PITR, replication,
8056    /// online index builds) come from the [`crate::retention::PinRegistry`];
8057    /// the oldest transaction snapshot (local pins plus the shared
8058    /// [`crate::retention::SnapshotRegistry`]) and the configured history
8059    /// window are projected into the report so all six sources are visible.
8060    pub fn version_pins_report(&self) -> crate::retention::PinsReport {
8061        let mut report = self.pins.report();
8062        let transaction_floor = [
8063            self.pinned.keys().next().copied(),
8064            self.snapshots.min_pinned(),
8065        ]
8066        .into_iter()
8067        .flatten()
8068        .min();
8069        if let Some(epoch) = transaction_floor {
8070            report.record_projection(crate::retention::PinSource::TransactionSnapshot, epoch);
8071        }
8072        if let Some(floor) = self.snapshots.history_floor(self.current_epoch()) {
8073            report.record_projection(crate::retention::PinSource::HistoryRetention, floor);
8074        }
8075        report
8076    }
8077
8078    pub(crate) fn clone_read_generation(&mut self) -> Result<Self> {
8079        self.publish_read_generation()?;
8080        let mut generation = self.clone();
8081        generation.read_only = true;
8082        generation.wal = WalSink::ReadOnly;
8083        generation.pending_delete_rids.clear();
8084        generation.pending_put_cols.clear();
8085        generation.pending_rows.clear();
8086        generation.pending_rows_auto_inc.clear();
8087        generation.pending_dels.clear();
8088        generation.pending_truncate = None;
8089        generation.agg_cache = Arc::new(HashMap::new());
8090        // The pinned generation keeps the view published at its birth, not
8091        // the writer's live cell: later publishes must not mutate it.
8092        generation.published = Arc::new(ArcSwap::new(self.published.load_full()));
8093        // S1C-004: the generation pins its birth epoch until it drops, so
8094        // version GC can never reclaim versions it still reads.
8095        generation.read_generation_pin = Some(Arc::new(self.pins.pin(
8096            crate::retention::PinSource::ReadGeneration,
8097            self.current_epoch(),
8098        )));
8099        Ok(generation)
8100    }
8101
8102    pub(crate) fn estimated_clone_bytes(&self) -> u64 {
8103        (std::mem::size_of::<Self>() as u64)
8104            .saturating_add(self.memtable.approx_bytes())
8105            .saturating_add(self.mutable_run.approx_bytes())
8106            .saturating_add(self.live_count.saturating_mul(64))
8107    }
8108
8109    /// Pin the current read snapshot; compaction will preserve the versions it
8110    /// needs until [`Table::unpin_snapshot`] is called.
8111    ///
8112    /// Mounted (shared-WAL) tables pin HLC via [`Snapshot::at_hlc`] so HLC is
8113    /// the cluster-wide authority. Standalone private-WAL tables remain
8114    /// epoch-only until they stamp HLC on commit.
8115    pub fn pin_snapshot(&mut self) -> Snapshot {
8116        let snap = self.snapshot();
8117        *self.pinned.entry(snap.epoch).or_insert(0) += 1;
8118        snap
8119    }
8120
8121    /// P0.5-T6: report the HLC GC floor as named pin sources.
8122    ///
8123    /// Epoch pins that cannot yet be projected to a durable HLC report
8124    /// [`HlcTimestamp::ZERO`](mongreldb_types::hlc::HlcTimestamp::ZERO). Physical
8125    /// reclamation still consults the epoch floor ([`Self::version_gc_floor`]).
8126    ///
8127    /// `project_epoch` maps a local pin epoch to an HLC (typically
8128    /// [`crate::Database::commit_ts_for_epoch`]); return `None` / `ZERO` when
8129    /// the epoch has no durable stamp.
8130    pub fn hlc_gc_floor(
8131        &self,
8132        mut project_epoch: impl FnMut(Epoch) -> Option<mongreldb_types::hlc::HlcTimestamp>,
8133    ) -> crate::epoch::GcFloor {
8134        let mut project = |epoch: Option<Epoch>| -> mongreldb_types::hlc::HlcTimestamp {
8135            epoch
8136                .and_then(&mut project_epoch)
8137                .filter(|ts| *ts != mongreldb_types::hlc::HlcTimestamp::ZERO)
8138                .unwrap_or(mongreldb_types::hlc::HlcTimestamp::ZERO)
8139        };
8140        let transaction = [
8141            self.pinned.keys().next().copied(),
8142            self.snapshots.min_pinned(),
8143        ]
8144        .into_iter()
8145        .flatten()
8146        .min();
8147        let history = self.snapshots.history_floor(self.current_epoch());
8148        crate::epoch::GcFloor {
8149            transaction_snapshot: project(transaction),
8150            history_retention: project(history),
8151            backup_pitr: project(
8152                self.pins
8153                    .oldest_for(crate::retention::PinSource::BackupPitr),
8154            ),
8155            replication: project(
8156                self.pins
8157                    .oldest_for(crate::retention::PinSource::Replication),
8158            ),
8159            read_generation: project(
8160                self.pins
8161                    .oldest_for(crate::retention::PinSource::ReadGeneration),
8162            ),
8163            online_index_build: project(
8164                self.pins
8165                    .oldest_for(crate::retention::PinSource::OnlineIndexBuild),
8166            ),
8167        }
8168    }
8169
8170    /// Release a pinned snapshot.
8171    pub fn unpin_snapshot(&mut self, snap: Snapshot) {
8172        if let Some(count) = self.pinned.get_mut(&snap.epoch) {
8173            *count -= 1;
8174            if *count == 0 {
8175                self.pinned.remove(&snap.epoch);
8176            }
8177        }
8178    }
8179
8180    /// Oldest pinned snapshot epoch, or `None` if no snapshot is active.
8181    /// Lowest snapshot epoch that compaction must preserve a version for, or
8182    /// `None` when no reader is pinned anywhere. Considers BOTH the single-table
8183    /// local pin set (`self.pinned`, used by the standalone `pin_snapshot` API)
8184    /// AND the shared `Database` snapshot registry (`db.snapshot()` readers) —
8185    /// otherwise a multi-table reader's version could be dropped by a compaction
8186    /// triggered on its table (the registry-gated reaper would then keep the
8187    /// old run *files*, but readers only scan the merged run, so the version
8188    /// would still be lost). Also folds in the unified [`crate::retention::PinRegistry`]
8189    /// (S1C-004): backup/PITR, replication, cursor/read-generation, and
8190    /// online-index-build pins all gate version reclamation here.
8191    pub(crate) fn min_active_snapshot(&self) -> Option<Epoch> {
8192        let local = self.pinned.keys().next().copied();
8193        let global = self.snapshots.min_pinned();
8194        let history = self.snapshots.history_floor(self.current_epoch());
8195        let pinned = self.pins.oldest_pinned();
8196        [local, global, history, pinned].into_iter().flatten().min()
8197    }
8198
8199    /// Configure timestamp-column retention on a standalone table. Mounted
8200    /// databases should use [`crate::Database::set_table_ttl`] so the DDL is
8201    /// WAL-replicated.
8202    pub fn set_ttl(&mut self, column_name: &str, duration_nanos: u64) -> Result<()> {
8203        self.ensure_writable()?;
8204        let policy = self.prepare_ttl_policy(column_name, duration_nanos)?;
8205        self.apply_ttl_policy_at(Some(policy), self.current_epoch())
8206    }
8207
8208    pub fn clear_ttl(&mut self) -> Result<()> {
8209        self.ensure_writable()?;
8210        self.apply_ttl_policy_at(None, self.current_epoch())
8211    }
8212
8213    pub fn ttl(&self) -> Option<TtlPolicy> {
8214        self.ttl
8215    }
8216
8217    pub(crate) fn prepare_ttl_policy(
8218        &self,
8219        column_name: &str,
8220        duration_nanos: u64,
8221    ) -> Result<TtlPolicy> {
8222        if duration_nanos == 0 || duration_nanos > i64::MAX as u64 {
8223            return Err(MongrelError::InvalidArgument(
8224                "TTL duration must be between 1 and i64::MAX nanoseconds".into(),
8225            ));
8226        }
8227        let column = self
8228            .schema
8229            .columns
8230            .iter()
8231            .find(|column| column.name == column_name)
8232            .ok_or_else(|| MongrelError::Schema(format!("unknown TTL column {column_name}")))?;
8233        if column.ty != TypeId::TimestampNanos {
8234            return Err(MongrelError::Schema(format!(
8235                "TTL column {column_name} must be TimestampNanos, is {:?}",
8236                column.ty
8237            )));
8238        }
8239        Ok(TtlPolicy {
8240            column_id: column.id,
8241            duration_nanos,
8242        })
8243    }
8244
8245    pub(crate) fn apply_ttl_policy_at(
8246        &mut self,
8247        policy: Option<TtlPolicy>,
8248        epoch: Epoch,
8249    ) -> Result<()> {
8250        if let Some(policy) = policy {
8251            let column = self
8252                .schema
8253                .columns
8254                .iter()
8255                .find(|column| column.id == policy.column_id)
8256                .ok_or_else(|| {
8257                    MongrelError::Schema(format!("unknown TTL column id {}", policy.column_id))
8258                })?;
8259            if column.ty != TypeId::TimestampNanos
8260                || policy.duration_nanos == 0
8261                || policy.duration_nanos > i64::MAX as u64
8262            {
8263                return Err(MongrelError::Schema("invalid TTL policy".into()));
8264            }
8265        }
8266        self.ttl = policy;
8267        self.agg_cache = Arc::new(HashMap::new());
8268        self.clear_result_cache();
8269        let _ = std::fs::remove_dir_all(self.dir.join("_shadow"));
8270        self.persist_manifest(epoch)
8271    }
8272
8273    pub(crate) fn row_expired_at(&self, row: &Row, now_nanos: i64) -> bool {
8274        let Some(policy) = self.ttl else {
8275            return false;
8276        };
8277        let Some(Value::Int64(timestamp)) = row.columns.get(&policy.column_id) else {
8278            return false;
8279        };
8280        timestamp.saturating_add(policy.duration_nanos as i64) <= now_nanos
8281    }
8282
8283    pub fn current_epoch(&self) -> Epoch {
8284        self.epoch.visible()
8285    }
8286
8287    pub fn memtable_len(&self) -> usize {
8288        self.memtable.len()
8289    }
8290
8291    /// Live row count. O(1) without TTL; TTL tables scan because wall-clock
8292    /// expiry can change without a commit epoch.
8293    pub fn count(&self) -> u64 {
8294        if self.ttl.is_none()
8295            && self.pending_put_cols.is_empty()
8296            && self.pending_delete_rids.is_empty()
8297            && self.pending_rows.is_empty()
8298            && self.pending_dels.is_empty()
8299            && self.pending_truncate.is_none()
8300        {
8301            self.live_count
8302        } else {
8303            self.visible_rows(self.snapshot())
8304                .map(|rows| rows.len() as u64)
8305                .unwrap_or(self.live_count)
8306        }
8307    }
8308
8309    /// Count rows matching an index-backed conjunctive predicate without
8310    /// materializing projected columns. Returns `None` when a condition cannot
8311    /// be served by the native predicate resolver.
8312    pub fn count_conditions(
8313        &mut self,
8314        conditions: &[crate::query::Condition],
8315        snapshot: Snapshot,
8316    ) -> Result<Option<u64>> {
8317        use crate::query::Condition;
8318        if self.ttl.is_some() {
8319            if conditions.is_empty() {
8320                return Ok(Some(self.visible_rows(snapshot)?.len() as u64));
8321            }
8322            let mut sets = Vec::with_capacity(conditions.len());
8323            for condition in conditions {
8324                sets.push(self.resolve_condition(condition, snapshot)?);
8325            }
8326            let survivors = RowIdSet::intersect_many(sets);
8327            let rows = self.visible_rows(snapshot)?;
8328            return Ok(Some(
8329                rows.into_iter()
8330                    .filter(|row| survivors.contains(row.row_id.0))
8331                    .count() as u64,
8332            ));
8333        }
8334        if conditions.is_empty() {
8335            return Ok(Some(self.count()));
8336        }
8337        let served = |c: &Condition| {
8338            matches!(
8339                c,
8340                Condition::Pk(_)
8341                    | Condition::BitmapEq { .. }
8342                    | Condition::BitmapIn { .. }
8343                    | Condition::BytesPrefix { .. }
8344                    | Condition::FmContains { .. }
8345                    | Condition::FmContainsAll { .. }
8346                    | Condition::Ann { .. }
8347                    | Condition::Range { .. }
8348                    | Condition::RangeF64 { .. }
8349                    | Condition::SparseMatch { .. }
8350                    | Condition::MinHashSimilar { .. }
8351                    | Condition::IsNull { .. }
8352                    | Condition::IsNotNull { .. }
8353            )
8354        };
8355        if !conditions.iter().all(served) {
8356            return Ok(None);
8357        }
8358        self.ensure_indexes_complete()?;
8359        if !self.pending_put_cols.is_empty()
8360            || !self.pending_delete_rids.is_empty()
8361            || !self.pending_rows.is_empty()
8362            || !self.pending_dels.is_empty()
8363            || self.pending_truncate.is_some()
8364        {
8365            let mut sets = Vec::with_capacity(conditions.len());
8366            for condition in conditions {
8367                sets.push(self.resolve_condition(condition, snapshot)?);
8368            }
8369            let rids = RowIdSet::intersect_many(sets).into_sorted_vec();
8370            return Ok(Some(self.rows_for_rids(&rids, snapshot)?.len() as u64));
8371        }
8372        let mut sets = Vec::with_capacity(conditions.len());
8373        for condition in conditions {
8374            sets.push(self.resolve_condition(condition, snapshot)?);
8375        }
8376        let mut rids = RowIdSet::intersect_many(sets);
8377        // §5.1: the in-memory indexes (bitmap/FM/ANN/sparse/minhash) are
8378        // append-only across puts (`index_row` adds entries but
8379        // `tombstone_row` never removes them), so deletes and PK-displacing
8380        // updates leave behind entries for now-tombstoned row-ids. The
8381        // materialize paths (`query`, `query_columns_native`) already drop
8382        // these via MVCC visibility during row fetch; only the count fast
8383        // path trusts raw index cardinality, so prune tombstoned overlay
8384        // row-ids here. On a clean table (empty overlay) the bitmap was
8385        // rebuilt at flush and is authoritative — the prune is skipped.
8386        if !self.memtable.is_empty() || !self.mutable_run.is_empty() {
8387            rids.remove_many(self.overlay_tombstoned_rids(snapshot));
8388        }
8389        let count = rids.len() as u64;
8390        crate::trace::QueryTrace::record(|t| {
8391            t.scan_mode = crate::trace::ScanMode::CountSurvivors;
8392            t.survivor_count = Some(count as usize);
8393            t.conditions_pushed = conditions.len();
8394        });
8395        Ok(Some(count))
8396    }
8397
8398    /// Row-ids whose newest visible overlay version is a tombstone. Used to
8399    /// prune stale entries left behind by the append-only in-memory indexes
8400    /// (see `count_conditions`). Only unflushed tombstones matter — a flush
8401    /// rebuilds indexes from runs and excludes tombstoned rows. (§5.1)
8402    fn overlay_tombstoned_rids(&self, snapshot: Snapshot) -> Vec<u64> {
8403        let mut out = Vec::new();
8404        for row in self.memtable.visible_versions(snapshot.epoch) {
8405            if row.deleted {
8406                out.push(row.row_id.0);
8407            }
8408        }
8409        for row in self.mutable_run.visible_versions(snapshot.epoch) {
8410            if row.deleted {
8411                out.push(row.row_id.0);
8412            }
8413        }
8414        out
8415    }
8416
8417    /// Bulk-load typed columns straight to a new run — the fast ingest path.
8418    /// Bypasses the WAL, the memtable, and the `Value` enum entirely; writes one
8419    /// compressed run (delta for sorted Int64, dictionary for low-card Bytes)
8420    /// with **LZ4** (Phase 15.3 — fast decode for scan-heavy analytical runs),
8421    /// rotates the WAL, and persists the manifest in a single fsync group.
8422    /// Index building follows [`Table::index_build_policy`]: deferred to the
8423    /// first query/flush by default, or bulk-built inline from the typed
8424    /// columns (Phase 14.2) under [`IndexBuildPolicy::Eager`].
8425    pub fn bulk_load_columns(
8426        &mut self,
8427        user_columns: Vec<(u16, columnar::NativeColumn)>,
8428    ) -> Result<Epoch> {
8429        self.bulk_load_columns_with(user_columns, 3, false, true)
8430    }
8431
8432    /// Maximal-throughput bulk ingest (Phase 14.4): skip zstd entirely and write
8433    /// raw `ALGO_PLAIN` pages. ~3–4× the encode throughput of
8434    /// [`Self::bulk_load_columns`] at ~3–4× the on-disk size — the right choice
8435    /// when ingest latency dominates and a background compaction will re-compress
8436    /// later. Indexing, WAL rotation, and the manifest are identical to
8437    /// [`Self::bulk_load_columns`].
8438    pub fn bulk_load_fast(
8439        &mut self,
8440        user_columns: Vec<(u16, columnar::NativeColumn)>,
8441    ) -> Result<Epoch> {
8442        self.bulk_load_columns_with(user_columns, -1, true, false)
8443    }
8444
8445    fn bulk_load_columns_with(
8446        &mut self,
8447        mut user_columns: Vec<(u16, columnar::NativeColumn)>,
8448        zstd_level: i32,
8449        force_plain: bool,
8450        lz4: bool,
8451    ) -> Result<Epoch> {
8452        self.ensure_writable()?;
8453        let n = user_columns.first().map(|(_, c)| c.len()).unwrap_or(0);
8454        if n == 0 {
8455            return Ok(self.current_epoch());
8456        }
8457        let epoch = self.commit_new_epoch()?;
8458        let live_before = self.live_count;
8459        // Spill pending mutable-run data before the Flush marker + WAL rotation.
8460        self.spill_mutable_run(epoch)?;
8461        let eager_index_build = self.index_build_policy == IndexBuildPolicy::Eager
8462            && self.indexes_complete
8463            && self.run_refs.is_empty()
8464            && self.memtable.is_empty()
8465            && self.mutable_run.is_empty();
8466        // Enforce NOT NULL constraints and primary-key upsert semantics before
8467        // any row id is allocated or bytes hit the run file.
8468        self.fill_auto_inc_native_columns(&mut user_columns, n)?;
8469        self.validate_columns_not_null(&user_columns, n)?;
8470        let winner_idx = self
8471            .bulk_pk_winner_indices(&user_columns, n)
8472            .filter(|idx| idx.len() != n);
8473        let (write_columns, write_n): (Vec<(u16, columnar::NativeColumn)>, usize) =
8474            match winner_idx.as_deref() {
8475                Some(idx) => {
8476                    let compacted = user_columns
8477                        .iter()
8478                        .map(|(id, c)| (*id, c.gather(idx)))
8479                        .collect();
8480                    (compacted, idx.len())
8481                }
8482                None => (user_columns, n),
8483            };
8484        self.advance_auto_inc_from_native_columns(&write_columns, write_n, live_before)?;
8485        let first = self.allocator.alloc_range(write_n as u64)?.0;
8486        for rid in first..first + write_n as u64 {
8487            self.reservoir.offer(rid);
8488        }
8489        let run_id = self.alloc_run_id()?;
8490        let path = self.run_path(run_id);
8491        let mut writer =
8492            RunWriter::new(&self.schema, run_id as u128, epoch, 0).with_native_endian();
8493        if force_plain {
8494            writer = writer.with_plain();
8495        } else if lz4 {
8496            // Phase 15.3: bulk-loaded analytical runs are scan-heavy, so encode
8497            // them with LZ4 (3–5× faster decode, ~10% worse ratio than zstd).
8498            writer = writer.with_lz4();
8499        } else {
8500            writer = writer.with_zstd_level(zstd_level);
8501        }
8502        if let Some(kek) = &self.kek {
8503            writer = writer.with_encryption(kek.as_ref(), self.indexable_column_specs());
8504        }
8505        let header = match self.create_run_file(run_id)? {
8506            Some(file) => writer.write_native_file(file, &write_columns, write_n, first)?,
8507            None => writer.write_native(&path, &write_columns, write_n, first)?,
8508        };
8509        self.run_refs.push(RunRef {
8510            run_id: run_id as u128,
8511            level: 0,
8512            epoch_created: epoch.0,
8513            row_count: header.row_count,
8514        });
8515        self.live_count = self.live_count.saturating_add(write_n as u64);
8516        if eager_index_build {
8517            let row_ids: Vec<u64> = (first..first + write_n as u64).collect();
8518            self.index_columns_bulk(&write_columns, &row_ids);
8519            self.indexes_complete = true;
8520            self.build_learned_ranges()?;
8521        } else {
8522            // Phase 14.7: defer index building off the ingest critical path for
8523            // non-empty tables where cross-run PK/update semantics must be
8524            // reconstructed from durable state.
8525            self.indexes_complete = false;
8526        }
8527        self.mark_flushed(epoch)?;
8528        self.persist_manifest(epoch)?;
8529        if eager_index_build {
8530            self.checkpoint_indexes(epoch);
8531        }
8532        self.clear_result_cache();
8533        self.data_generation = self.data_generation.wrapping_add(1);
8534        Ok(epoch)
8535    }
8536
8537    /// Bulk-build the live in-memory indexes (HOT/bitmap/FM/sparse) straight
8538    /// from typed columns — the deferred batch-indexing path (Phase 14.2).
8539    ///
8540    /// Replaces the per-row `index_into` loop: no `Row`, no per-row
8541    /// `HashMap<u16, Value>`, no `Value` enum. Index keys are computed directly
8542    /// from the typed buffers via [`columnar::encode_key_native`], tokenized for
8543    /// `ENCRYPTED_INDEXABLE` columns the same way `index_into` on a tokenized
8544    /// row would. FM is appended dirty and rebuilt once on the next query; the
8545    /// others are populated in a single typed pass. Entries are merged into the
8546    /// existing indexes so this is correct under multi-run loads and partial
8547    /// reindexes.
8548    ///
8549    /// `row_ids[i]` is the `RowId` of element `i` of every column. ANN
8550    /// (`IndexKind::Ann`) is intentionally skipped: the native codec carries no
8551    /// embeddings, so an `Embedding` column can never reach this path (a native
8552    /// bulk load of an embedding schema fails at encode). LearnedRange is built
8553    /// separately from the runs by [`Self::build_learned_ranges`].
8554    fn index_columns_bulk(&mut self, columns: &[(u16, columnar::NativeColumn)], row_ids: &[u64]) {
8555        let n = row_ids.len();
8556        if n == 0 {
8557            return;
8558        }
8559        let by_id: std::collections::HashMap<u16, &columnar::NativeColumn> =
8560            columns.iter().map(|(id, c)| (*id, c)).collect();
8561        let ty_of: std::collections::HashMap<u16, TypeId> = self
8562            .schema
8563            .columns
8564            .iter()
8565            .map(|c| (c.id, c.ty.clone()))
8566            .collect();
8567        let pk_id = self.schema.primary_key().map(|c| c.id);
8568
8569        for (i, &rid) in row_ids.iter().enumerate() {
8570            let row_id = RowId(rid);
8571            if let Some(pid) = pk_id {
8572                if let Some(col) = by_id.get(&pid) {
8573                    let ty = ty_of.get(&pid).cloned().unwrap_or(TypeId::Int64);
8574                    if let Some(key) = bulk_index_key(&self.column_keys, pid, ty, col, i) {
8575                        self.insert_hot_pk(key, row_id);
8576                    }
8577                }
8578            }
8579            for idef in &self.schema.indexes {
8580                let Some(col) = by_id.get(&idef.column_id) else {
8581                    continue;
8582                };
8583                let ty = ty_of.get(&idef.column_id).cloned().unwrap_or(TypeId::Int64);
8584                match idef.kind {
8585                    IndexKind::Bitmap => {
8586                        if let Some(b) = self.bitmap.get_mut(&idef.column_id) {
8587                            if let Some(key) =
8588                                bulk_index_key(&self.column_keys, idef.column_id, ty, col, i)
8589                            {
8590                                b.insert(key, row_id);
8591                            }
8592                        }
8593                    }
8594                    IndexKind::FmIndex => {
8595                        if let Some(f) = self.fm.get_mut(&idef.column_id) {
8596                            if let Some(bytes) = columnar::native_bytes_at(col, i) {
8597                                f.insert(bytes.to_vec(), row_id);
8598                            }
8599                        }
8600                    }
8601                    IndexKind::Sparse => {
8602                        if let Some(s) = self.sparse.get_mut(&idef.column_id) {
8603                            if let Some(bytes) = columnar::native_bytes_at(col, i) {
8604                                if let Ok(terms) = bincode::deserialize::<Vec<(u32, f32)>>(bytes) {
8605                                    s.insert(&terms, row_id);
8606                                }
8607                            }
8608                        }
8609                    }
8610                    IndexKind::MinHash => {
8611                        if let Some(mh) = self.minhash.get_mut(&idef.column_id) {
8612                            if let Some(bytes) = columnar::native_bytes_at(col, i) {
8613                                let tokens = crate::index::token_hashes_from_bytes(bytes);
8614                                mh.insert(&tokens, row_id);
8615                            }
8616                        }
8617                    }
8618                    _ => {}
8619                }
8620            }
8621        }
8622    }
8623
8624    /// no `Value`). Fast path: empty memtable + single run decodes columns
8625    /// directly and gathers visible indices; falls back to the `Value` path
8626    /// pivoted to native columns otherwise. `projection` (a set of column ids)
8627    /// limits decoding to the requested columns — `None` ⇒ all user columns.
8628    pub fn visible_columns_native(
8629        &self,
8630        snapshot: Snapshot,
8631        projection: Option<&[u16]>,
8632    ) -> Result<Vec<(u16, columnar::NativeColumn)>> {
8633        self.visible_columns_native_inner(snapshot, projection, None)
8634    }
8635
8636    pub fn visible_columns_native_with_control(
8637        &self,
8638        snapshot: Snapshot,
8639        projection: Option<&[u16]>,
8640        control: &crate::ExecutionControl,
8641    ) -> Result<Vec<(u16, columnar::NativeColumn)>> {
8642        self.visible_columns_native_inner(snapshot, projection, Some(control))
8643    }
8644
8645    fn visible_columns_native_inner(
8646        &self,
8647        snapshot: Snapshot,
8648        projection: Option<&[u16]>,
8649        control: Option<&crate::ExecutionControl>,
8650    ) -> Result<Vec<(u16, columnar::NativeColumn)>> {
8651        execution_checkpoint(control, 0)?;
8652        let wanted: Vec<u16> = match projection {
8653            Some(p) => p.to_vec(),
8654            None => self.schema.columns.iter().map(|c| c.id).collect(),
8655        };
8656        if self.ttl.is_none()
8657            && self.memtable.is_empty()
8658            && self.mutable_run.is_empty()
8659            && self.run_refs.len() == 1
8660        {
8661            let rr = self.run_refs[0].clone();
8662            let mut reader = self.open_reader(rr.run_id)?;
8663            let idxs = reader.visible_indices_native(snapshot.epoch)?;
8664            execution_checkpoint(control, 0)?;
8665            let all_visible = idxs.len() == reader.row_count();
8666            // Phase 15.1: decode every requested column in parallel when the
8667            // reader is mmap-backed. Each column already parallel-decodes its
8668            // own pages, so a wide table saturates the pool via nested rayon
8669            // without oversubscribing (work-stealing handles it). Falls back to
8670            // the sequential `&mut` path when mmap is unavailable.
8671            if reader.has_mmap() && control.is_none() {
8672                use rayon::prelude::*;
8673                // Pre-resolve the requested ids that exist in the schema (don't
8674                // capture `self` inside the rayon closure).
8675                let valid: Vec<u16> = wanted
8676                    .iter()
8677                    .filter(|cid| self.schema.columns.iter().any(|c| c.id == **cid))
8678                    .copied()
8679                    .collect();
8680                // Decode concurrently; `collect` preserves `valid` order.
8681                let decoded: Vec<(u16, columnar::NativeColumn)> = valid
8682                    .par_iter()
8683                    .filter_map(|cid| {
8684                        reader
8685                            .column_native_shared(*cid)
8686                            .ok()
8687                            .map(|col| (*cid, col))
8688                    })
8689                    .collect();
8690                let cols = decoded
8691                    .into_iter()
8692                    .map(|(id, col)| (id, if all_visible { col } else { col.gather(&idxs) }))
8693                    .collect();
8694                return Ok(cols);
8695            }
8696            let mut cols = Vec::with_capacity(wanted.len());
8697            for (index, cid) in wanted.iter().enumerate() {
8698                execution_checkpoint(control, index)?;
8699                let cdef = match self.schema.columns.iter().find(|c| c.id == *cid) {
8700                    Some(c) => c,
8701                    None => continue,
8702                };
8703                let col = reader.column_native(cdef.id)?;
8704                cols.push((cdef.id, if all_visible { col } else { col.gather(&idxs) }));
8705            }
8706            return Ok(cols);
8707        }
8708        let vcols = self.visible_columns(snapshot)?;
8709        execution_checkpoint(control, 0)?;
8710        let want_set: std::collections::HashSet<u16> = wanted.iter().copied().collect();
8711        let out: Vec<(u16, columnar::NativeColumn)> = vcols
8712            .into_iter()
8713            .filter(|(id, _)| want_set.contains(id))
8714            .map(|(id, vals)| {
8715                let ty = self
8716                    .schema
8717                    .columns
8718                    .iter()
8719                    .find(|c| c.id == id)
8720                    .map(|c| c.ty.clone())
8721                    .unwrap_or(TypeId::Bytes);
8722                (id, columnar::values_to_native(ty, &vals))
8723            })
8724            .collect();
8725        Ok(out)
8726    }
8727
8728    pub fn run_count(&self) -> usize {
8729        self.run_refs.len()
8730    }
8731
8732    /// Whether the memtable is empty (no unflushed puts).
8733    pub fn memtable_is_empty(&self) -> bool {
8734        self.memtable.is_empty()
8735    }
8736
8737    /// Cumulative raw-page-cache hit/miss counts (Priority 14: hit visibility).
8738    /// Useful for confirming a repeat scan is served from cache or measuring a
8739    /// query's locality after [`reset_page_cache_stats`](Self::reset_page_cache_stats).
8740    pub fn page_cache_stats(&self) -> crate::cache::CacheStats {
8741        self.page_cache.stats()
8742    }
8743
8744    /// Zero the raw-page-cache hit/miss counters.
8745    pub fn reset_page_cache_stats(&self) {
8746        self.page_cache.reset_stats();
8747    }
8748
8749    /// The run IDs in level order (Phase 15.5: used by the Arrow IPC shadow to
8750    /// key shadow files and detect stale shadows).
8751    pub fn run_ids(&self) -> Vec<u128> {
8752        self.run_refs.iter().map(|r| r.run_id).collect()
8753    }
8754
8755    /// Whether the single run (if exactly one) is clean — i.e. has
8756    /// `RUN_FLAG_CLEAN` set (Phase 15.5: the shadow is zero-copy only for clean
8757    /// runs).
8758    pub fn single_run_is_clean(&self) -> bool {
8759        if self.ttl.is_some() || self.run_refs.len() != 1 {
8760            return false;
8761        }
8762        self.open_reader(self.run_refs[0].run_id)
8763            .map(|r| r.is_clean())
8764            .unwrap_or(false)
8765    }
8766
8767    /// Best-effort resolve of the survivor RowId set for fine-grained cache
8768    /// invalidation (hardening (c)). On the single-run fast path, opens a reader
8769    /// and calls `resolve_survivor_rids`. On the multi-run/memtable path,
8770    /// returns an empty bitmap — conservative (condition_cols still catches
8771    /// column mutations, and deletes are caught by the epoch-free design falling
8772    /// through to the multi-run path which re-resolves).
8773    fn resolve_footprint(
8774        &self,
8775        conditions: &[crate::query::Condition],
8776        snapshot: Snapshot,
8777    ) -> roaring::RoaringBitmap {
8778        if !self.memtable.is_empty() || !self.mutable_run.is_empty() {
8779            return roaring::RoaringBitmap::new();
8780        }
8781        if self.run_refs.is_empty() {
8782            return roaring::RoaringBitmap::new();
8783        }
8784        // Try the single-run fast path.
8785        if self.run_refs.len() == 1 {
8786            if let Ok(mut reader) = self.open_reader(self.run_refs[0].run_id) {
8787                if let Ok(rids) = self.resolve_survivor_rids(conditions, &mut reader, snapshot) {
8788                    return rids.to_roaring_lossy();
8789                }
8790            }
8791        }
8792        roaring::RoaringBitmap::new()
8793    }
8794
8795    /// Phase 19.1 + hardening (c): a cached form of
8796    /// [`Table::query_columns_native`]. The cache key embeds the snapshot epoch
8797    /// so two queries at different pinned snapshots never share an entry;
8798    /// invalidation is fine-grained — a `commit()` drops only entries whose
8799    /// footprint intersects a deleted RowId or whose condition-columns intersect
8800    /// a mutated column. On a miss the underlying `query_columns_native` runs and
8801    /// the result is cached as typed `NativeColumn`s. Returns `None` exactly when
8802    /// the non-cached path would (conditions not pushdown-served). Strictly
8803    /// additive — callers wanting fresh results keep using
8804    /// `query_columns_native`.
8805    pub fn query_columns_native_cached(
8806        &mut self,
8807        conditions: &[crate::query::Condition],
8808        projection: Option<&[u16]>,
8809        snapshot: Snapshot,
8810    ) -> Result<Option<Vec<(u16, columnar::NativeColumn)>>> {
8811        self.query_columns_native_cached_inner(conditions, projection, snapshot, None)
8812    }
8813
8814    pub fn query_columns_native_cached_with_control(
8815        &mut self,
8816        conditions: &[crate::query::Condition],
8817        projection: Option<&[u16]>,
8818        snapshot: Snapshot,
8819        control: &crate::ExecutionControl,
8820    ) -> Result<Option<Vec<(u16, columnar::NativeColumn)>>> {
8821        self.query_columns_native_cached_inner(conditions, projection, snapshot, Some(control))
8822    }
8823
8824    fn query_columns_native_cached_inner(
8825        &mut self,
8826        conditions: &[crate::query::Condition],
8827        projection: Option<&[u16]>,
8828        snapshot: Snapshot,
8829        control: Option<&crate::ExecutionControl>,
8830    ) -> Result<Option<Vec<(u16, columnar::NativeColumn)>>> {
8831        execution_checkpoint(control, 0)?;
8832        // Wall-clock expiry changes without an MVCC epoch, so an epoch-keyed
8833        // result can become stale while sitting in the cache.
8834        if self.ttl.is_some() {
8835            return self.query_columns_native_inner(conditions, projection, snapshot, control);
8836        }
8837        if conditions.is_empty() {
8838            return self.query_columns_native_inner(conditions, projection, snapshot, control);
8839        }
8840        // The snapshot epoch is part of the key so two queries with identical
8841        // conditions/projection but pinned at different snapshots never share a
8842        // cached result (MVCC isolation for the explicit-snapshot API).
8843        let key = crate::query::canonical_query_key(conditions, projection, snapshot.epoch.0);
8844        if let Some(hit) = self.result_cache.lock().get_columns(key) {
8845            crate::trace::QueryTrace::record(|t| {
8846                t.result_cache_hit = true;
8847                t.scan_mode = crate::trace::ScanMode::NativePushdown;
8848            });
8849            return Ok(Some((*hit).clone()));
8850        }
8851        let res = self.query_columns_native_inner(conditions, projection, snapshot, control)?;
8852        execution_checkpoint(control, 0)?;
8853        if let Some(cols) = &res {
8854            let footprint = self.resolve_footprint(conditions, snapshot);
8855            let condition_cols = crate::query::condition_columns(conditions);
8856            execution_checkpoint(control, 0)?;
8857            self.result_cache.lock().insert(
8858                key,
8859                CachedEntry {
8860                    data: CachedData::Columns(Arc::new(cols.clone())),
8861                    footprint,
8862                    condition_cols,
8863                },
8864            );
8865        }
8866        Ok(res)
8867    }
8868
8869    /// Phase 19.1 + hardening (c): a cached form of [`Table::query`]. The cache key
8870    /// is epoch-independent; invalidation is fine-grained (see
8871    /// [`Table::query_columns_native_cached`]). On a hit returns the cached rows (no
8872    /// re-resolve, no re-decode).
8873    pub fn query_cached(&mut self, q: &crate::query::Query) -> Result<Vec<Row>> {
8874        if self.ttl.is_some() {
8875            return self.query(q);
8876        }
8877        if q.conditions.is_empty() {
8878            return self.query(q);
8879        }
8880        let key = crate::query::canonical_query_key(&q.conditions, None, 0)
8881            ^ (q.limit.unwrap_or(usize::MAX) as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)
8882            ^ (q.offset as u64).wrapping_mul(0xC2B2_AE3D_27D4_EB4F);
8883        if let Some(hit) = self.result_cache.lock().get_rows(key) {
8884            crate::trace::QueryTrace::record(|t| {
8885                t.result_cache_hit = true;
8886                t.scan_mode = crate::trace::ScanMode::Materialized;
8887            });
8888            return Ok((*hit).clone());
8889        }
8890        let rows = self.query(q)?;
8891        let footprint = rows.iter().map(|r| r.row_id.0 as u32).collect();
8892        let condition_cols = crate::query::condition_columns(&q.conditions);
8893        self.result_cache.lock().insert(
8894            key,
8895            CachedEntry {
8896                data: CachedData::Rows(Arc::new(rows.clone())),
8897                footprint,
8898                condition_cols,
8899            },
8900        );
8901        Ok(rows)
8902    }
8903
8904    // -----------------------------------------------------------------------
8905    // Traced query wrappers (OPTIMIZATIONS.md Priority 0 / 16).
8906    //
8907    // Each `_traced` method runs its underlying query inside a
8908    // [`crate::trace::QueryTrace::capture`] scope and returns the result
8909    // alongside the captured path trace. The trace records which physical path
8910    // served the query (cursor / pushdown / materialized / count-shortcut),
8911    // whether indexes were rebuilt, whether the result cache hit, overlay size,
8912    // survivor count, and the fast row-id map usage. Recording is zero-cost
8913    // when no `_traced` method is on the call stack (the plain methods are
8914    // unchanged).
8915    // -----------------------------------------------------------------------
8916
8917    /// [`Self::query_columns_native`] with a captured [`crate::trace::QueryTrace`].
8918    #[allow(clippy::type_complexity)]
8919    pub fn query_columns_native_traced(
8920        &mut self,
8921        conditions: &[crate::query::Condition],
8922        projection: Option<&[u16]>,
8923        snapshot: Snapshot,
8924    ) -> Result<(
8925        Option<Vec<(u16, columnar::NativeColumn)>>,
8926        crate::trace::QueryTrace,
8927    )> {
8928        let (result, trace) = crate::trace::QueryTrace::capture(|| {
8929            self.query_columns_native(conditions, projection, snapshot)
8930        });
8931        Ok((result?, trace))
8932    }
8933
8934    /// [`Self::query_columns_native_cached`] with a captured
8935    /// [`crate::trace::QueryTrace`] (records result-cache hits too).
8936    #[allow(clippy::type_complexity)]
8937    pub fn query_columns_native_cached_traced(
8938        &mut self,
8939        conditions: &[crate::query::Condition],
8940        projection: Option<&[u16]>,
8941        snapshot: Snapshot,
8942    ) -> Result<(
8943        Option<Vec<(u16, columnar::NativeColumn)>>,
8944        crate::trace::QueryTrace,
8945    )> {
8946        let (result, trace) = crate::trace::QueryTrace::capture(|| {
8947            self.query_columns_native_cached(conditions, projection, snapshot)
8948        });
8949        Ok((result?, trace))
8950    }
8951
8952    /// [`Self::native_page_cursor`] with a captured [`crate::trace::QueryTrace`].
8953    pub fn native_page_cursor_traced(
8954        &self,
8955        snapshot: Snapshot,
8956        projection: Vec<(u16, TypeId)>,
8957        conditions: &[crate::query::Condition],
8958    ) -> Result<(Option<NativePageCursor>, crate::trace::QueryTrace)> {
8959        let (result, trace) = crate::trace::QueryTrace::capture(|| {
8960            self.native_page_cursor(snapshot, projection, conditions)
8961        });
8962        Ok((result?, trace))
8963    }
8964
8965    /// [`Self::native_multi_run_cursor`] with a captured [`crate::trace::QueryTrace`].
8966    pub fn native_multi_run_cursor_traced(
8967        &self,
8968        snapshot: Snapshot,
8969        projection: Vec<(u16, TypeId)>,
8970        conditions: &[crate::query::Condition],
8971    ) -> Result<(
8972        Option<crate::cursor::MultiRunCursor>,
8973        crate::trace::QueryTrace,
8974    )> {
8975        let (result, trace) = crate::trace::QueryTrace::capture(|| {
8976            self.native_multi_run_cursor(snapshot, projection, conditions)
8977        });
8978        Ok((result?, trace))
8979    }
8980
8981    /// [`Self::count_conditions`] with a captured [`crate::trace::QueryTrace`].
8982    pub fn count_conditions_traced(
8983        &mut self,
8984        conditions: &[crate::query::Condition],
8985        snapshot: Snapshot,
8986    ) -> Result<(Option<u64>, crate::trace::QueryTrace)> {
8987        let (result, trace) =
8988            crate::trace::QueryTrace::capture(|| self.count_conditions(conditions, snapshot));
8989        Ok((result?, trace))
8990    }
8991
8992    /// [`Self::query`] with a captured [`crate::trace::QueryTrace`].
8993    pub fn query_traced(
8994        &mut self,
8995        q: &crate::query::Query,
8996    ) -> Result<(Vec<Row>, crate::trace::QueryTrace)> {
8997        let (result, trace) = crate::trace::QueryTrace::capture(|| self.query(q));
8998        Ok((result?, trace))
8999    }
9000
9001    /// Predicate pushdown: resolve `conditions` via indexes to find the matching
9002    /// row-id set, then decode only those rows' columns — not the whole table.
9003    /// Returns `None` if the conditions can't be served by indexes (caller falls
9004    /// back to a full scan). This is the fast path for `WHERE col = 'value'`.
9005    pub fn query_columns_native(
9006        &mut self,
9007        conditions: &[crate::query::Condition],
9008        projection: Option<&[u16]>,
9009        snapshot: Snapshot,
9010    ) -> Result<Option<Vec<(u16, columnar::NativeColumn)>>> {
9011        self.query_columns_native_inner(conditions, projection, snapshot, None)
9012    }
9013
9014    pub fn query_columns_native_with_control(
9015        &mut self,
9016        conditions: &[crate::query::Condition],
9017        projection: Option<&[u16]>,
9018        snapshot: Snapshot,
9019        control: &crate::ExecutionControl,
9020    ) -> Result<Option<Vec<(u16, columnar::NativeColumn)>>> {
9021        self.query_columns_native_inner(conditions, projection, snapshot, Some(control))
9022    }
9023
9024    fn query_columns_native_inner(
9025        &mut self,
9026        conditions: &[crate::query::Condition],
9027        projection: Option<&[u16]>,
9028        snapshot: Snapshot,
9029        control: Option<&crate::ExecutionControl>,
9030    ) -> Result<Option<Vec<(u16, columnar::NativeColumn)>>> {
9031        use crate::query::Condition;
9032        execution_checkpoint(control, 0)?;
9033        // TTL reads use the materialized visibility path so the wall-clock
9034        // cutoff is captured once and applied to every storage tier.
9035        if self.ttl.is_some() {
9036            return Ok(None);
9037        }
9038        if conditions.is_empty() {
9039            return Ok(None);
9040        }
9041        self.ensure_indexes_complete()?;
9042
9043        // Only these conditions are pushdown-served. Range/RangeF64 need a
9044        // column read on the single-run fast path; off it they fall back to a
9045        // visible-rows scan via `resolve_condition` (still correct for any
9046        // layout, just not page-pruned).
9047        let served = |c: &Condition| {
9048            matches!(
9049                c,
9050                Condition::Pk(_)
9051                    | Condition::BitmapEq { .. }
9052                    | Condition::BitmapIn { .. }
9053                    | Condition::BytesPrefix { .. }
9054                    | Condition::FmContains { .. }
9055                    | Condition::FmContainsAll { .. }
9056                    | Condition::Ann { .. }
9057                    | Condition::Range { .. }
9058                    | Condition::RangeF64 { .. }
9059                    | Condition::SparseMatch { .. }
9060                    | Condition::MinHashSimilar { .. }
9061                    | Condition::IsNull { .. }
9062                    | Condition::IsNotNull { .. }
9063            )
9064        };
9065        if !conditions.iter().all(served) {
9066            return Ok(None);
9067        }
9068        let fast_path =
9069            self.memtable.is_empty() && self.mutable_run.is_empty() && self.run_refs.len() == 1;
9070        crate::trace::QueryTrace::record(|t| {
9071            t.run_count = self.run_refs.len();
9072            t.memtable_rows = self.memtable.len();
9073            t.mutable_run_rows = self.mutable_run.len();
9074            t.conditions_pushed = conditions.len();
9075            t.learned_range_used = conditions.iter().any(|c| match c {
9076                Condition::Range { column_id, .. } | Condition::RangeF64 { column_id, .. } => {
9077                    self.learned_range.contains_key(column_id)
9078                }
9079                _ => false,
9080            });
9081        });
9082        // Build column list (projected or all user columns) + projection pairs.
9083        let col_ids: Vec<u16> = projection
9084            .map(|p| p.to_vec())
9085            .unwrap_or_else(|| self.schema.columns.iter().map(|c| c.id).collect());
9086        let proj_pairs: Vec<(u16, TypeId)> = col_ids
9087            .iter()
9088            .map(|&cid| {
9089                let ty = self
9090                    .schema
9091                    .columns
9092                    .iter()
9093                    .find(|c| c.id == cid)
9094                    .map(|c| c.ty.clone())
9095                    .unwrap_or(TypeId::Bytes);
9096                (cid, ty)
9097            })
9098            .collect();
9099
9100        // -----------------------------------------------------------------------
9101        // Fast path: single run, empty memtable/mutable-run → resolve survivors,
9102        // binary-search positions, gather only the projected columns from one
9103        // reader. This is the fastest pushdown path (no cursor overhead).
9104        // -----------------------------------------------------------------------
9105        if fast_path {
9106            // A Range/RangeF64 needs a column read *unless* its column has a
9107            // learned (PGM) range index, in which case it's served in-memory.
9108            let needs_column = conditions.iter().any(|c| match c {
9109                Condition::Range { column_id, .. } => !self.learned_range.contains_key(column_id),
9110                Condition::RangeF64 { column_id, .. } => {
9111                    !self.learned_range.contains_key(column_id)
9112                }
9113                _ => false,
9114            });
9115            let mut reader_opt: Option<RunReader> = if needs_column {
9116                Some(self.open_reader(self.run_refs[0].run_id)?)
9117            } else {
9118                None
9119            };
9120            let mut sets: Vec<RowIdSet> = Vec::new();
9121            for (index, c) in conditions.iter().enumerate() {
9122                execution_checkpoint(control, index)?;
9123                let s = match c {
9124                    Condition::Range { column_id, lo, hi }
9125                        if !self.learned_range.contains_key(column_id) =>
9126                    {
9127                        if reader_opt.is_none() {
9128                            reader_opt = Some(self.open_reader(self.run_refs[0].run_id)?);
9129                        }
9130                        reader_opt
9131                            .as_mut()
9132                            .expect("reader opened for range")
9133                            .range_row_id_set_i64(*column_id, *lo, *hi)?
9134                    }
9135                    Condition::RangeF64 {
9136                        column_id,
9137                        lo,
9138                        lo_inclusive,
9139                        hi,
9140                        hi_inclusive,
9141                    } if !self.learned_range.contains_key(column_id) => {
9142                        if reader_opt.is_none() {
9143                            reader_opt = Some(self.open_reader(self.run_refs[0].run_id)?);
9144                        }
9145                        reader_opt
9146                            .as_mut()
9147                            .expect("reader opened for range")
9148                            .range_row_id_set_f64(
9149                                *column_id,
9150                                *lo,
9151                                *lo_inclusive,
9152                                *hi,
9153                                *hi_inclusive,
9154                            )?
9155                    }
9156                    _ => self.resolve_condition(c, snapshot)?,
9157                };
9158                sets.push(s);
9159            }
9160            let candidates = RowIdSet::intersect_many(sets);
9161            crate::trace::QueryTrace::record(|t| {
9162                t.survivor_count = Some(candidates.len());
9163            });
9164            if candidates.is_empty() {
9165                let cols: Vec<(u16, columnar::NativeColumn)> = col_ids
9166                    .iter()
9167                    .map(|&id| {
9168                        (
9169                            id,
9170                            columnar::null_native(
9171                                proj_pairs
9172                                    .iter()
9173                                    .find(|(c, _)| c == &id)
9174                                    .map(|(_, t)| t.clone())
9175                                    .unwrap_or(TypeId::Bytes),
9176                                0,
9177                            ),
9178                        )
9179                    })
9180                    .collect();
9181                return Ok(Some(cols));
9182            }
9183            let mut reader = match reader_opt.take() {
9184                Some(r) => r,
9185                None => self.open_reader(self.run_refs[0].run_id)?,
9186            };
9187            let candidate_ids = candidates.into_sorted_vec();
9188            let (positions, fast_rid) = if let Some(positions) =
9189                reader.positions_for_row_ids_fast(&candidate_ids)
9190            {
9191                (positions, true)
9192            } else {
9193                let col = reader.column_native(crate::sorted_run::SYS_ROW_ID)?;
9194                match col {
9195                    columnar::NativeColumn::Int64 { data, .. } => {
9196                        let mut p = Vec::with_capacity(candidate_ids.len());
9197                        for (index, rid) in candidate_ids.iter().enumerate() {
9198                            execution_checkpoint(control, index)?;
9199                            if let Ok(position) = data.binary_search(&(*rid as i64)) {
9200                                p.push(position);
9201                            }
9202                        }
9203                        p.sort_unstable();
9204                        (p, false)
9205                    }
9206                    _ => return Err(MongrelError::InvalidArgument("sys row_id not int64".into())),
9207                }
9208            };
9209            crate::trace::QueryTrace::record(|t| {
9210                t.scan_mode = crate::trace::ScanMode::NativePushdown;
9211                t.fast_row_id_map = fast_rid;
9212            });
9213            let mut cols = Vec::with_capacity(col_ids.len());
9214            for (index, cid) in col_ids.iter().enumerate() {
9215                execution_checkpoint(control, index)?;
9216                let col = reader.column_native(*cid)?;
9217                cols.push((*cid, col.gather(&positions)));
9218            }
9219            return Ok(Some(cols));
9220        }
9221
9222        // -----------------------------------------------------------------------
9223        // Non-fast path (multi-run / non-empty overlay). Route through the
9224        // columnar cursor (OPTIMIZATIONS.md Priority 1 + 4): the cursor builder
9225        // resolves MVCC, predicates, and overlay internally in batch, then
9226        // streams projected columns page-by-page. This avoids the per-rid
9227        // `rows_for_rids` `get_version`-across-all-runs cost that made multi-run
9228        // pushdown ~1000× slower than the single-run fast path.
9229        //
9230        // The cursor handles both single-run-with-overlay (`native_page_cursor`)
9231        // and multi-run (`native_multi_run_cursor`) layouts. The empty-table
9232        // (no runs, memtable-only) edge case falls through to `rows_for_rids`.
9233        // -----------------------------------------------------------------------
9234        if !self.run_refs.is_empty() {
9235            use crate::cursor::{
9236                drain_cursor_to_columns, drain_cursor_to_columns_with_control, Cursor,
9237            };
9238            let remaining: usize;
9239            let mut cursor: Box<dyn crate::cursor::Cursor> = if self.run_refs.len() == 1 {
9240                let c = self
9241                    .native_page_cursor(snapshot, proj_pairs.clone(), conditions)?
9242                    .expect("single-run cursor should build when run_refs.len() == 1");
9243                remaining = c.remaining_rows();
9244                Box::new(c)
9245            } else {
9246                let c = self
9247                    .native_multi_run_cursor(snapshot, proj_pairs.clone(), conditions)?
9248                    .expect("multi-run cursor should build when run_refs.len() >= 1");
9249                remaining = c.remaining_rows();
9250                Box::new(c)
9251            };
9252            crate::trace::QueryTrace::record(|t| {
9253                if t.survivor_count.is_none() {
9254                    t.survivor_count = Some(remaining);
9255                }
9256            });
9257            let cols = match control {
9258                Some(control) => {
9259                    drain_cursor_to_columns_with_control(cursor.as_mut(), &proj_pairs, control)?
9260                }
9261                None => drain_cursor_to_columns(cursor.as_mut(), &proj_pairs)?,
9262            };
9263            return Ok(Some(cols));
9264        }
9265
9266        // Empty-table fallback (no sorted runs, memtable/mutable-run only): the
9267        // cursor builders return `None` for `run_refs.is_empty()`, so resolve
9268        // from overlay indexes and materialize via `rows_for_rids`. This is the
9269        // rare edge case (fresh table with only `put`s, no `flush`/`bulk_load`).
9270        crate::trace::QueryTrace::record(|t| {
9271            t.scan_mode = crate::trace::ScanMode::Materialized;
9272            t.row_materialized = true;
9273        });
9274        let mut sets: Vec<RowIdSet> = Vec::with_capacity(conditions.len());
9275        for (index, c) in conditions.iter().enumerate() {
9276            execution_checkpoint(control, index)?;
9277            sets.push(self.resolve_condition(c, snapshot)?);
9278        }
9279        let rids = RowIdSet::intersect_many(sets).into_sorted_vec();
9280        let rows = self.rows_for_rids(&rids, snapshot)?;
9281        let mut cols: Vec<(u16, columnar::NativeColumn)> = Vec::with_capacity(col_ids.len());
9282        for (index, (cid, ty)) in proj_pairs.iter().enumerate() {
9283            execution_checkpoint(control, index)?;
9284            let vals: Vec<Value> = rows
9285                .iter()
9286                .map(|r| r.columns.get(cid).cloned().unwrap_or(Value::Null))
9287                .collect();
9288            cols.push((*cid, columnar::values_to_native(ty.clone(), &vals)));
9289        }
9290        Ok(Some(cols))
9291    }
9292
9293    /// Build a lazy, page-aware [`NativePageCursor`] for the single-run fast
9294    /// path. MVCC visibility and predicate survivor resolution are settled up
9295    /// front (so they see the live indexes under the DB lock); the cursor then
9296    /// owns the reader and decodes only the projected columns of pages that
9297    /// contain survivors, lazily. This is the fused-predicate + page-skip +
9298    /// late-materialization scan.
9299    ///
9300    /// Phase 13.1: the memtable / mutable-run overlay is now handled. Rows with
9301    /// a newer version in the overlay are excluded from the run's page plans
9302    /// (their run version is stale); the overlay rows are pre-materialized and
9303    /// appended as a final batch via [`NativePageCursor::new_with_overlay`].
9304    ///
9305    /// Returns `None` only for multiple sorted runs; the caller falls back to
9306    /// the materialize-then-stream scan for that layout.
9307    pub fn native_page_cursor(
9308        &self,
9309        snapshot: Snapshot,
9310        projection: Vec<(u16, TypeId)>,
9311        conditions: &[crate::query::Condition],
9312    ) -> Result<Option<NativePageCursor>> {
9313        use crate::cursor::build_page_plans;
9314        if self.ttl.is_some() {
9315            return Ok(None);
9316        }
9317        // See `scan_cursor`: incomplete (deferred) indexes cannot resolve
9318        // conditions — signal "can't serve" instead of empty survivor sets.
9319        if !conditions.is_empty() && !self.indexes_complete {
9320            return Ok(None);
9321        }
9322        if self.run_refs.len() != 1 {
9323            return Ok(None);
9324        }
9325        let mut reader = self.open_reader(self.run_refs[0].run_id)?;
9326        let (positions, rids) = reader.visible_positions_with_rids(snapshot.epoch)?;
9327
9328        // Collect overlay rows from memtable + mutable_run (visible, newest
9329        // version per row). These shadow any stale version in the run.
9330        let overlay_rids: HashSet<u64> = {
9331            let mut s = HashSet::new();
9332            for row in self.memtable.visible_versions(snapshot.epoch) {
9333                s.insert(row.row_id.0);
9334            }
9335            for row in self.mutable_run.visible_versions(snapshot.epoch) {
9336                s.insert(row.row_id.0);
9337            }
9338            s
9339        };
9340
9341        // Resolve survivor rids via indexes (covers overlay rows for index-
9342        // served conditions: PK, bitmap, FM, ANN, sparse — all maintained on
9343        // every put).
9344        let survivors = if conditions.is_empty() {
9345            None
9346        } else {
9347            Some(self.resolve_survivor_rids(conditions, &mut reader, snapshot)?)
9348        };
9349
9350        // Exclude overlay rids from the run portion: their version in the run
9351        // is stale (updated/deleted in the overlay) or they don't exist in the
9352        // run (new inserts). When there are conditions, we remove overlay rids
9353        // from the survivor set. When there are no conditions, we synthesize a
9354        // survivor set = (all visible run rids) − (overlay rids) so the stale
9355        // run rows are pruned.
9356        let run_survivors: Option<RowIdSet> = if overlay_rids.is_empty() {
9357            survivors.clone()
9358        } else if let Some(s) = &survivors {
9359            let mut run_set = s.clone();
9360            run_set.remove_many(overlay_rids.iter().copied());
9361            Some(run_set)
9362        } else {
9363            Some(RowIdSet::from_unsorted(
9364                rids.iter()
9365                    .map(|&r| r as u64)
9366                    .filter(|r| !overlay_rids.contains(r))
9367                    .collect(),
9368            ))
9369        };
9370
9371        let overlay_rows = if overlay_rids.is_empty() {
9372            Vec::new()
9373        } else {
9374            let bound = Self::overlay_materialization_bound(conditions, &survivors);
9375            self.overlay_visible_rows(snapshot, bound)
9376        };
9377
9378        // Build page plans for the run portion.
9379        let plans = if positions.is_empty() {
9380            Vec::new()
9381        } else {
9382            let page_rows = reader.page_row_counts(crate::sorted_run::SYS_ROW_ID)?;
9383            build_page_plans(&positions, &rids, &page_rows, run_survivors.as_ref())
9384        };
9385
9386        // Filter and materialize the overlay.
9387        let overlay = if overlay_rows.is_empty() {
9388            None
9389        } else {
9390            let filtered =
9391                self.filter_overlay_rows(overlay_rows, conditions, survivors.as_ref(), snapshot)?;
9392            if filtered.is_empty() {
9393                None
9394            } else {
9395                Some(self.materialize_overlay(&filtered, &projection))
9396            }
9397        };
9398
9399        let overlay_row_count = overlay
9400            .as_ref()
9401            .map(|c| c.first().map(|c| c.len()).unwrap_or(0))
9402            .unwrap_or(0);
9403        crate::trace::QueryTrace::record(|t| {
9404            t.scan_mode = crate::trace::ScanMode::NativePageCursor;
9405            t.run_count = self.run_refs.len();
9406            t.memtable_rows = self.memtable.len();
9407            t.mutable_run_rows = self.mutable_run.len();
9408            t.overlay_rows = overlay_row_count;
9409            t.conditions_pushed = conditions.len();
9410            t.pages_decoded = plans
9411                .iter()
9412                .map(|p| p.positions.len())
9413                .sum::<usize>()
9414                .min(1);
9415        });
9416
9417        Ok(Some(NativePageCursor::new_with_overlay(
9418            reader, projection, plans, overlay,
9419        )))
9420    }
9421    /// Generalizes [`Self::native_page_cursor`] (single-run) to arbitrary run
9422    /// counts via a k-way merge by `RowId`. Cross-run MVCC resolution (newest
9423    /// visible version per `RowId`) and predicate survivor resolution are settled
9424    /// up front from the cheap system columns + global indexes; the cursor then
9425    /// lazily decodes the projected data columns of just the pages that own
9426    /// survivors, each page at most once. The memtable / mutable-run overlay is
9427    /// materialized and yielded as a final batch (mirroring the single-run path).
9428    ///
9429    /// Returns `None` only when there are no runs at all (caller falls back).
9430    #[allow(clippy::type_complexity)]
9431    pub fn native_multi_run_cursor(
9432        &self,
9433        snapshot: Snapshot,
9434        projection: Vec<(u16, TypeId)>,
9435        conditions: &[crate::query::Condition],
9436    ) -> Result<Option<crate::cursor::MultiRunCursor>> {
9437        use crate::cursor::{MultiRunCursor, RunStream};
9438        use crate::sorted_run::SYS_ROW_ID;
9439        use std::collections::{BinaryHeap, HashMap, HashSet};
9440        if self.ttl.is_some() {
9441            return Ok(None);
9442        }
9443        // See `scan_cursor`: incomplete (deferred) indexes cannot resolve
9444        // conditions — signal "can't serve" instead of empty survivor sets.
9445        if !conditions.is_empty() && !self.indexes_complete {
9446            return Ok(None);
9447        }
9448        if self.run_refs.is_empty() {
9449            return Ok(None);
9450        }
9451
9452        // Open each run once; read its system columns + page layout.
9453        let mut run_meta: Vec<(RunReader, Vec<i64>, Vec<i64>, Vec<u8>, Vec<usize>)> =
9454            Vec::with_capacity(self.run_refs.len());
9455        for rr in &self.run_refs {
9456            let mut reader = self.open_reader(rr.run_id)?;
9457            let (rids, eps, del) = reader.system_columns_native()?;
9458            let page_rows = reader.page_row_counts(SYS_ROW_ID)?;
9459            run_meta.push((reader, rids, eps, del, page_rows));
9460        }
9461
9462        // Global cross-run newest-version resolution: rid -> (epoch, run_idx,
9463        // position, deleted). Mirrors `visible_rows`, tracking which run owns
9464        // the newest MVCC-visible version.
9465        let mut best: HashMap<u64, (u64, usize, usize, bool)> = HashMap::new();
9466        for (run_idx, (_, rids, eps, del, _)) in run_meta.iter().enumerate() {
9467            for i in 0..rids.len() {
9468                let rid = rids[i] as u64;
9469                let e = eps[i] as u64;
9470                if e > snapshot.epoch.0 {
9471                    continue;
9472                }
9473                let is_del = del[i] != 0;
9474                best.entry(rid)
9475                    .and_modify(|cur| {
9476                        if e > cur.0 {
9477                            *cur = (e, run_idx, i, is_del);
9478                        }
9479                    })
9480                    .or_insert((e, run_idx, i, is_del));
9481            }
9482        }
9483
9484        // Overlay rids (memtable + mutable-run) shadow every run version.
9485        let overlay_rids: HashSet<u64> = {
9486            let mut s = HashSet::new();
9487            for row in self.memtable.visible_versions(snapshot.epoch) {
9488                s.insert(row.row_id.0);
9489            }
9490            for row in self.mutable_run.visible_versions(snapshot.epoch) {
9491                s.insert(row.row_id.0);
9492            }
9493            s
9494        };
9495
9496        // Predicate survivors (global, layout-independent).
9497        let survivors: Option<RowIdSet> = if conditions.is_empty() {
9498            None
9499        } else {
9500            let mut sets: Vec<RowIdSet> = Vec::with_capacity(conditions.len());
9501            for c in conditions {
9502                sets.push(self.resolve_condition(c, snapshot)?);
9503            }
9504            Some(RowIdSet::intersect_many(sets))
9505        };
9506
9507        // Per-run owned survivors: (rid, position), ascending by rid. A row is
9508        // owned by the run holding its newest visible version, is not deleted,
9509        // is not shadowed by the overlay, and satisfies the predicate.
9510        let mut per_run: Vec<Vec<(u64, usize)>> = vec![Vec::new(); run_meta.len()];
9511        for (rid, (_, run_idx, pos, deleted)) in &best {
9512            if *deleted {
9513                continue;
9514            }
9515            if overlay_rids.contains(rid) {
9516                continue;
9517            }
9518            if let Some(s) = &survivors {
9519                if !s.contains(*rid) {
9520                    continue;
9521                }
9522            }
9523            per_run[*run_idx].push((*rid, *pos));
9524        }
9525        for v in per_run.iter_mut() {
9526            v.sort_unstable_by_key(|&(rid, _)| rid);
9527        }
9528
9529        // Build the merge streams: map each owned position to (page_seq, within).
9530        let mut streams = Vec::with_capacity(run_meta.len());
9531        let mut heap: BinaryHeap<std::cmp::Reverse<(u64, usize)>> = BinaryHeap::new();
9532        let mut total = 0usize;
9533        for (run_idx, (reader, _, _, _, page_rows)) in run_meta.into_iter().enumerate() {
9534            let mut starts = Vec::with_capacity(page_rows.len());
9535            let mut acc = 0usize;
9536            for &r in &page_rows {
9537                starts.push(acc);
9538                acc += r;
9539            }
9540            let mut survivors_vec: Vec<(u64, usize, usize)> =
9541                Vec::with_capacity(per_run[run_idx].len());
9542            for &(rid, pos) in &per_run[run_idx] {
9543                let page_seq = match starts.partition_point(|&s| s <= pos) {
9544                    0 => continue,
9545                    p => p - 1,
9546                };
9547                let within = pos - starts[page_seq];
9548                survivors_vec.push((rid, page_seq, within));
9549            }
9550            total += survivors_vec.len();
9551            if let Some(&(rid, _, _)) = survivors_vec.first() {
9552                heap.push(std::cmp::Reverse((rid, run_idx)));
9553            }
9554            streams.push(RunStream::new(reader, survivors_vec, page_rows));
9555        }
9556
9557        // Materialize the overlay (filtered + projected), yielded as the final batch.
9558        let overlay_rows = if overlay_rids.is_empty() {
9559            Vec::new()
9560        } else {
9561            let bound = Self::overlay_materialization_bound(conditions, &survivors);
9562            self.overlay_visible_rows(snapshot, bound)
9563        };
9564        let overlay = if overlay_rows.is_empty() {
9565            None
9566        } else {
9567            let filtered =
9568                self.filter_overlay_rows(overlay_rows, conditions, survivors.as_ref(), snapshot)?;
9569            if filtered.is_empty() {
9570                None
9571            } else {
9572                Some(self.materialize_overlay(&filtered, &projection))
9573            }
9574        };
9575
9576        let overlay_row_count = overlay
9577            .as_ref()
9578            .map(|c| c.first().map(|c| c.len()).unwrap_or(0))
9579            .unwrap_or(0);
9580        crate::trace::QueryTrace::record(|t| {
9581            t.scan_mode = crate::trace::ScanMode::MultiRunCursor;
9582            t.run_count = self.run_refs.len();
9583            t.memtable_rows = self.memtable.len();
9584            t.mutable_run_rows = self.mutable_run.len();
9585            t.overlay_rows = overlay_row_count;
9586            t.conditions_pushed = conditions.len();
9587            t.survivor_count = Some(total);
9588        });
9589
9590        Ok(Some(MultiRunCursor::new(
9591            streams, projection, heap, total, overlay,
9592        )))
9593    }
9594
9595    /// Collect visible, non-deleted overlay rows from the memtable and mutable-
9596    /// run tier at `snapshot`. These are the rows whose data lives only in the
9597    /// in-memory buffers (not yet in a sorted run), or that shadow a stale
9598    /// version in the run.
9599    /// The survivor set that bounds overlay materialization (Priority 2), or
9600    /// `None` when overlay rows must be fully materialized — i.e. there is a
9601    /// `Range`/`RangeF64` residual, for which the index-served survivor set does
9602    /// not cover matching overlay rows (those are evaluated downstream). This
9603    /// mirrors the `all_index_served` branch of
9604    /// [`filter_overlay_rows`](Self::filter_overlay_rows), so bounding here is
9605    /// result-preserving.
9606    fn overlay_materialization_bound<'a>(
9607        conditions: &[crate::query::Condition],
9608        survivors: &'a Option<RowIdSet>,
9609    ) -> Option<&'a RowIdSet> {
9610        use crate::query::Condition;
9611        let has_range = conditions
9612            .iter()
9613            .any(|c| matches!(c, Condition::Range { .. } | Condition::RangeF64 { .. }));
9614        if has_range {
9615            None
9616        } else {
9617            survivors.as_ref()
9618        }
9619    }
9620
9621    /// Materialize the visible overlay rows (memtable + mutable-run, newest
9622    /// version per row, non-deleted).
9623    ///
9624    /// Priority 2 (selective overlay probing): when `bound` is `Some`, only rows
9625    /// whose id is in it are materialized. The caller passes the index-resolved
9626    /// survivor set as `bound` exactly when every condition is index-served — in
9627    /// which case [`filter_overlay_rows`](Self::filter_overlay_rows) would discard
9628    /// any non-survivor overlay row anyway, so this prunes the materialization
9629    /// without changing the result. With a Range/RangeF64 residual the survivor
9630    /// set is incomplete for overlay rows, so the caller passes `None` (full
9631    /// materialization) and the range is re-evaluated downstream.
9632    fn overlay_visible_rows(&self, snapshot: Snapshot, bound: Option<&RowIdSet>) -> Vec<Row> {
9633        let mut best: HashMap<u64, (Epoch, Row)> = HashMap::new();
9634        let mut fold = |row: Row| {
9635            if let Some(b) = bound {
9636                if !b.contains(row.row_id.0) {
9637                    return;
9638                }
9639            }
9640            best.entry(row.row_id.0)
9641                .and_modify(|(be, br)| {
9642                    if row.committed_epoch > *be {
9643                        *be = row.committed_epoch;
9644                        *br = row.clone();
9645                    }
9646                })
9647                .or_insert_with(|| (row.committed_epoch, row));
9648        };
9649        for row in self.memtable.visible_versions(snapshot.epoch) {
9650            fold(row);
9651        }
9652        for row in self.mutable_run.visible_versions(snapshot.epoch) {
9653            fold(row);
9654        }
9655        let mut out: Vec<Row> = best
9656            .into_values()
9657            .filter_map(|(_, r)| if r.deleted { None } else { Some(r) })
9658            .collect();
9659        out.sort_by_key(|r| r.row_id);
9660        out
9661    }
9662
9663    /// Filter overlay rows against the conjunctive predicate. Range / RangeF64
9664    /// are evaluated directly (the reader-served survivor set misses overlay
9665    /// rows). All other conditions are index-served (indexes maintained on
9666    /// every `put`) so the intersected `survivors` set includes overlay rows
9667    /// that match — but ONLY when every condition is index-served. When there
9668    /// is a mix, we compute per-condition index sets for non-range conditions
9669    /// and evaluate range conditions directly, so the intersection is correct.
9670    fn filter_overlay_rows(
9671        &self,
9672        rows: Vec<Row>,
9673        conditions: &[crate::query::Condition],
9674        survivors: Option<&RowIdSet>,
9675        snapshot: Snapshot,
9676    ) -> Result<Vec<Row>> {
9677        if conditions.is_empty() {
9678            return Ok(rows);
9679        }
9680        use crate::query::Condition;
9681        // Determine whether every condition is index-served (survivors set is
9682        // then complete for overlay rows). If so, a simple membership check
9683        // suffices and is cheapest.
9684        let all_index_served = !conditions
9685            .iter()
9686            .any(|c| matches!(c, Condition::Range { .. } | Condition::RangeF64 { .. }));
9687        if all_index_served {
9688            return Ok(rows
9689                .into_iter()
9690                .filter(|r| survivors.is_none_or(|s| s.contains(r.row_id.0)))
9691                .collect());
9692        }
9693        // Mixed: compute per-condition index sets for non-range conditions, and
9694        // evaluate range conditions directly on column values.
9695        let mut per_cond_sets: Vec<RowIdSet> = Vec::with_capacity(conditions.len());
9696        for c in conditions {
9697            let s = match c {
9698                Condition::Range { .. } | Condition::RangeF64 { .. } => RowIdSet::empty(),
9699                _ => self.resolve_condition(c, snapshot)?,
9700            };
9701            per_cond_sets.push(s);
9702        }
9703        Ok(rows
9704            .into_iter()
9705            .filter(|row| {
9706                conditions.iter().enumerate().all(|(i, c)| match c {
9707                    Condition::Range { column_id, lo, hi } => {
9708                        matches!(row.columns.get(column_id), Some(Value::Int64(v)) if *v >= *lo && *v <= *hi)
9709                    }
9710                    Condition::RangeF64 { column_id, lo, lo_inclusive, hi, hi_inclusive } => {
9711                        match row.columns.get(column_id) {
9712                            Some(Value::Float64(v)) => {
9713                                let lo_ok = if *lo_inclusive { *v >= *lo } else { *v > *lo };
9714                                let hi_ok = if *hi_inclusive { *v <= *hi } else { *v < *hi };
9715                                lo_ok && hi_ok
9716                            }
9717                            _ => false,
9718                        }
9719                    }
9720                    _ => per_cond_sets[i].contains(row.row_id.0),
9721                })
9722            })
9723            .collect())
9724    }
9725
9726    /// Materialize overlay rows into typed `NativeColumn`s for the cursor's
9727    /// final batch.
9728    fn materialize_overlay(
9729        &self,
9730        rows: &[Row],
9731        projection: &[(u16, TypeId)],
9732    ) -> Vec<columnar::NativeColumn> {
9733        if projection.is_empty() {
9734            return vec![columnar::null_native(TypeId::Int64, rows.len())];
9735        }
9736        let mut cols = Vec::with_capacity(projection.len());
9737        for (cid, ty) in projection {
9738            let vals: Vec<Value> = rows
9739                .iter()
9740                .map(|r| r.columns.get(cid).cloned().unwrap_or(Value::Null))
9741                .collect();
9742            cols.push(columnar::values_to_native(ty.clone(), &vals));
9743        }
9744        cols
9745    }
9746
9747    /// Resolve a conjunctive predicate to its surviving `RowId` set on the
9748    /// single-run fast path: each condition becomes a `RowId` set via the
9749    /// in-memory indexes or the reader's page-pruned range scan, then they are
9750    /// intersected. Mirrors the resolution inside [`Self::query_columns_native`].
9751    fn resolve_survivor_rids(
9752        &self,
9753        conditions: &[crate::query::Condition],
9754        reader: &mut RunReader,
9755        snapshot: Snapshot,
9756    ) -> Result<RowIdSet> {
9757        use crate::query::Condition;
9758        let mut sets: Vec<RowIdSet> = Vec::new();
9759        for c in conditions {
9760            self.validate_condition(c)?;
9761            let s: RowIdSet = match c {
9762                Condition::Pk(key) => {
9763                    let lookup = self
9764                        .schema
9765                        .primary_key()
9766                        .map(|pk| self.index_lookup_key_bytes(pk.id, key))
9767                        .unwrap_or_else(|| key.clone());
9768                    self.hot
9769                        .get(&lookup)
9770                        .map(|r| RowIdSet::one(r.0))
9771                        .unwrap_or_else(RowIdSet::empty)
9772                }
9773                Condition::BitmapEq { column_id, value } => {
9774                    let lookup = self.index_lookup_key_bytes(*column_id, value);
9775                    self.bitmap
9776                        .get(column_id)
9777                        .map(|b| RowIdSet::from_roaring(b.get(&lookup)))
9778                        .unwrap_or_else(RowIdSet::empty)
9779                }
9780                Condition::BitmapIn { column_id, values } => {
9781                    let bm = self.bitmap.get(column_id);
9782                    let mut acc = roaring::RoaringBitmap::new();
9783                    if let Some(b) = bm {
9784                        for v in values {
9785                            let lookup = self.index_lookup_key_bytes(*column_id, v);
9786                            acc |= b.get(&lookup);
9787                        }
9788                    }
9789                    RowIdSet::from_roaring(acc)
9790                }
9791                Condition::BytesPrefix { column_id, prefix } => {
9792                    if let Some(b) = self.bitmap.get(column_id) {
9793                        let lookup_prefix = self.index_lookup_key_bytes(*column_id, prefix);
9794                        let mut acc = roaring::RoaringBitmap::new();
9795                        for key in b.keys() {
9796                            if key.starts_with(&lookup_prefix) {
9797                                acc |= b.get(&key);
9798                            }
9799                        }
9800                        RowIdSet::from_roaring(acc)
9801                    } else {
9802                        RowIdSet::empty()
9803                    }
9804                }
9805                Condition::FmContains { column_id, pattern } => self
9806                    .fm
9807                    .get(column_id)
9808                    .map(|f| {
9809                        RowIdSet::from_unsorted(
9810                            f.locate(pattern).into_iter().map(|r| r.0).collect(),
9811                        )
9812                    })
9813                    .unwrap_or_else(RowIdSet::empty),
9814                Condition::FmContainsAll {
9815                    column_id,
9816                    patterns,
9817                } => {
9818                    if let Some(f) = self.fm.get(column_id) {
9819                        let sets: Vec<RowIdSet> = patterns
9820                            .iter()
9821                            .map(|pat| {
9822                                RowIdSet::from_unsorted(
9823                                    f.locate(pat).into_iter().map(|r| r.0).collect(),
9824                                )
9825                            })
9826                            .collect();
9827                        RowIdSet::intersect_many(sets)
9828                    } else {
9829                        RowIdSet::empty()
9830                    }
9831                }
9832                Condition::Ann {
9833                    column_id,
9834                    query,
9835                    k,
9836                } => RowIdSet::from_unsorted(
9837                    self.retrieve_filtered(
9838                        &crate::query::Retriever::Ann {
9839                            column_id: *column_id,
9840                            query: query.clone(),
9841                            k: *k,
9842                        },
9843                        snapshot,
9844                        None,
9845                        None,
9846                        None,
9847                        None,
9848                    )?
9849                    .into_iter()
9850                    .map(|hit| hit.row_id.0)
9851                    .collect(),
9852                ),
9853                Condition::SparseMatch {
9854                    column_id,
9855                    query,
9856                    k,
9857                } => RowIdSet::from_unsorted(
9858                    self.retrieve_filtered(
9859                        &crate::query::Retriever::Sparse {
9860                            column_id: *column_id,
9861                            query: query.clone(),
9862                            k: *k,
9863                        },
9864                        snapshot,
9865                        None,
9866                        None,
9867                        None,
9868                        None,
9869                    )?
9870                    .into_iter()
9871                    .map(|hit| hit.row_id.0)
9872                    .collect(),
9873                ),
9874                Condition::MinHashSimilar {
9875                    column_id,
9876                    query,
9877                    k,
9878                } => match self.minhash.get(column_id) {
9879                    Some(index) => {
9880                        let candidates = index.candidate_row_ids(query);
9881                        let eligible =
9882                            self.eligible_candidate_ids(&candidates, *column_id, snapshot, None)?;
9883                        RowIdSet::from_unsorted(
9884                            index
9885                                .search_filtered(query, *k, |row_id| eligible.contains(&row_id))
9886                                .into_iter()
9887                                .map(|(row_id, _)| row_id.0)
9888                                .collect(),
9889                        )
9890                    }
9891                    None => RowIdSet::empty(),
9892                },
9893                Condition::Range { column_id, lo, hi } => {
9894                    if let Some(li) = self.learned_range.get(column_id) {
9895                        RowIdSet::from_unsorted(li.range(*lo, *hi).into_iter().collect())
9896                    } else {
9897                        reader.range_row_id_set_i64(*column_id, *lo, *hi)?
9898                    }
9899                }
9900                Condition::RangeF64 {
9901                    column_id,
9902                    lo,
9903                    lo_inclusive,
9904                    hi,
9905                    hi_inclusive,
9906                } => {
9907                    if let Some(li) = self.learned_range.get(column_id) {
9908                        RowIdSet::from_unsorted(
9909                            li.range_f64(*lo, *lo_inclusive, *hi, *hi_inclusive)
9910                                .into_iter()
9911                                .collect(),
9912                        )
9913                    } else {
9914                        reader.range_row_id_set_f64(
9915                            *column_id,
9916                            *lo,
9917                            *lo_inclusive,
9918                            *hi,
9919                            *hi_inclusive,
9920                        )?
9921                    }
9922                }
9923                Condition::IsNull { column_id } => reader.null_row_id_set(*column_id, true)?,
9924                Condition::IsNotNull { column_id } => reader.null_row_id_set(*column_id, false)?,
9925            };
9926            sets.push(s);
9927        }
9928        Ok(RowIdSet::intersect_many(sets))
9929    }
9930
9931    /// Native vectorized aggregate over a (possibly filtered) column on the
9932    /// single-run fast path (Phase 7.2). Resolves survivors via the same
9933    /// page-pruned cursor as the scan, then accumulates the aggregate in one
9934    /// pass over the typed buffer — no `Value`, no Arrow `RecordBatch`.
9935    ///
9936    /// `column` is `None` for `COUNT(*)`. Returns `Ok(None)` when the fast path
9937    /// does not apply (multi-run / non-empty memtable); the caller scans.
9938    /// Open the streaming [`Cursor`](crate::cursor::Cursor) matching the current
9939    /// run layout: the single-run page cursor when there is exactly one sorted
9940    /// run, otherwise the multi-run k-way merge cursor. Both fuse the predicate,
9941    /// skip non-surviving pages, and fold the memtable / mutable-run overlay, so
9942    /// callers stay columnar end-to-end and never materialize `Row`s. Returns
9943    /// `None` when no cursor applies (e.g. an overlay-only table with no sorted
9944    /// run), leaving the caller to fall back.
9945    ///
9946    /// This is the single source of truth for layout-aware cursor selection,
9947    /// shared by the column scan ([`Self::query_columns_native`] / the SQL
9948    /// provider) and the aggregate path ([`Self::aggregate_native`]). New
9949    /// streaming consumers should build on this rather than re-deciding the
9950    /// cursor by run count.
9951    pub fn scan_cursor(
9952        &self,
9953        snapshot: Snapshot,
9954        projection: Vec<(u16, TypeId)>,
9955        conditions: &[crate::query::Condition],
9956    ) -> Result<Option<Box<dyn crate::cursor::Cursor>>> {
9957        if self.ttl.is_some() {
9958            return Ok(None);
9959        }
9960        // A deferred bulk load leaves the live indexes unbuilt; resolving
9961        // conditions against them would return silently-empty survivor sets.
9962        // Signal "can't serve" so the caller falls back to a `&mut` path that
9963        // runs `ensure_indexes_complete`. (Condition-free scans don't touch
9964        // the indexes and stay served.)
9965        if !conditions.is_empty() && !self.indexes_complete {
9966            return Ok(None);
9967        }
9968        if self.run_refs.len() == 1 {
9969            Ok(self
9970                .native_page_cursor(snapshot, projection, conditions)?
9971                .map(|c| Box::new(c) as Box<dyn crate::cursor::Cursor>))
9972        } else {
9973            Ok(self
9974                .native_multi_run_cursor(snapshot, projection, conditions)?
9975                .map(|c| Box::new(c) as Box<dyn crate::cursor::Cursor>))
9976        }
9977    }
9978
9979    /// Native vectorized aggregate over a (possibly filtered) column, in one
9980    /// pass over the typed buffers — no `Value`, no Arrow batch. Layout-agnostic:
9981    /// survivors stream through [`Self::scan_cursor`] (single- or multi-run,
9982    /// overlay-folded), so the same path serves every sorted-run layout.
9983    ///
9984    /// `column` is `None` for `COUNT(*)`. Order of attempts:
9985    /// 1. Single clean run + no `WHERE` ⇒ `MIN`/`MAX`/`COUNT(col)` straight from
9986    ///    page `min`/`max`/`null_count` (no decode).
9987    /// 2. `COUNT(*)` ⇒ survivor cardinality from the cursor's page plans.
9988    /// 3. Otherwise accumulate the projected column over the cursor.
9989    ///
9990    /// Returns `Ok(None)` (caller scans) when no native path applies: an
9991    /// overlay-only table with no sorted run, or a non-numeric column.
9992    pub fn aggregate_native(
9993        &self,
9994        snapshot: Snapshot,
9995        column: Option<u16>,
9996        conditions: &[crate::query::Condition],
9997        agg: NativeAgg,
9998    ) -> Result<Option<NativeAggResult>> {
9999        self.aggregate_native_inner(snapshot, column, conditions, agg, None)
10000    }
10001
10002    pub fn aggregate_native_with_control(
10003        &self,
10004        snapshot: Snapshot,
10005        column: Option<u16>,
10006        conditions: &[crate::query::Condition],
10007        agg: NativeAgg,
10008        control: &crate::ExecutionControl,
10009    ) -> Result<Option<NativeAggResult>> {
10010        self.aggregate_native_inner(snapshot, column, conditions, agg, Some(control))
10011    }
10012
10013    fn aggregate_native_inner(
10014        &self,
10015        snapshot: Snapshot,
10016        column: Option<u16>,
10017        conditions: &[crate::query::Condition],
10018        agg: NativeAgg,
10019        control: Option<&crate::ExecutionControl>,
10020    ) -> Result<Option<NativeAggResult>> {
10021        execution_checkpoint(control, 0)?;
10022        if self.ttl.is_some() {
10023            return Ok(None);
10024        }
10025        // 1. Single clean run + no WHERE ⇒ MIN/MAX/COUNT(col) from page stats.
10026        if self.run_refs.len() == 1 && conditions.is_empty() {
10027            if let Some(res) = self.aggregate_from_stats(snapshot, column, agg)? {
10028                return Ok(Some(res));
10029            }
10030        }
10031        // 2. COUNT(*) ⇒ survivor count from the cursor's page plans, no decode.
10032        //    Overlay-only replicas (no sorted run yet) fall through to a
10033        //    visible-row scan so aggregate_native still serves correctly.
10034        if matches!(agg, NativeAgg::Count) && column.is_none() {
10035            if let Some(c) = self.scan_cursor(snapshot, Vec::new(), conditions)? {
10036                return Ok(Some(NativeAggResult::Count(c.remaining_rows() as u64)));
10037            }
10038            let rows = self.visible_rows_filtered(snapshot, conditions, control)?;
10039            return Ok(Some(NativeAggResult::Count(rows.len() as u64)));
10040        }
10041        // 3. Accumulate the projected column. COUNT(col) excludes nulls — the
10042        //    accumulator's count is the non-null count, which `pack_*` returns.
10043        let cid = match column {
10044            Some(c) => c,
10045            None => return Ok(None),
10046        };
10047        let ty = self.column_type(cid);
10048        if let Some(mut cursor) = self.scan_cursor(snapshot, vec![(cid, ty.clone())], conditions)? {
10049            execution_checkpoint(control, 0)?;
10050            return match ty {
10051                TypeId::Int64 | TypeId::TimestampNanos | TypeId::Date32 => {
10052                    let (count, sum, mn, mx) = accumulate_int(cursor.as_mut(), control)?;
10053                    Ok(Some(pack_int(agg, count, sum, mn, mx)))
10054                }
10055                TypeId::Float64 => {
10056                    let (count, sum, mn, mx) = accumulate_float(cursor.as_mut(), control)?;
10057                    Ok(Some(pack_float(agg, count, sum, mn, mx)))
10058                }
10059                _ => Ok(None),
10060            };
10061        }
10062        // Overlay-only / replica path: fold over visible rows in memory.
10063        let rows = self.visible_rows_filtered(snapshot, conditions, control)?;
10064        execution_checkpoint(control, 0)?;
10065        match ty {
10066            TypeId::Int64 | TypeId::TimestampNanos | TypeId::Date32 => {
10067                let mut count = 0u64;
10068                let mut sum = 0i128;
10069                let mut mn = i64::MAX;
10070                let mut mx = i64::MIN;
10071                for row in &rows {
10072                    if let Some(Value::Int64(v)) = row.columns.get(&cid) {
10073                        count += 1;
10074                        sum += i128::from(*v);
10075                        mn = mn.min(*v);
10076                        mx = mx.max(*v);
10077                    }
10078                }
10079                Ok(Some(pack_int(agg, count, sum, mn, mx)))
10080            }
10081            TypeId::Float64 => {
10082                let mut count = 0u64;
10083                let mut sum = 0.0f64;
10084                let mut mn = f64::INFINITY;
10085                let mut mx = f64::NEG_INFINITY;
10086                for row in &rows {
10087                    if let Some(Value::Float64(v)) = row.columns.get(&cid) {
10088                        count += 1;
10089                        sum += *v;
10090                        mn = mn.min(*v);
10091                        mx = mx.max(*v);
10092                    }
10093                }
10094                Ok(Some(pack_float(agg, count, sum, mn, mx)))
10095            }
10096            _ => Ok(None),
10097        }
10098    }
10099
10100    /// Visible rows matching `conditions`, for overlay-only aggregate fallbacks.
10101    fn visible_rows_filtered(
10102        &self,
10103        snapshot: Snapshot,
10104        conditions: &[crate::query::Condition],
10105        control: Option<&crate::ExecutionControl>,
10106    ) -> Result<Vec<Row>> {
10107        let rows = if let Some(control) = control {
10108            self.visible_rows_controlled(snapshot, control)?
10109        } else {
10110            self.visible_rows(snapshot)?
10111        };
10112        if conditions.is_empty() {
10113            return Ok(rows);
10114        }
10115        Ok(rows
10116            .into_iter()
10117            .filter(|row| {
10118                conditions
10119                    .iter()
10120                    .all(|cond| condition_matches_row(cond, row, &self.schema))
10121            })
10122            .collect())
10123    }
10124
10125    /// Phase 7.1 metadata fast path: answer an unfiltered `MIN`/`MAX`/`COUNT(col)`
10126    /// straight from page `min`/`max`/`null_count` — no column decode. Returns
10127    /// `None` (caller decodes) for `COUNT(*)`/`SUM`/`AVG`, when exact stats are
10128    /// unavailable (multi-version run; [`Table::exact_column_stats`] gates this),
10129    /// or for a column whose stats omit `min`/`max` while it still holds values
10130    /// (e.g. an encrypted column) — returning `NULL` there would be a wrong
10131    /// answer, so we fall back to decoding.
10132    fn aggregate_from_stats(
10133        &self,
10134        snapshot: Snapshot,
10135        column: Option<u16>,
10136        agg: NativeAgg,
10137    ) -> Result<Option<NativeAggResult>> {
10138        let cid = match (agg, column) {
10139            (NativeAgg::Count | NativeAgg::Min | NativeAgg::Max, Some(c)) => c,
10140            _ => return Ok(None), // COUNT(*), SUM, AVG: not served from page stats
10141        };
10142        let Some(stats) = self.exact_column_stats(snapshot, &[cid])? else {
10143            return Ok(None);
10144        };
10145        let Some(cs) = stats.get(&cid) else {
10146            return Ok(None);
10147        };
10148        match agg {
10149            // COUNT(col) excludes NULLs: live rows minus the column's null count.
10150            NativeAgg::Count => Ok(Some(NativeAggResult::Count(
10151                self.live_count.saturating_sub(cs.null_count),
10152            ))),
10153            NativeAgg::Min | NativeAgg::Max => {
10154                let bound = if agg == NativeAgg::Min {
10155                    &cs.min
10156                } else {
10157                    &cs.max
10158                };
10159                match bound {
10160                    Some(Value::Int64(x)) => Ok(Some(NativeAggResult::Int(*x))),
10161                    Some(Value::Float64(x)) => Ok(Some(NativeAggResult::Float(*x))),
10162                    Some(_) => Ok(None), // unexpected stat type ⇒ decode
10163                    // No bound: a genuine SQL NULL only when the column is wholly
10164                    // null. Otherwise the stats are simply unavailable (encrypted),
10165                    // so decode for a correct answer.
10166                    None if cs.null_count >= self.live_count => Ok(Some(NativeAggResult::Null)),
10167                    None => Ok(None),
10168                }
10169            }
10170            _ => Ok(None),
10171        }
10172    }
10173
10174    /// Phase 7.1c: exact `COUNT(DISTINCT col)` from the bitmap index's partition
10175    /// cardinality — the number of distinct indexed values — with no scan. Each
10176    /// distinct value is one bitmap key; under the insert-only invariant (empty
10177    /// overlay, single run, `live_count == row_count`) every key has at least one
10178    /// live row, so the key count is exact. `NULL` is excluded from
10179    /// `COUNT(DISTINCT)`, so a null key (from an explicit `Value::Null` put) is
10180    /// discounted. Returns `None` (caller scans) without a bitmap index on the
10181    /// column or when the invariant does not hold.
10182    pub fn count_distinct_from_bitmap(&mut self, column_id: u16) -> Result<Option<u64>> {
10183        if self.ttl.is_some() {
10184            return Ok(None);
10185        }
10186        if !(self.memtable.is_empty() && self.mutable_run.is_empty() && self.run_refs.len() == 1) {
10187            return Ok(None);
10188        }
10189        // A deferred bulk load leaves the bitmap unbuilt; complete it before
10190        // trusting its key count (same lazy contract as `query`/`flush`).
10191        self.ensure_indexes_complete()?;
10192        let reader = self.open_reader(self.run_refs[0].run_id)?;
10193        if self.live_count != reader.row_count() as u64 {
10194            return Ok(None);
10195        }
10196        let Some(bm) = self.bitmap.get(&column_id) else {
10197            return Ok(None); // no bitmap index ⇒ let the caller scan
10198        };
10199        let mut distinct = bm.value_count() as u64;
10200        // A null key (explicit `Value::Null`) is indexed but excluded from
10201        // COUNT(DISTINCT). (Schema-evolution-absent columns are never indexed.)
10202        if !bm.get(&Value::Null.encode_key()).is_empty() {
10203            distinct = distinct.saturating_sub(1);
10204        }
10205        Ok(Some(distinct))
10206    }
10207
10208    /// Incremental aggregate over the live table (Phase 8.3). For an append-only
10209    /// table, a warm cache entry (same `cache_key`) lets the result be refreshed
10210    /// by aggregating **only the newly inserted rows** (row-id watermark delta)
10211    /// and merging, instead of a full recompute. The caller supplies a stable
10212    /// `cache_key` (e.g. a hash of the SQL + projection); distinct queries must
10213    /// use distinct keys.
10214    ///
10215    /// Returns [`IncrementalAggResult`] with the merged state and whether the
10216    /// delta path was taken. A single `delete` (ever) disables the incremental
10217    /// path for the table, so correctness never relies on append-only behavior
10218    /// that deletes invalidate.
10219    pub fn aggregate_incremental(
10220        &mut self,
10221        cache_key: u64,
10222        conditions: &[crate::query::Condition],
10223        column: Option<u16>,
10224        agg: NativeAgg,
10225    ) -> Result<IncrementalAggResult> {
10226        self.aggregate_incremental_inner(cache_key, conditions, column, agg, None)
10227    }
10228
10229    pub fn aggregate_incremental_with_control(
10230        &mut self,
10231        cache_key: u64,
10232        conditions: &[crate::query::Condition],
10233        column: Option<u16>,
10234        agg: NativeAgg,
10235        control: &crate::ExecutionControl,
10236    ) -> Result<IncrementalAggResult> {
10237        self.aggregate_incremental_inner(cache_key, conditions, column, agg, Some(control))
10238    }
10239
10240    fn aggregate_incremental_inner(
10241        &mut self,
10242        cache_key: u64,
10243        conditions: &[crate::query::Condition],
10244        column: Option<u16>,
10245        agg: NativeAgg,
10246        control: Option<&crate::ExecutionControl>,
10247    ) -> Result<IncrementalAggResult> {
10248        execution_checkpoint(control, 0)?;
10249        let snap = self.snapshot();
10250        let cur_wm = self.allocator.current().0;
10251        let cur_epoch = snap.epoch.0;
10252        // The watermark equals the committed row count only when the memtable is
10253        // empty (every allocated row id is durably in a run). With pending
10254        // (uncommitted) writes the allocator is ahead of the visible set, so the
10255        // delta range would silently skip just-committed rows — disable the
10256        // incremental path entirely in that case. The mutable-run tier holding
10257        // un-spilled data also disables it (those rows aren't in a run yet).
10258        let incremental_ok = self.ttl.is_none()
10259            && !self.had_deletes
10260            && self.memtable.is_empty()
10261            && self.mutable_run.is_empty();
10262
10263        // Incremental path: append-only, no pending writes, warm cache, advanced
10264        // epoch.
10265        if incremental_ok {
10266            if let Some(cached) = self.agg_cache.get(&cache_key).cloned() {
10267                if cached.epoch == cur_epoch {
10268                    return Ok(IncrementalAggResult {
10269                        state: cached.state,
10270                        incremental: true,
10271                        delta_rows: 0,
10272                    });
10273                }
10274                if cached.epoch < cur_epoch && cached.watermark <= cur_wm {
10275                    let delta_len = cur_wm.saturating_sub(cached.watermark) as usize;
10276                    let mut delta_rids = Vec::with_capacity(delta_len);
10277                    for (index, row_id) in (cached.watermark..cur_wm).enumerate() {
10278                        execution_checkpoint(control, index)?;
10279                        delta_rids.push(row_id);
10280                    }
10281                    let delta_rows = self.rows_for_rids(&delta_rids, snap)?;
10282                    execution_checkpoint(control, 0)?;
10283                    let index_sets = self.resolve_index_conditions(conditions, snap)?;
10284                    let delta_state = agg_state_from_rows(
10285                        &delta_rows,
10286                        conditions,
10287                        &index_sets,
10288                        column,
10289                        agg,
10290                        &self.schema,
10291                        control,
10292                    )?;
10293                    let merged = cached.state.merge(delta_state);
10294                    let delta_n = delta_rids.len() as u64;
10295                    Arc::make_mut(&mut self.agg_cache).insert(
10296                        cache_key,
10297                        CachedAgg {
10298                            state: merged.clone(),
10299                            watermark: cur_wm,
10300                            epoch: cur_epoch,
10301                        },
10302                    );
10303                    return Ok(IncrementalAggResult {
10304                        state: merged,
10305                        incremental: true,
10306                        delta_rows: delta_n,
10307                    });
10308                }
10309            }
10310        }
10311
10312        // Cold path. For Count/Sum/Min/Max the fast vectorized cursor produces a
10313        // directly-seedable state; for Avg it returns only the mean (losing the
10314        // sum+count needed to merge a future delta), so Avg falls back to a
10315        // visible-rows scan that captures both.
10316        let cursor_ok =
10317            self.memtable.is_empty() && self.mutable_run.is_empty() && self.run_refs.len() == 1;
10318        let state = if cursor_ok && agg != NativeAgg::Avg {
10319            match self.aggregate_native_inner(snap, column, conditions, agg, control)? {
10320                Some(result) => {
10321                    AggState::from_native(result, agg, column.map(|c| self.column_type(c)))
10322                }
10323                None => self.agg_state_full_scan(conditions, column, agg, snap, control)?,
10324            }
10325        } else {
10326            self.agg_state_full_scan(conditions, column, agg, snap, control)?
10327        };
10328        // Seed only when the watermark is meaningful (no pending writes).
10329        if incremental_ok {
10330            Arc::make_mut(&mut self.agg_cache).insert(
10331                cache_key,
10332                CachedAgg {
10333                    state: state.clone(),
10334                    watermark: cur_wm,
10335                    epoch: cur_epoch,
10336                },
10337            );
10338        }
10339        Ok(IncrementalAggResult {
10340            state,
10341            incremental: false,
10342            delta_rows: 0,
10343        })
10344    }
10345
10346    /// Full visible-rows scan → [`AggState`] (cold path; captures sum+count for
10347    /// correct Avg seeding).
10348    fn agg_state_full_scan(
10349        &self,
10350        conditions: &[crate::query::Condition],
10351        column: Option<u16>,
10352        agg: NativeAgg,
10353        snap: Snapshot,
10354        control: Option<&crate::ExecutionControl>,
10355    ) -> Result<AggState> {
10356        execution_checkpoint(control, 0)?;
10357        let rows = self.visible_rows(snap)?;
10358        execution_checkpoint(control, 0)?;
10359        let index_sets = self.resolve_index_conditions(conditions, snap)?;
10360        agg_state_from_rows(
10361            &rows,
10362            conditions,
10363            &index_sets,
10364            column,
10365            agg,
10366            &self.schema,
10367            control,
10368        )
10369    }
10370
10371    /// Resolve only the index-defined conditions (`Ann`/`SparseMatch`) to row-id
10372    /// sets for membership testing during row-wise aggregation.
10373    fn resolve_index_conditions(
10374        &self,
10375        conditions: &[crate::query::Condition],
10376        snapshot: Snapshot,
10377    ) -> Result<Vec<RowIdSet>> {
10378        use crate::query::Condition;
10379        let mut sets = Vec::new();
10380        for c in conditions {
10381            if matches!(
10382                c,
10383                Condition::Ann { .. }
10384                    | Condition::SparseMatch { .. }
10385                    | Condition::MinHashSimilar { .. }
10386            ) {
10387                sets.push(self.resolve_condition(c, snapshot)?);
10388            }
10389        }
10390        Ok(sets)
10391    }
10392
10393    fn column_type(&self, cid: u16) -> TypeId {
10394        self.schema
10395            .columns
10396            .iter()
10397            .find(|c| c.id == cid)
10398            .map(|c| c.ty.clone())
10399            .unwrap_or(TypeId::Bytes)
10400    }
10401
10402    /// Approximate `COUNT`/`SUM`/`AVG` over a filtered set, computed from the
10403    /// in-memory reservoir sample (Phase 8.2). Returns a point estimate plus a
10404    /// normal-theory confidence interval at the supplied z-score (1.96 ≈ 95 %).
10405    ///
10406    /// The WHERE predicates are evaluated **exactly** on each sampled row (so
10407    /// LIKE/FM and equality/range contribute no index bias); `Ann`/`SparseMatch`
10408    /// are index-defined and resolved once to a row-id set that sampled rows are
10409    /// tested against. `Ok(None)` when there is no usable sample.
10410    pub fn approx_aggregate(
10411        &mut self,
10412        conditions: &[crate::query::Condition],
10413        column: Option<u16>,
10414        agg: ApproxAgg,
10415        z: f64,
10416    ) -> Result<Option<ApproxResult>> {
10417        self.approx_aggregate_with_candidate_authorization(conditions, column, agg, z, None)
10418    }
10419
10420    /// Security-aware approximate aggregate. RLS is evaluated only for the
10421    /// reservoir candidates, and column masks are applied before aggregation.
10422    pub fn approx_aggregate_with_candidate_authorization(
10423        &mut self,
10424        conditions: &[crate::query::Condition],
10425        column: Option<u16>,
10426        agg: ApproxAgg,
10427        z: f64,
10428        authorization: Option<&crate::security::CandidateAuthorization<'_>>,
10429    ) -> Result<Option<ApproxResult>> {
10430        use crate::query::Condition;
10431        self.ensure_reservoir_complete()?;
10432        // Approx stats estimate the current live population. Prefer the table
10433        // snapshot, but if HLC dual-model visibility filters the entire
10434        // reservoir sample (epoch-only vs HLC-stamped), fall back to an
10435        // unbounded product snapshot so Count/Sum estimates remain available.
10436        let mut snapshot = self.snapshot();
10437        let n_pop = self.count();
10438        let sample_rids: Vec<u64> = self.reservoir.row_ids().to_vec();
10439        if sample_rids.is_empty() {
10440            return Ok(None);
10441        }
10442        // Materialize the live, non-deleted sampled rows.
10443        let mut live_sample = self.rows_for_rids(&sample_rids, snapshot)?;
10444        if live_sample.is_empty() {
10445            snapshot = Snapshot::unbounded();
10446            live_sample = self.rows_for_rids(&sample_rids, snapshot)?;
10447        }
10448        let s = live_sample.len();
10449        if s == 0 {
10450            return Ok(None);
10451        }
10452        let authorized = authorization
10453            .map(|authorization| {
10454                let candidates = live_sample.iter().map(|row| row.row_id).collect::<Vec<_>>();
10455                self.policy_allowed_candidate_ids(&candidates, snapshot, authorization, None)
10456            })
10457            .transpose()?;
10458
10459        // Pre-resolve Ann/Sparse conditions (index-defined predicates) to row-id
10460        // sets; the per-row predicates below are evaluated exactly.
10461        let mut index_sets: Vec<RowIdSet> = Vec::new();
10462        for c in conditions {
10463            if matches!(
10464                c,
10465                Condition::Ann { .. }
10466                    | Condition::SparseMatch { .. }
10467                    | Condition::MinHashSimilar { .. }
10468            ) {
10469                index_sets.push(self.resolve_condition(c, snapshot)?);
10470            }
10471        }
10472
10473        // For Sum/Avg, gather the numeric column value of each passing row.
10474        let cid = match (agg, column) {
10475            (ApproxAgg::Count, _) => None,
10476            (_, Some(c)) => Some(c),
10477            _ => return Ok(None),
10478        };
10479        let mut passing_vals: Vec<f64> = Vec::with_capacity(s);
10480        for r in &live_sample {
10481            if authorized
10482                .as_ref()
10483                .is_some_and(|authorized| !authorized.contains(&r.row_id))
10484            {
10485                continue;
10486            }
10487            // Exact per-row predicate evaluation.
10488            if !conditions
10489                .iter()
10490                .all(|c| condition_matches_row(c, r, &self.schema))
10491            {
10492                continue;
10493            }
10494            // Ann/Sparse membership.
10495            if !index_sets.iter().all(|set| set.contains(r.row_id.0)) {
10496                continue;
10497            }
10498            if let Some(cid) = cid {
10499                let mut cells = r
10500                    .columns
10501                    .get(&cid)
10502                    .cloned()
10503                    .map(|value| vec![(cid, value)])
10504                    .unwrap_or_default();
10505                if let Some(authorization) = authorization {
10506                    authorization.security.apply_masks_to_cells(
10507                        authorization.table,
10508                        &mut cells,
10509                        authorization.principal,
10510                    );
10511                }
10512                if let Some(v) = as_f64(cells.first().map(|(_, value)| value)) {
10513                    passing_vals.push(v);
10514                } // nulls ⇒ excluded (matching SQL AVG/SUM null semantics)
10515            } else {
10516                passing_vals.push(0.0); // placeholder for COUNT
10517            }
10518        }
10519        let m = passing_vals.len();
10520
10521        let (point, half) = match agg {
10522            ApproxAgg::Count => {
10523                // Proportion estimate scaled to the population.
10524                let p = m as f64 / s as f64;
10525                let point = n_pop as f64 * p;
10526                let var = if s > 1 {
10527                    n_pop as f64 * n_pop as f64 * p * (1.0 - p) / s as f64
10528                        * (1.0 - s as f64 / n_pop as f64).max(0.0)
10529                } else {
10530                    0.0
10531                };
10532                (point, z * var.sqrt())
10533            }
10534            ApproxAgg::Sum => {
10535                // Horvitz–Thompson: each sampled row represents n_pop/s rows.
10536                let y: Vec<f64> = live_sample
10537                    .iter()
10538                    .map(|r| {
10539                        let passes_row = authorized
10540                            .as_ref()
10541                            .is_none_or(|authorized| authorized.contains(&r.row_id))
10542                            && conditions
10543                                .iter()
10544                                .all(|c| condition_matches_row(c, r, &self.schema))
10545                            && index_sets.iter().all(|set| set.contains(r.row_id.0));
10546                        if passes_row {
10547                            cid.and_then(|cid| {
10548                                let mut cells = r
10549                                    .columns
10550                                    .get(&cid)
10551                                    .cloned()
10552                                    .map(|value| vec![(cid, value)])
10553                                    .unwrap_or_default();
10554                                if let Some(authorization) = authorization {
10555                                    authorization.security.apply_masks_to_cells(
10556                                        authorization.table,
10557                                        &mut cells,
10558                                        authorization.principal,
10559                                    );
10560                                }
10561                                as_f64(cells.first().map(|(_, value)| value))
10562                            })
10563                            .unwrap_or(0.0)
10564                        } else {
10565                            0.0
10566                        }
10567                    })
10568                    .collect();
10569                let mean_y = y.iter().sum::<f64>() / s as f64;
10570                let point = n_pop as f64 * mean_y;
10571                let var = if s > 1 {
10572                    let ss: f64 = y.iter().map(|v| (v - mean_y).powi(2)).sum();
10573                    let var_y = ss / (s - 1) as f64;
10574                    n_pop as f64 * n_pop as f64 * var_y / s as f64
10575                        * (1.0 - s as f64 / n_pop as f64).max(0.0)
10576                } else {
10577                    0.0
10578                };
10579                (point, z * var.sqrt())
10580            }
10581            ApproxAgg::Avg => {
10582                if m == 0 {
10583                    return Ok(Some(ApproxResult {
10584                        point: 0.0,
10585                        ci_low: 0.0,
10586                        ci_high: 0.0,
10587                        n_population: n_pop,
10588                        n_sample_live: s,
10589                        n_passing: 0,
10590                    }));
10591                }
10592                let mean = passing_vals.iter().sum::<f64>() / m as f64;
10593                let half = if m > 1 {
10594                    let ss: f64 = passing_vals.iter().map(|v| (v - mean).powi(2)).sum();
10595                    let sd = (ss / (m - 1) as f64).sqrt();
10596                    let fpc = (1.0 - s as f64 / n_pop as f64).max(0.0);
10597                    z * sd / (m as f64).sqrt() * fpc.sqrt()
10598                } else {
10599                    0.0
10600                };
10601                (mean, half)
10602            }
10603        };
10604
10605        Ok(Some(ApproxResult {
10606            point,
10607            ci_low: point - half,
10608            ci_high: point + half,
10609            n_population: n_pop,
10610            n_sample_live: s,
10611            n_passing: m,
10612        }))
10613    }
10614
10615    /// Exact per-column statistics for the analytical aggregate fast path
10616    /// (Phase 7.1: `MIN`/`MAX`/`COUNT(col)` from page stats). Returns `None`
10617    /// unless the table is effectively insert-only at `snapshot` — empty
10618    /// memtable, a single sorted run, and `live_count == run.row_count()` — so
10619    /// the run's page `min`/`max`/`null_count` are exact (no tombstoned or
10620    /// superseded versions skew them). Under deletes/updates the caller falls
10621    /// back to scanning.
10622    pub fn exact_column_stats(
10623        &self,
10624        _snapshot: Snapshot,
10625        projection: &[u16],
10626    ) -> Result<Option<HashMap<u16, ColumnStat>>> {
10627        if self.ttl.is_some()
10628            || !(self.memtable.is_empty()
10629                && self.mutable_run.is_empty()
10630                && self.run_refs.len() == 1)
10631        {
10632            return Ok(None);
10633        }
10634        let reader = self.open_reader(self.run_refs[0].run_id)?;
10635        if self.live_count != reader.row_count() as u64 {
10636            return Ok(None);
10637        }
10638        let mut out = HashMap::new();
10639        for &cid in projection {
10640            let cdef = match self.schema.columns.iter().find(|c| c.id == cid) {
10641                Some(c) => c,
10642                None => continue,
10643            };
10644            // Absent column (schema evolution) ⇒ all rows null.
10645            let Some(stats) = reader.column_page_stats(cid) else {
10646                out.insert(
10647                    cid,
10648                    ColumnStat {
10649                        min: None,
10650                        max: None,
10651                        null_count: self.live_count,
10652                    },
10653                );
10654                continue;
10655            };
10656            let stat = match cdef.ty {
10657                TypeId::Int64 | TypeId::TimestampNanos | TypeId::Date32 => {
10658                    agg_int(stats, crate::sorted_run::be_i64).map(|(mn, mx, n)| ColumnStat {
10659                        min: mn.map(Value::Int64),
10660                        max: mx.map(Value::Int64),
10661                        null_count: n,
10662                    })
10663                }
10664                TypeId::Float64 => {
10665                    agg_float(stats, crate::sorted_run::be_f64).map(|(mn, mx, n)| ColumnStat {
10666                        min: mn.map(Value::Float64),
10667                        max: mx.map(Value::Float64),
10668                        null_count: n,
10669                    })
10670                }
10671                _ => None,
10672            };
10673            if let Some(s) = stat {
10674                out.insert(cid, s);
10675            }
10676        }
10677        Ok(Some(out))
10678    }
10679
10680    pub fn dir(&self) -> &Path {
10681        &self.dir
10682    }
10683
10684    pub fn schema(&self) -> &Schema {
10685        &self.schema
10686    }
10687
10688    pub fn ann_index(&self, column_id: u16) -> Option<&crate::index::AnnIndex> {
10689        self.ann.get(&column_id)
10690    }
10691
10692    pub fn ann_index_mut(&mut self, column_id: u16) -> Option<&mut crate::index::AnnIndex> {
10693        self.ann.get_mut(&column_id)
10694    }
10695
10696    pub(crate) fn set_catalog_name(&mut self, name: String) {
10697        self.name = name;
10698    }
10699
10700    pub(crate) fn prepare_alter_column(
10701        &mut self,
10702        column_name: &str,
10703        change: &AlterColumn,
10704    ) -> Result<(ColumnDef, Option<Schema>)> {
10705        if !self.pending_rows.is_empty() || !self.pending_dels.is_empty() {
10706            return Err(MongrelError::InvalidArgument(
10707                "ALTER COLUMN requires committing staged writes first".into(),
10708            ));
10709        }
10710        let old = self
10711            .schema
10712            .columns
10713            .iter()
10714            .find(|c| c.name == column_name)
10715            .cloned()
10716            .ok_or_else(|| MongrelError::Schema(format!("unknown column {column_name}")))?;
10717        let mut next = old.clone();
10718
10719        if let Some(name) = &change.name {
10720            let trimmed = name.trim();
10721            if trimmed.is_empty() {
10722                return Err(MongrelError::InvalidArgument(
10723                    "ALTER COLUMN name must not be empty".into(),
10724                ));
10725            }
10726            if trimmed != old.name && self.schema.columns.iter().any(|c| c.name == trimmed) {
10727                return Err(MongrelError::Schema(format!(
10728                    "column {trimmed} already exists"
10729                )));
10730            }
10731            next.name = trimmed.to_string();
10732        }
10733
10734        if let Some(ty) = &change.ty {
10735            next.ty = ty.clone();
10736        }
10737        if let Some(flags) = change.flags {
10738            validate_alter_column_flags(old.flags, flags)?;
10739            next.flags = flags;
10740        }
10741
10742        if let Some(default_change) = &change.default_value {
10743            next.default_value = default_change.clone();
10744        }
10745        if let Some(source_change) = &change.embedding_source {
10746            next.embedding_source = source_change.clone();
10747        }
10748
10749        validate_alter_column_type(&self.schema, &old, &next, self.has_stored_versions())?;
10750        if old.flags.contains(ColumnFlags::NULLABLE)
10751            && !next.flags.contains(ColumnFlags::NULLABLE)
10752            && self.column_has_nulls(old.id)?
10753        {
10754            return Err(MongrelError::InvalidArgument(format!(
10755                "column '{}' contains NULL values",
10756                old.name
10757            )));
10758        }
10759        if next == old {
10760            return Ok((next, None));
10761        }
10762        let mut schema = self.schema.clone();
10763        let index = schema
10764            .columns
10765            .iter()
10766            .position(|column| column.id == next.id)
10767            .ok_or_else(|| MongrelError::Schema(format!("unknown column {}", next.id)))?;
10768        schema.columns[index] = next.clone();
10769        schema.schema_id = schema
10770            .schema_id
10771            .checked_add(1)
10772            .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
10773        schema.validate_auto_increment()?;
10774        schema.validate_defaults()?;
10775        Ok((next, Some(schema)))
10776    }
10777
10778    pub(crate) fn apply_altered_schema_prepared(&mut self, schema: Schema) {
10779        self.schema = schema;
10780        self.auto_inc = resolve_auto_inc(&self.schema);
10781        self.column_keys = build_column_keys(self.kek.as_deref(), &self.schema);
10782        self.clear_result_cache();
10783        let _ = std::fs::remove_dir_all(self.dir.join("_shadow"));
10784    }
10785
10786    /// Publish one hidden index artifact with its prepared schema. Callers
10787    /// hold the final commit barrier and have already verified that
10788    /// `artifact` covers the table's exact current epoch.
10789    pub(crate) fn publish_index_schema_change(
10790        &mut self,
10791        schema: Schema,
10792        artifact: SecondaryIndexArtifact,
10793    ) -> Result<()> {
10794        self.apply_altered_schema_prepared(schema);
10795        self.prune_index_maps_to_schema();
10796        match artifact {
10797            SecondaryIndexArtifact::Bitmap(column_id, index) => {
10798                self.bitmap.insert(column_id, index);
10799            }
10800            SecondaryIndexArtifact::LearnedRange(column_id, index) => {
10801                Arc::make_mut(&mut self.learned_range).insert(column_id, index);
10802            }
10803            SecondaryIndexArtifact::Fm(column_id, index) => {
10804                self.fm.insert(column_id, *index);
10805            }
10806            SecondaryIndexArtifact::Ann(column_id, index) => {
10807                self.ann.insert(column_id, *index);
10808            }
10809            SecondaryIndexArtifact::Sparse(column_id, index) => {
10810                self.sparse.insert(column_id, index);
10811            }
10812            SecondaryIndexArtifact::MinHash(column_id, index) => {
10813                self.minhash.insert(column_id, index);
10814            }
10815        }
10816        self.indexes_complete = true;
10817        self.seal_generations();
10818        let view = Arc::new(self.capture_read_generation());
10819        self.published.store(Arc::clone(&view));
10820        checkpoint_current_schema(self)?;
10821        // The catalog + rows are authoritative. A later flush/checkpoint
10822        // persists this derived generation without extending the write fence.
10823        self.invalidate_index_checkpoint();
10824        Ok(())
10825    }
10826
10827    /// Drop one secondary index from the live maps without rewriting table
10828    /// rows. Installs `schema` (already missing the dropped index), prunes
10829    /// maps, publishes a new generation, and invalidates the derived
10830    /// global-index checkpoint so reopen rebuilds from the authoritative
10831    /// schema + rows.
10832    pub(crate) fn publish_index_drop(&mut self, schema: Schema) -> Result<()> {
10833        self.apply_altered_schema_prepared(schema);
10834        self.prune_index_maps_to_schema();
10835        self.indexes_complete = true;
10836        self.seal_generations();
10837        let view = Arc::new(self.capture_read_generation());
10838        self.published.store(Arc::clone(&view));
10839        checkpoint_current_schema(self)?;
10840        // Dropped-index maps no longer match any prior checkpoint image.
10841        self.invalidate_index_checkpoint();
10842        Ok(())
10843    }
10844
10845    fn prune_index_maps_to_schema(&mut self) {
10846        let keep_bitmap: std::collections::HashSet<u16> = self
10847            .schema
10848            .indexes
10849            .iter()
10850            .filter(|index| index.kind == IndexKind::Bitmap)
10851            .map(|index| index.column_id)
10852            .collect();
10853        let keep_ann: std::collections::HashSet<u16> = self
10854            .schema
10855            .indexes
10856            .iter()
10857            .filter(|index| index.kind == IndexKind::Ann)
10858            .map(|index| index.column_id)
10859            .collect();
10860        let keep_fm: std::collections::HashSet<u16> = self
10861            .schema
10862            .indexes
10863            .iter()
10864            .filter(|index| index.kind == IndexKind::FmIndex)
10865            .map(|index| index.column_id)
10866            .collect();
10867        let keep_sparse: std::collections::HashSet<u16> = self
10868            .schema
10869            .indexes
10870            .iter()
10871            .filter(|index| index.kind == IndexKind::Sparse)
10872            .map(|index| index.column_id)
10873            .collect();
10874        let keep_minhash: std::collections::HashSet<u16> = self
10875            .schema
10876            .indexes
10877            .iter()
10878            .filter(|index| index.kind == IndexKind::MinHash)
10879            .map(|index| index.column_id)
10880            .collect();
10881        let keep_learned: std::collections::HashSet<u16> = self
10882            .schema
10883            .indexes
10884            .iter()
10885            .filter(|index| index.kind == IndexKind::LearnedRange)
10886            .map(|index| index.column_id)
10887            .collect();
10888        self.bitmap
10889            .retain(|column_id, _| keep_bitmap.contains(column_id));
10890        self.ann.retain(|column_id, _| keep_ann.contains(column_id));
10891        self.fm.retain(|column_id, _| keep_fm.contains(column_id));
10892        self.sparse
10893            .retain(|column_id, _| keep_sparse.contains(column_id));
10894        self.minhash
10895            .retain(|column_id, _| keep_minhash.contains(column_id));
10896        {
10897            let learned = Arc::make_mut(&mut self.learned_range);
10898            learned.retain(|column_id, _| keep_learned.contains(column_id));
10899        }
10900    }
10901
10902    pub(crate) fn checkpoint_altered_schema(&mut self) -> Result<()> {
10903        checkpoint_current_schema(self)
10904    }
10905
10906    pub fn alter_column(&mut self, column_name: &str, change: AlterColumn) -> Result<ColumnDef> {
10907        self.ensure_writable()?;
10908        let previous_schema = self.schema.clone();
10909        let (column, schema) = self.prepare_alter_column(column_name, &change)?;
10910        if let Some(schema) = schema {
10911            self.apply_altered_schema_prepared(schema);
10912            self.checkpoint_standalone_schema_change(previous_schema)?;
10913        }
10914        Ok(column)
10915    }
10916
10917    fn column_has_nulls(&mut self, column_id: u16) -> Result<bool> {
10918        if self.live_count == 0 {
10919            return Ok(false);
10920        }
10921        let snap = self.snapshot();
10922        let columns = self.visible_columns_native(snap, Some(&[column_id]))?;
10923        Ok(columns
10924            .first()
10925            .map(|(_, col)| col.null_count(col.len()) != 0)
10926            .unwrap_or(true))
10927    }
10928
10929    fn has_stored_versions(&self) -> bool {
10930        !self.memtable.is_empty()
10931            || !self.mutable_run.is_empty()
10932            || self.run_refs.iter().any(|r| r.row_count > 0)
10933            || !self.retiring.is_empty()
10934    }
10935
10936    /// Add a column to the schema (schema evolution). Existing runs simply read
10937    /// back as null for the new column until re-written. Persists the new schema
10938    /// and manifest. The caller supplies the full [`ColumnFlags`] so migrations
10939    /// can add `PRIMARY KEY` / `AUTO_INCREMENT` columns correctly.
10940    pub fn add_column(
10941        &mut self,
10942        name: &str,
10943        ty: TypeId,
10944        flags: ColumnFlags,
10945        default_value: Option<crate::schema::DefaultExpr>,
10946    ) -> Result<u16> {
10947        self.add_column_with_id(name, ty, flags, default_value, None)
10948    }
10949
10950    pub fn add_column_with_id(
10951        &mut self,
10952        name: &str,
10953        ty: TypeId,
10954        flags: ColumnFlags,
10955        default_value: Option<crate::schema::DefaultExpr>,
10956        requested_id: Option<u16>,
10957    ) -> Result<u16> {
10958        self.ensure_writable()?;
10959        let previous_schema = self.schema.clone();
10960        let (column, schema) =
10961            self.prepare_add_column(name, ty, flags, default_value, requested_id)?;
10962        self.apply_altered_schema_prepared(schema);
10963        self.checkpoint_standalone_schema_change(previous_schema)?;
10964        Ok(column.id)
10965    }
10966
10967    pub(crate) fn prepare_add_column(
10968        &mut self,
10969        name: &str,
10970        ty: TypeId,
10971        flags: ColumnFlags,
10972        default_value: Option<crate::schema::DefaultExpr>,
10973        requested_id: Option<u16>,
10974    ) -> Result<(ColumnDef, Schema)> {
10975        self.ensure_writable()?;
10976        if self.schema.columns.iter().any(|c| c.name == name) {
10977            return Err(MongrelError::Schema(format!(
10978                "column {name} already exists"
10979            )));
10980        }
10981        let id = if let Some(id) = requested_id.filter(|id| *id != 0) {
10982            if self.schema.columns.iter().any(|c| c.id == id) {
10983                return Err(MongrelError::Schema(format!(
10984                    "column id {id} already exists"
10985                )));
10986            }
10987            id
10988        } else {
10989            self.schema
10990                .columns
10991                .iter()
10992                .map(|c| c.id)
10993                .max()
10994                .unwrap_or(0)
10995                .checked_add(1)
10996                .ok_or_else(|| MongrelError::Schema("column id space exhausted".into()))?
10997        };
10998        let column = ColumnDef {
10999            id,
11000            name: name.to_string(),
11001            ty,
11002            flags,
11003            default_value,
11004            embedding_source: None,
11005        };
11006        let mut next_schema = self.schema.clone();
11007        next_schema.columns.push(column.clone());
11008        next_schema.schema_id = next_schema
11009            .schema_id
11010            .checked_add(1)
11011            .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
11012        next_schema.validate_auto_increment()?;
11013        next_schema.validate_defaults()?;
11014        Ok((column, next_schema))
11015    }
11016
11017    /// Declare a `LearnedRange` (PGM) index on an existing numeric column and
11018    /// build it immediately from the current sorted run (Phase 13.3). After
11019    /// this, `Condition::Range` / `Condition::RangeF64` on that column resolve
11020    /// survivors sub-linearly (O(log segments + log ε)) instead of scanning the
11021    /// full column.
11022    ///
11023    /// Requires exactly one sorted run (call after `flush`). The index is
11024    /// rebuilt automatically on subsequent flushes.
11025    pub fn add_learned_range_index(&mut self, column_name: &str) -> Result<()> {
11026        self.ensure_writable()?;
11027        let cid = self
11028            .schema
11029            .columns
11030            .iter()
11031            .find(|c| c.name == column_name)
11032            .map(|c| c.id)
11033            .ok_or_else(|| MongrelError::Schema(format!("unknown column {column_name}")))?;
11034        let ty = self
11035            .schema
11036            .columns
11037            .iter()
11038            .find(|c| c.id == cid)
11039            .map(|c| c.ty.clone())
11040            .unwrap_or(TypeId::Int64);
11041        if !matches!(
11042            ty,
11043            TypeId::Int64 | TypeId::Float64 | TypeId::TimestampNanos | TypeId::Date32
11044        ) {
11045            return Err(MongrelError::Schema(format!(
11046                "LearnedRange requires a numeric column; {column_name} is {ty:?}"
11047            )));
11048        }
11049        if self
11050            .schema
11051            .indexes
11052            .iter()
11053            .any(|i| i.column_id == cid && i.kind == IndexKind::LearnedRange)
11054        {
11055            return Ok(()); // already declared
11056        }
11057        let previous_schema = self.schema.clone();
11058        let previous_learned_range = Arc::clone(&self.learned_range);
11059        let mut next_schema = previous_schema.clone();
11060        next_schema.indexes.push(IndexDef {
11061            name: format!("{}_learned_range", column_name),
11062            column_id: cid,
11063            kind: IndexKind::LearnedRange,
11064            predicate: None,
11065            options: Default::default(),
11066        });
11067        next_schema.schema_id = next_schema
11068            .schema_id
11069            .checked_add(1)
11070            .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
11071        self.apply_altered_schema_prepared(next_schema);
11072        if let Err(error) = self.build_learned_ranges() {
11073            self.apply_altered_schema_prepared(previous_schema);
11074            self.learned_range = previous_learned_range;
11075            return Err(error);
11076        }
11077        if let Err(error) = self.checkpoint_standalone_schema_change(previous_schema) {
11078            if !matches!(
11079                &error,
11080                MongrelError::DurableCommit { .. } | MongrelError::CommitOutcomeUnknown { .. }
11081            ) {
11082                self.learned_range = previous_learned_range;
11083            }
11084            return Err(error);
11085        }
11086        Ok(())
11087    }
11088
11089    fn checkpoint_standalone_schema_change(&mut self, previous_schema: Schema) -> Result<()> {
11090        let mut schema_published = false;
11091        let schema_result = match self._root_guard.as_deref() {
11092            Some(root) => write_schema_durable_with_after(root, &self.schema, || {
11093                schema_published = true;
11094            }),
11095            None => write_schema_with_after(&self.dir, &self.schema, || {
11096                schema_published = true;
11097            }),
11098        };
11099        if schema_result.is_err() && !schema_published {
11100            self.apply_altered_schema_prepared(previous_schema);
11101            return schema_result;
11102        }
11103
11104        let manifest_result = self.persist_manifest(self.current_epoch());
11105        match (schema_result, manifest_result) {
11106            (_, Ok(())) => Ok(()),
11107            (Ok(()), Err(error)) => {
11108                self.poison_after_maintenance_publish_failure();
11109                Err(MongrelError::DurableCommit {
11110                    epoch: self.current_epoch().0,
11111                    message: format!(
11112                        "schema is durable but matching manifest publication failed: {error}"
11113                    ),
11114                })
11115            }
11116            (Err(schema_error), Err(manifest_error)) => {
11117                self.poison_after_maintenance_publish_failure();
11118                Err(MongrelError::CommitOutcomeUnknown {
11119                    epoch: self.current_epoch().0,
11120                    message: format!(
11121                        "schema publication sync failed ({schema_error}); matching manifest publication also failed ({manifest_error})"
11122                    ),
11123                })
11124            }
11125        }
11126    }
11127
11128    /// Tuning knob for the WAL auto-sync threshold. A no-op on a mounted table
11129    /// (the shared WAL's durability is governed by the group-commit coordinator).
11130    pub fn set_sync_byte_threshold(&mut self, threshold: u64) {
11131        self.sync_byte_threshold = threshold;
11132        if let WalSink::Private(w) = &mut self.wal {
11133            w.set_sync_byte_threshold(threshold);
11134        }
11135    }
11136
11137    /// Flush all live page-cache entries to the persistent `_cache/` backing
11138    /// directory (best-effort). Useful before a clean shutdown so hot pages
11139    /// survive restart.
11140    pub fn page_cache_flush(&self) {
11141        self.page_cache.flush_to_disk();
11142    }
11143
11144    /// Number of entries currently in the shared page cache (diagnostic).
11145    pub fn page_cache_len(&self) -> usize {
11146        self.page_cache.len()
11147    }
11148
11149    /// Number of entries currently in the shared decoded-page cache (Phase
11150    /// 15.4 diagnostic).
11151    pub fn decoded_cache_len(&self) -> usize {
11152        self.decoded_cache.len()
11153    }
11154
11155    /// Drain the live memtable (prototype/testing helper used by the flush path
11156    /// demos). Prefer [`Table::flush`] for the durable path.
11157    pub fn drain_memtable_sorted(&mut self) -> Vec<Row> {
11158        self.memtable.drain_sorted()
11159    }
11160
11161    pub(crate) fn run_path(&self, run_id: u64) -> PathBuf {
11162        self.runs_dir().join(format!("r-{run_id}.sr"))
11163    }
11164
11165    pub(crate) fn create_run_file(&self, run_id: u64) -> Result<Option<std::fs::File>> {
11166        match self.runs_root.as_deref() {
11167            Some(root) => Ok(Some(root.create_regular_new(format!("r-{run_id}.sr"))?)),
11168            None => Ok(None),
11169        }
11170    }
11171
11172    pub(crate) fn create_run_entry(&self, name: &Path) -> Result<Option<std::fs::File>> {
11173        match self.runs_root.as_deref() {
11174            Some(root) => Ok(Some(root.create_regular_new(name)?)),
11175            None => Ok(None),
11176        }
11177    }
11178
11179    pub(crate) fn remove_run_entry(&self, name: &Path) -> Result<()> {
11180        match self.runs_root.as_deref() {
11181            Some(root) => match root.remove_file(name) {
11182                Ok(()) => Ok(()),
11183                Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
11184                Err(error) => Err(error.into()),
11185            },
11186            None => match std::fs::remove_file(self.runs_dir().join(name)) {
11187                Ok(()) => Ok(()),
11188                Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
11189                Err(error) => Err(error.into()),
11190            },
11191        }
11192    }
11193
11194    pub(crate) fn publish_run_entry(&self, source: &Path, destination: &Path) -> Result<()> {
11195        match self.runs_root.as_deref() {
11196            Some(root) => root
11197                .rename_file_new(source, destination)
11198                .map_err(Into::into),
11199            None => crate::durable_file::rename(
11200                &self.runs_dir().join(source),
11201                &self.runs_dir().join(destination),
11202            )
11203            .map_err(Into::into),
11204        }
11205    }
11206
11207    pub(crate) fn active_run_ids(&self) -> impl Iterator<Item = u128> + '_ {
11208        self.run_refs.iter().map(|run| run.run_id)
11209    }
11210
11211    pub(crate) fn table_dir(&self) -> &Path {
11212        &self.dir
11213    }
11214
11215    pub(crate) fn schema_ref(&self) -> &crate::schema::Schema {
11216        &self.schema
11217    }
11218
11219    pub(crate) fn alloc_run_id(&mut self) -> Result<u64> {
11220        let id = self.next_run_id;
11221        self.next_run_id = self
11222            .next_run_id
11223            .checked_add(1)
11224            .ok_or_else(|| MongrelError::Full("run-id namespace exhausted".into()))?;
11225        Ok(id)
11226    }
11227
11228    pub(crate) fn link_run(&mut self, run_ref: crate::manifest::RunRef) {
11229        self.run_refs.push(run_ref);
11230    }
11231
11232    /// Link a spilled run found during shared-WAL recovery (spec §8.5).
11233    /// **Idempotent**: if the run is already in the manifest (the publish phase
11234    /// persisted it before the crash, or this is a clean reopen with the
11235    /// `TxnCommit` still in the WAL) this is a no-op returning `false`, so the
11236    /// caller never double-links or double-counts. Otherwise — a crash *after*
11237    /// the commit fsync but *before* publish persisted the manifest — the run is
11238    /// Enqueue a compaction-superseded run for retention-gated deletion (spec
11239    /// §6.4). The file stays on disk until [`Self::reap_retiring`] removes it
11240    /// once `min_active_snapshot` has advanced past `retire_epoch`.
11241    pub(crate) fn retire_run(&mut self, run_id: u128, retire_epoch: u64) {
11242        self.retiring.push(crate::manifest::RetiredRun {
11243            run_id,
11244            retire_epoch,
11245        });
11246    }
11247
11248    /// Physically delete retired run files whose `retire_epoch` no pinned reader
11249    /// can still need (`min_active >= retire_epoch`), drop them from the queue,
11250    /// and persist the manifest if anything changed. Returns the count reaped.
11251    pub(crate) fn reap_retiring(
11252        &mut self,
11253        min_active: Epoch,
11254        backup_pinned: &std::collections::HashSet<u128>,
11255    ) -> Result<usize> {
11256        if self.retiring.is_empty() {
11257            return Ok(0);
11258        }
11259        let mut reaped = 0;
11260        let mut kept: Vec<crate::manifest::RetiredRun> = Vec::new();
11261        // Delete-then-persist is crash-idempotent: if we crash after unlinking
11262        // some files but before persisting, the manifest still lists them in
11263        // `retiring`; the next `reap_retiring` re-issues `remove_file` (the
11264        // error is ignored) and `check()` excludes `retiring` ids from orphan
11265        // detection, so the lingering entries are harmless until then.
11266        for r in std::mem::take(&mut self.retiring) {
11267            if min_active.0 >= r.retire_epoch && !backup_pinned.contains(&r.run_id) {
11268                let _ = self.remove_run_entry(Path::new(&format!("r-{}.sr", r.run_id)));
11269                reaped += 1;
11270            } else {
11271                kept.push(r);
11272            }
11273        }
11274        self.retiring = kept;
11275        if reaped > 0 {
11276            self.persist_manifest(self.current_epoch())?;
11277        }
11278        Ok(reaped)
11279    }
11280
11281    pub(crate) fn has_reapable_retiring(
11282        &self,
11283        min_active: Epoch,
11284        backup_pinned: &std::collections::HashSet<u128>,
11285    ) -> bool {
11286        self.retiring
11287            .iter()
11288            .any(|run| min_active.0 >= run.retire_epoch && !backup_pinned.contains(&run.run_id))
11289    }
11290
11291    pub(crate) fn recover_spilled_run(&mut self, run_ref: crate::manifest::RunRef) -> bool {
11292        if self.run_refs.iter().any(|r| r.run_id == run_ref.run_id) {
11293            return false;
11294        }
11295        self.live_count = self.live_count.saturating_add(run_ref.row_count);
11296        self.run_refs.push(run_ref);
11297        self.indexes_complete = false;
11298        true
11299    }
11300
11301    pub(crate) fn kek_ref(&self) -> Option<&Arc<Kek>> {
11302        self.kek.as_ref()
11303    }
11304
11305    pub(crate) fn open_reader(&self, run_id: u128) -> Result<RunReader> {
11306        let mut reader = match self.runs_root.as_deref() {
11307            Some(root) => RunReader::open_file_with_cache(
11308                root.open_regular(format!("r-{run_id}.sr"))?,
11309                self.schema.clone(),
11310                self.kek.clone(),
11311                Some(self.page_cache.clone()),
11312                Some(self.decoded_cache.clone()),
11313                self.table_id,
11314                Some(&self.verified_runs),
11315                None,
11316            )?,
11317            None => RunReader::open_with_cache(
11318                self.dir.join(RUNS_DIR).join(format!("r-{run_id}.sr")),
11319                self.schema.clone(),
11320                self.kek.clone(),
11321                Some(self.page_cache.clone()),
11322                Some(self.decoded_cache.clone()),
11323                self.table_id,
11324                Some(&self.verified_runs),
11325            )?,
11326        };
11327        // Overlay the real commit epoch for uniform-epoch (large-txn spill) runs:
11328        // their stored `_epoch` is a placeholder; the manifest RunRef carries the
11329        // assigned epoch. A no-op for ordinary runs.
11330        if let Some(rr) = self.run_refs.iter().find(|r| r.run_id == run_id) {
11331            reader.set_uniform_epoch(Epoch(rr.epoch_created));
11332        }
11333        Ok(reader)
11334    }
11335
11336    pub(crate) fn run_refs(&self) -> &[RunRef] {
11337        &self.run_refs
11338    }
11339
11340    pub(crate) fn retiring_run_ids(&self) -> impl Iterator<Item = u128> + '_ {
11341        self.retiring.iter().map(|run| run.run_id)
11342    }
11343
11344    pub(crate) fn runs_dir(&self) -> PathBuf {
11345        self.runs_root
11346            .as_deref()
11347            .and_then(|root| root.io_path().ok())
11348            .unwrap_or_else(|| self.dir.join(RUNS_DIR))
11349    }
11350
11351    pub(crate) fn wal_dir(&self) -> PathBuf {
11352        self.dir.join(WAL_DIR)
11353    }
11354
11355    pub(crate) fn set_run_refs(&mut self, refs: Vec<RunRef>) {
11356        self.run_refs = refs;
11357    }
11358
11359    pub(crate) fn compaction_zstd_level(&self) -> i32 {
11360        self.compaction_zstd_level
11361    }
11362
11363    pub(crate) fn kek(&self) -> Option<Arc<Kek>> {
11364        self.kek.clone()
11365    }
11366
11367    /// The index-checkpoint DEK (KEK-derived) for encrypted tables; `None` for
11368    /// plaintext tables. The checkpoint embeds index keys / PGM segment values
11369    /// derived from user data, so an encrypted table must encrypt it at rest.
11370    fn idx_dek(&self) -> Option<Zeroizing<[u8; DEK_LEN]>> {
11371        self.kek.as_ref().map(|k| k.derive_idx_key())
11372    }
11373
11374    /// Manifest (and other DB-wide metadata) meta DEK, derived from the KEK so
11375    /// the on-disk manifest is encrypted + authenticated at rest for encrypted
11376    /// tables. `None` for plaintext.
11377    fn manifest_meta_dek(&self) -> Option<[u8; DEK_LEN]> {
11378        self.kek.as_ref().map(|k| *k.derive_meta_key())
11379    }
11380
11381    /// `(column_id, scheme)` for every ENCRYPTED_INDEXABLE column — passed to
11382    /// the run writer so each run's descriptor records the column keys.
11383    pub(crate) fn indexable_column_specs(&self) -> Vec<(u16, u8)> {
11384        self.column_keys
11385            .iter()
11386            .map(|(&id, &(_, scheme))| (id, scheme))
11387            .collect()
11388    }
11389
11390    /// Tokenize a value for an ENCRYPTED_INDEXABLE column (HMAC-eq or OPE-range,
11391    /// per the column's scheme). Returns `None` for plaintext columns. Indexes
11392    /// over such columns store tokens, and queries tokenize literals the same
11393    /// way — so lookups never decrypt the stored (encrypted) page payloads.
11394    fn tokenize_value(&self, column_id: u16, v: &Value) -> Option<Value> {
11395        self.tokenize_value_enc(column_id, v)
11396    }
11397
11398    fn tokenize_value_enc(&self, column_id: u16, v: &Value) -> Option<Value> {
11399        use crate::encryption::{hmac_token, ope_token_f64, ope_token_i64, SCHEME_HMAC_EQ};
11400        let (key, scheme) = self.column_keys.get(&column_id)?;
11401        let token: Vec<u8> = match (*scheme, v) {
11402            (SCHEME_HMAC_EQ, _) => hmac_token(key, &v.encode_key()).to_vec(),
11403            (_, Value::Int64(x)) => ope_token_i64(key, *x).to_vec(),
11404            (_, Value::Float64(x)) => ope_token_f64(key, *x).to_vec(),
11405            _ => hmac_token(key, &v.encode_key()).to_vec(),
11406        };
11407        Some(Value::Bytes(token))
11408    }
11409
11410    /// Encoded index key for a `Value`, tokenized for HMAC-eq columns.
11411    fn index_lookup_key(&self, column_id: u16, v: &Value) -> Vec<u8> {
11412        self.index_lookup_key_bytes(column_id, &v.encode_key())
11413    }
11414
11415    /// Tokenize an already-encoded lookup key (equality queries pass the
11416    /// encoded search value; HMAC-eq columns wrap it under the column key).
11417    fn index_lookup_key_bytes(&self, column_id: u16, encoded: &[u8]) -> Vec<u8> {
11418        {
11419            use crate::encryption::{hmac_token, SCHEME_HMAC_EQ};
11420            if let Some((key, scheme)) = self.column_keys.get(&column_id) {
11421                if *scheme == SCHEME_HMAC_EQ {
11422                    return hmac_token(key, encoded).to_vec();
11423                }
11424            }
11425        }
11426        let _ = column_id;
11427        encoded.to_vec()
11428    }
11429}
11430
11431fn native_int64_strictly_increasing(col: &columnar::NativeColumn, n: usize) -> bool {
11432    let columnar::NativeColumn::Int64 { data, validity } = col else {
11433        return false;
11434    };
11435    if data.len() < n || !columnar::all_non_null(validity, n) {
11436        return false;
11437    }
11438    data.iter()
11439        .take(n)
11440        .zip(data.iter().skip(1))
11441        .all(|(a, b)| a < b)
11442}
11443
11444/// Exact aggregate of a column's page stats into a min/max/null_count triple
11445/// (Phase 7.1). Only meaningful when the owning table is insert-only, which
11446/// [`Table::exact_column_stats`] gates on.
11447#[derive(Debug, Clone)]
11448pub struct ColumnStat {
11449    pub min: Option<Value>,
11450    pub max: Option<Value>,
11451    pub null_count: u64,
11452}
11453
11454/// A supported native aggregate (Phase 7.2).
11455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11456pub enum NativeAgg {
11457    Count,
11458    Sum,
11459    Min,
11460    Max,
11461    Avg,
11462}
11463
11464/// The typed result of a [`NativeAgg`] over a column.
11465#[derive(Debug, Clone, PartialEq)]
11466pub enum NativeAggResult {
11467    Count(u64),
11468    Int(i64),
11469    Float(f64),
11470    /// No non-null inputs (SUM/MIN/MAX/AVG over zero rows ⇒ SQL NULL).
11471    Null,
11472}
11473
11474/// A supported approximate aggregate over the reservoir sample (Phase 8.2).
11475#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11476pub enum ApproxAgg {
11477    Count,
11478    Sum,
11479    Avg,
11480}
11481
11482/// Point estimate with a normal-theory confidence interval from the reservoir
11483/// sample (Phase 8.2). `ci_low`/`ci_high` bracket `point` at the requested
11484/// z-score; the interval has zero width when the sample equals the whole table.
11485#[derive(Debug, Clone)]
11486pub struct ApproxResult {
11487    /// Point estimate of the aggregate.
11488    pub point: f64,
11489    /// Lower bound (`point − z·SE`).
11490    pub ci_low: f64,
11491    /// Upper bound (`point + z·SE`).
11492    pub ci_high: f64,
11493    /// Live population size (the table's `count()`).
11494    pub n_population: u64,
11495    /// Live rows in the sample (`≤` reservoir capacity).
11496    pub n_sample_live: usize,
11497    /// Sampled rows passing the WHERE predicate.
11498    pub n_passing: usize,
11499}
11500
11501/// A mergeable running aggregate state (Phase 8.3). Two states over disjoint
11502/// row sets `merge` into the state over their union, so a cached analytical
11503/// aggregate can be updated by merging in only the delta (newly inserted rows)
11504/// instead of a full recompute.
11505#[derive(Debug, Clone, PartialEq)]
11506pub enum AggState {
11507    /// `COUNT(*)` or `COUNT(col)` over `n` matching rows.
11508    Count(u64),
11509    /// Int64 `SUM`: running `i128` sum + non-null count.
11510    SumI {
11511        sum: i128,
11512        count: u64,
11513    },
11514    /// Float64 `SUM`: running `f64` sum + non-null count.
11515    SumF {
11516        sum: f64,
11517        count: u64,
11518    },
11519    /// Int64 `AVG`: running `i128` sum + non-null count (avg = sum/count).
11520    AvgI {
11521        sum: i128,
11522        count: u64,
11523    },
11524    /// Float64 `AVG`: running `f64` sum + non-null count.
11525    AvgF {
11526        sum: f64,
11527        count: u64,
11528    },
11529    /// Int64 `MIN`/`MAX`.
11530    MinI(i64),
11531    MaxI(i64),
11532    /// Float64 `MIN`/`MAX`.
11533    MinF(f64),
11534    MaxF(f64),
11535    /// No matching rows observed yet.
11536    Empty,
11537}
11538
11539impl AggState {
11540    /// Combine two states over disjoint row sets into the state over the union.
11541    pub fn merge(self, other: AggState) -> AggState {
11542        use AggState::*;
11543        match (self, other) {
11544            (Empty, x) | (x, Empty) => x,
11545            (Count(a), Count(b)) => Count(a + b),
11546            (SumI { sum: sa, count: ca }, SumI { sum: sb, count: cb }) => SumI {
11547                sum: sa + sb,
11548                count: ca + cb,
11549            },
11550            (SumF { sum: sa, count: ca }, SumF { sum: sb, count: cb }) => SumF {
11551                sum: sa + sb,
11552                count: ca + cb,
11553            },
11554            (AvgI { sum: sa, count: ca }, AvgI { sum: sb, count: cb }) => AvgI {
11555                sum: sa + sb,
11556                count: ca + cb,
11557            },
11558            (AvgF { sum: sa, count: ca }, AvgF { sum: sb, count: cb }) => AvgF {
11559                sum: sa + sb,
11560                count: ca + cb,
11561            },
11562            (MinI(a), MinI(b)) => MinI(a.min(b)),
11563            (MaxI(a), MaxI(b)) => MaxI(a.max(b)),
11564            (MinF(a), MinF(b)) => MinF(a.min(b)),
11565            (MaxF(a), MaxF(b)) => MaxF(a.max(b)),
11566            _ => Empty, // mismatched kinds — shouldn't happen (same query)
11567        }
11568    }
11569
11570    /// The scalar point value (`f64`), or `None` when there were no inputs.
11571    pub fn point(&self) -> Option<f64> {
11572        match self {
11573            AggState::Count(n) => Some(*n as f64),
11574            AggState::SumI { sum, .. } => Some(*sum as f64),
11575            AggState::SumF { sum, .. } => Some(*sum),
11576            AggState::AvgI { sum, count } if *count > 0 => Some(*sum as f64 / *count as f64),
11577            AggState::AvgF { sum, count } if *count > 0 => Some(*sum / *count as f64),
11578            AggState::MinI(n) => Some(*n as f64),
11579            AggState::MaxI(n) => Some(*n as f64),
11580            AggState::MinF(n) => Some(*n),
11581            AggState::MaxF(n) => Some(*n),
11582            AggState::AvgI { .. } | AggState::AvgF { .. } | AggState::Empty => None,
11583        }
11584    }
11585
11586    /// Convert a vectorized [`NativeAggResult`] (from the cursor path) into a
11587    /// mergeable [`AggState`], so the incremental cache can be seeded from the
11588    /// fast cold path. `ty` is the column's type (`None` for COUNT(*)).
11589    pub fn from_native(result: NativeAggResult, agg: NativeAgg, ty: Option<TypeId>) -> Self {
11590        let is_float = matches!(ty, Some(TypeId::Float64));
11591        match (agg, result) {
11592            (NativeAgg::Count, NativeAggResult::Count(n)) => AggState::Count(n),
11593            (NativeAgg::Sum, NativeAggResult::Int(x)) => AggState::SumI {
11594                sum: x as i128,
11595                count: 1, // count unknown from NativeAggResult; use sentinel
11596            },
11597            (NativeAgg::Sum, NativeAggResult::Float(x)) => AggState::SumF { sum: x, count: 1 },
11598            (NativeAgg::Avg, NativeAggResult::Float(x)) => AggState::AvgF { sum: x, count: 1 },
11599            (NativeAgg::Min, NativeAggResult::Int(x)) => AggState::MinI(x),
11600            (NativeAgg::Max, NativeAggResult::Int(x)) => AggState::MaxI(x),
11601            (NativeAgg::Min, NativeAggResult::Float(x)) => AggState::MinF(x),
11602            (NativeAgg::Max, NativeAggResult::Float(x)) => AggState::MaxF(x),
11603            (NativeAgg::Count, _) => AggState::Empty,
11604            (_, NativeAggResult::Null) => AggState::Empty,
11605            _ => {
11606                let _ = is_float;
11607                AggState::Empty
11608            }
11609        }
11610    }
11611}
11612
11613/// A cached incremental aggregate (Phase 8.3): the mergeable state, the row-id
11614/// watermark it covers (rows `[0, watermark)`), and the snapshot epoch.
11615#[derive(Debug, Clone)]
11616pub struct CachedAgg {
11617    pub state: AggState,
11618    pub watermark: u64,
11619    pub epoch: u64,
11620}
11621
11622/// Outcome of [`Table::aggregate_incremental`].
11623#[derive(Debug, Clone)]
11624pub struct IncrementalAggResult {
11625    /// The aggregate state covering all rows at the current epoch.
11626    pub state: AggState,
11627    /// `true` when produced by merging only the delta (new rows); `false` when
11628    /// a full recompute was required (cold cache, deletes, or same epoch).
11629    pub incremental: bool,
11630    /// Rows processed in the delta pass (`0` for a full recompute).
11631    pub delta_rows: u64,
11632}
11633
11634/// Compute a mergeable [`AggState`] over `rows` that pass every per-row
11635/// `conditions` conjunct (and whose row id is in every pre-resolved
11636/// `index_sets`). Shared by the cold (full) and warm (delta) incremental paths.
11637fn agg_state_from_rows(
11638    rows: &[Row],
11639    conditions: &[crate::query::Condition],
11640    index_sets: &[RowIdSet],
11641    column: Option<u16>,
11642    agg: NativeAgg,
11643    schema: &Schema,
11644    control: Option<&crate::ExecutionControl>,
11645) -> Result<AggState> {
11646    let mut count: u64 = 0;
11647    let mut sum_i: i128 = 0;
11648    let mut sum_f: f64 = 0.0;
11649    let mut mn_i: i64 = i64::MAX;
11650    let mut mx_i: i64 = i64::MIN;
11651    let mut mn_f: f64 = f64::INFINITY;
11652    let mut mx_f: f64 = f64::NEG_INFINITY;
11653    let mut saw_int = false;
11654    let mut saw_float = false;
11655    for (index, r) in rows.iter().enumerate() {
11656        execution_checkpoint(control, index)?;
11657        if !conditions
11658            .iter()
11659            .all(|c| condition_matches_row(c, r, schema))
11660        {
11661            continue;
11662        }
11663        if !index_sets.iter().all(|s| s.contains(r.row_id.0)) {
11664            continue;
11665        }
11666        match agg {
11667            NativeAgg::Count => match column {
11668                // COUNT(*) counts every passing row.
11669                None => count += 1,
11670                // COUNT(col) excludes NULLs — explicit `Value::Null` and a column
11671                // absent from the row (schema evolution) are both NULL.
11672                Some(cid) => match r.columns.get(&cid) {
11673                    None | Some(Value::Null) => {}
11674                    Some(_) => count += 1,
11675                },
11676            },
11677            _ => match column.and_then(|cid| r.columns.get(&cid)) {
11678                Some(Value::Int64(n)) => {
11679                    count += 1;
11680                    sum_i += *n as i128;
11681                    mn_i = mn_i.min(*n);
11682                    mx_i = mx_i.max(*n);
11683                    saw_int = true;
11684                }
11685                Some(Value::Float64(f)) => {
11686                    count += 1;
11687                    sum_f += f;
11688                    mn_f = mn_f.min(*f);
11689                    mx_f = mx_f.max(*f);
11690                    saw_float = true;
11691                }
11692                _ => {}
11693            },
11694        }
11695    }
11696    Ok(match agg {
11697        NativeAgg::Count => {
11698            if count == 0 {
11699                AggState::Empty
11700            } else {
11701                AggState::Count(count)
11702            }
11703        }
11704        NativeAgg::Sum => {
11705            if count == 0 {
11706                AggState::Empty
11707            } else if saw_int {
11708                AggState::SumI { sum: sum_i, count }
11709            } else {
11710                AggState::SumF { sum: sum_f, count }
11711            }
11712        }
11713        NativeAgg::Avg => {
11714            if count == 0 {
11715                AggState::Empty
11716            } else if saw_int {
11717                AggState::AvgI { sum: sum_i, count }
11718            } else {
11719                AggState::AvgF { sum: sum_f, count }
11720            }
11721        }
11722        NativeAgg::Min => {
11723            if !saw_int && !saw_float {
11724                AggState::Empty
11725            } else if saw_int {
11726                AggState::MinI(mn_i)
11727            } else {
11728                AggState::MinF(mn_f)
11729            }
11730        }
11731        NativeAgg::Max => {
11732            if !saw_int && !saw_float {
11733                AggState::Empty
11734            } else if saw_int {
11735                AggState::MaxI(mx_i)
11736            } else {
11737                AggState::MaxF(mx_f)
11738            }
11739        }
11740    })
11741}
11742
11743/// Evaluate an index-served [`Condition`] exactly against a materialized row.
11744/// `Ann`/`SparseMatch` (index-defined) always pass here; callers test those via a
11745/// pre-resolved row-id set.
11746fn condition_matches_row(c: &crate::query::Condition, row: &Row, schema: &Schema) -> bool {
11747    use crate::query::Condition;
11748    match c {
11749        Condition::Pk(key) => match schema.primary_key() {
11750            Some(pk) => row
11751                .columns
11752                .get(&pk.id)
11753                .map(|v| v.encode_key() == *key)
11754                .unwrap_or(false),
11755            None => false,
11756        },
11757        Condition::BitmapEq { column_id, value } => row
11758            .columns
11759            .get(column_id)
11760            .map(|v| v.encode_key() == *value)
11761            .unwrap_or(false),
11762        Condition::BitmapIn { column_id, values } => {
11763            let key = row.columns.get(column_id).map(|v| v.encode_key());
11764            match key {
11765                Some(k) => values.contains(&k),
11766                None => false,
11767            }
11768        }
11769        Condition::BytesPrefix { column_id, prefix } => row
11770            .columns
11771            .get(column_id)
11772            .map(|v| v.encode_key().starts_with(prefix))
11773            .unwrap_or(false),
11774        Condition::Range { column_id, lo, hi } => match row.columns.get(column_id) {
11775            Some(Value::Int64(n)) => *n >= *lo && *n <= *hi,
11776            _ => false,
11777        },
11778        Condition::RangeF64 {
11779            column_id,
11780            lo,
11781            lo_inclusive,
11782            hi,
11783            hi_inclusive,
11784        } => match row.columns.get(column_id) {
11785            Some(Value::Float64(n)) => {
11786                let lo_ok = if *lo_inclusive { *n >= *lo } else { *n > *lo };
11787                let hi_ok = if *hi_inclusive { *n <= *hi } else { *n < *hi };
11788                lo_ok && hi_ok
11789            }
11790            _ => false,
11791        },
11792        Condition::FmContains { column_id, pattern } => match row.columns.get(column_id) {
11793            Some(Value::Bytes(b)) => {
11794                !pattern.is_empty() && b.windows(pattern.len()).any(|w| w == &pattern[..])
11795            }
11796            _ => false,
11797        },
11798        Condition::FmContainsAll {
11799            column_id,
11800            patterns,
11801        } => match row.columns.get(column_id) {
11802            Some(Value::Bytes(b)) => patterns
11803                .iter()
11804                .all(|pat| !pat.is_empty() && b.windows(pat.len()).any(|w| w == &pat[..])),
11805            _ => false,
11806        },
11807        Condition::Ann { .. }
11808        | Condition::SparseMatch { .. }
11809        | Condition::MinHashSimilar { .. } => true,
11810        Condition::IsNull { column_id } => {
11811            matches!(row.columns.get(column_id), Some(Value::Null) | None)
11812        }
11813        Condition::IsNotNull { column_id } => {
11814            !matches!(row.columns.get(column_id), Some(Value::Null) | None)
11815        }
11816    }
11817}
11818
11819/// Coerce a cell to `f64` for Sum/Avg (Int64/Float64 only).
11820fn as_f64(v: Option<&Value>) -> Option<f64> {
11821    match v {
11822        Some(Value::Int64(n)) => Some(*n as f64),
11823        Some(Value::Float64(f)) => Some(*f),
11824        _ => None,
11825    }
11826}
11827
11828/// One-pass vectorized accumulation of `(non-null count, sum, min, max)` over an
11829/// Int64 column streamed through `cursor`. The inner loop over a contiguous
11830/// `&[i64]` autovectorizes (SIMD) for the all-non-null prefix.
11831fn accumulate_int(
11832    cursor: &mut dyn crate::cursor::Cursor,
11833    control: Option<&crate::ExecutionControl>,
11834) -> Result<(u64, i128, i64, i64)> {
11835    let mut count: u64 = 0;
11836    let mut sum: i128 = 0;
11837    let mut mn: i64 = i64::MAX;
11838    let mut mx: i64 = i64::MIN;
11839    while let Some(cols) = cursor.next_batch()? {
11840        execution_checkpoint(control, 0)?;
11841        if let Some(crate::columnar::NativeColumn::Int64 { data, validity }) = cols.first() {
11842            if crate::columnar::all_non_null(validity, data.len()) {
11843                // All-non-null: vectorized sum/min/max with no per-element branch.
11844                count += data.len() as u64;
11845                for (chunk_index, chunk) in data.chunks(1024).enumerate() {
11846                    execution_checkpoint(control, chunk_index * 1024)?;
11847                    sum += chunk.iter().map(|&v| v as i128).sum::<i128>();
11848                    mn = mn.min(*chunk.iter().min().unwrap_or(&mn));
11849                    mx = mx.max(*chunk.iter().max().unwrap_or(&mx));
11850                }
11851            } else {
11852                for (i, &v) in data.iter().enumerate() {
11853                    execution_checkpoint(control, i)?;
11854                    if crate::columnar::validity_bit(validity, i) {
11855                        count += 1;
11856                        sum += v as i128;
11857                        mn = mn.min(v);
11858                        mx = mx.max(v);
11859                    }
11860                }
11861            }
11862        }
11863    }
11864    Ok((count, sum, mn, mx))
11865}
11866
11867/// f64 analogue of [`accumulate_int`].
11868fn accumulate_float(
11869    cursor: &mut dyn crate::cursor::Cursor,
11870    control: Option<&crate::ExecutionControl>,
11871) -> Result<(u64, f64, f64, f64)> {
11872    let mut count: u64 = 0;
11873    let mut sum: f64 = 0.0;
11874    let mut mn: f64 = f64::INFINITY;
11875    let mut mx: f64 = f64::NEG_INFINITY;
11876    while let Some(cols) = cursor.next_batch()? {
11877        execution_checkpoint(control, 0)?;
11878        if let Some(crate::columnar::NativeColumn::Float64 { data, validity }) = cols.first() {
11879            if crate::columnar::all_non_null(validity, data.len()) {
11880                count += data.len() as u64;
11881                for (chunk_index, chunk) in data.chunks(1024).enumerate() {
11882                    execution_checkpoint(control, chunk_index * 1024)?;
11883                    sum += chunk.iter().sum::<f64>();
11884                    mn = mn.min(chunk.iter().copied().fold(f64::INFINITY, f64::min));
11885                    mx = mx.max(chunk.iter().copied().fold(f64::NEG_INFINITY, f64::max));
11886                }
11887            } else {
11888                for (i, &v) in data.iter().enumerate() {
11889                    execution_checkpoint(control, i)?;
11890                    if crate::columnar::validity_bit(validity, i) {
11891                        count += 1;
11892                        sum += v;
11893                        mn = mn.min(v);
11894                        mx = mx.max(v);
11895                    }
11896                }
11897            }
11898        }
11899    }
11900    Ok((count, sum, mn, mx))
11901}
11902
11903#[inline]
11904fn execution_checkpoint(control: Option<&crate::ExecutionControl>, index: usize) -> Result<()> {
11905    if index.is_multiple_of(256) {
11906        control
11907            .map(crate::ExecutionControl::checkpoint)
11908            .transpose()?;
11909    }
11910    Ok(())
11911}
11912
11913fn pack_int(agg: NativeAgg, count: u64, sum: i128, mn: i64, mx: i64) -> NativeAggResult {
11914    if count == 0 && !matches!(agg, NativeAgg::Count) {
11915        return NativeAggResult::Null;
11916    }
11917    match agg {
11918        NativeAgg::Count => NativeAggResult::Count(count),
11919        // i64 overflow on Sum ⇒ SQL NULL (DataFusion errors on overflow; null is
11920        // a safe, non-misleading fallback rather than a saturated wrong value).
11921        NativeAgg::Sum => match sum.try_into() {
11922            Ok(v) => NativeAggResult::Int(v),
11923            Err(_) => NativeAggResult::Null,
11924        },
11925        NativeAgg::Min => NativeAggResult::Int(mn),
11926        NativeAgg::Max => NativeAggResult::Int(mx),
11927        NativeAgg::Avg => NativeAggResult::Float((sum as f64) / (count as f64)),
11928    }
11929}
11930
11931fn pack_float(agg: NativeAgg, count: u64, sum: f64, mn: f64, mx: f64) -> NativeAggResult {
11932    if count == 0 && !matches!(agg, NativeAgg::Count) {
11933        return NativeAggResult::Null;
11934    }
11935    match agg {
11936        NativeAgg::Count => NativeAggResult::Count(count),
11937        NativeAgg::Sum => NativeAggResult::Float(sum),
11938        NativeAgg::Min => NativeAggResult::Float(mn),
11939        NativeAgg::Max => NativeAggResult::Float(mx),
11940        NativeAgg::Avg => NativeAggResult::Float(sum / (count as f64)),
11941    }
11942}
11943
11944/// Aggregate per-page `min`/`max`/`null_count` into a column-wide i64 triple.
11945/// Returns `None` if no page contributes a non-null min/max (all-null column).
11946fn agg_int(
11947    stats: &[crate::page::PageStat],
11948    decode: fn(Option<&[u8]>) -> Option<i64>,
11949) -> Option<(Option<i64>, Option<i64>, u64)> {
11950    let (mut mn, mut mx, mut nulls) = (i64::MAX, i64::MIN, 0u64);
11951    let mut any = false;
11952    for s in stats {
11953        if let Some(v) = decode(s.min.as_deref()) {
11954            mn = mn.min(v);
11955            any = true;
11956        }
11957        if let Some(v) = decode(s.max.as_deref()) {
11958            mx = mx.max(v);
11959            any = true;
11960        }
11961        nulls += s.null_count;
11962    }
11963    any.then_some((Some(mn), Some(mx), nulls))
11964}
11965
11966/// f64 analogue of [`agg_int`] (compares as f64, not as bit patterns).
11967fn agg_float(
11968    stats: &[crate::page::PageStat],
11969    decode: fn(Option<&[u8]>) -> Option<f64>,
11970) -> Option<(Option<f64>, Option<f64>, u64)> {
11971    let (mut mn, mut mx, mut nulls) = (f64::INFINITY, f64::NEG_INFINITY, 0u64);
11972    let mut any = false;
11973    for s in stats {
11974        if let Some(v) = decode(s.min.as_deref()) {
11975            mn = mn.min(v);
11976            any = true;
11977        }
11978        if let Some(v) = decode(s.max.as_deref()) {
11979            mx = mx.max(v);
11980            any = true;
11981        }
11982        nulls += s.null_count;
11983    }
11984    any.then_some((Some(mn), Some(mx), nulls))
11985}
11986
11987/// The four maintained secondary-index maps, keyed by column id.
11988type SecondaryIndexes = (
11989    HashMap<u16, BitmapIndex>,
11990    HashMap<u16, AnnIndex>,
11991    HashMap<u16, FmIndex>,
11992    HashMap<u16, SparseIndex>,
11993    HashMap<u16, MinHashIndex>,
11994);
11995
11996fn empty_indexes(schema: &Schema) -> SecondaryIndexes {
11997    let mut bitmap = HashMap::new();
11998    let mut ann = HashMap::new();
11999    let mut fm = HashMap::new();
12000    let mut sparse = HashMap::new();
12001    let mut minhash = HashMap::new();
12002    for idef in &schema.indexes {
12003        match idef.kind {
12004            IndexKind::Bitmap => {
12005                bitmap.insert(idef.column_id, BitmapIndex::new());
12006            }
12007            IndexKind::Ann => {
12008                let dim = schema
12009                    .columns
12010                    .iter()
12011                    .find(|c| c.id == idef.column_id)
12012                    .and_then(|c| match c.ty {
12013                        TypeId::Embedding { dim } => Some(dim as usize),
12014                        _ => None,
12015                    })
12016                    .unwrap_or(0);
12017                let options = idef.options.ann.clone().unwrap_or_default();
12018                ann.insert(
12019                    idef.column_id,
12020                    AnnIndex::with_full_options(
12021                        dim,
12022                        options.m,
12023                        options.ef_construction,
12024                        options.ef_search,
12025                        &options,
12026                    ),
12027                );
12028            }
12029            IndexKind::FmIndex => {
12030                fm.insert(idef.column_id, FmIndex::new());
12031            }
12032            IndexKind::Sparse => {
12033                sparse.insert(idef.column_id, SparseIndex::new());
12034            }
12035            IndexKind::MinHash => {
12036                let options = idef.options.minhash.clone().unwrap_or_default();
12037                minhash.insert(
12038                    idef.column_id,
12039                    MinHashIndex::with_options(options.permutations, options.bands),
12040                );
12041            }
12042            _ => {}
12043        }
12044    }
12045    (bitmap, ann, fm, sparse, minhash)
12046}
12047
12048const ALTER_COLUMN_PROTECTED_FLAGS: u32 = ColumnFlags::PRIMARY_KEY
12049    | ColumnFlags::AUTO_INCREMENT
12050    | ColumnFlags::ENCRYPTED
12051    | ColumnFlags::ENCRYPTED_INDEXABLE
12052    | ColumnFlags::EMBEDDING_BINARY_QUANTIZED;
12053
12054fn validate_alter_column_flags(old: ColumnFlags, new: ColumnFlags) -> Result<()> {
12055    if (old.bits() ^ new.bits()) & ALTER_COLUMN_PROTECTED_FLAGS != 0 {
12056        return Err(MongrelError::Schema(
12057            "ALTER COLUMN may only change NULLABLE; primary key, auto-increment, encryption, and embedding flags are immutable".into(),
12058        ));
12059    }
12060    Ok(())
12061}
12062
12063fn validate_alter_column_type(
12064    schema: &Schema,
12065    old: &ColumnDef,
12066    next: &ColumnDef,
12067    has_stored_versions: bool,
12068) -> Result<()> {
12069    if old.ty == next.ty {
12070        return Ok(());
12071    }
12072    if schema.indexes.iter().any(|i| i.column_id == old.id) {
12073        return Err(MongrelError::Schema(format!(
12074            "ALTER COLUMN TYPE is not supported for indexed column '{}'",
12075            old.name
12076        )));
12077    }
12078    if !has_stored_versions || storage_compatible_type_change(old.ty.clone(), next.ty.clone()) {
12079        return Ok(());
12080    }
12081    Err(MongrelError::Schema(format!(
12082        "ALTER COLUMN TYPE from {:?} to {:?} requires an empty column or a representation-compatible type",
12083        old.ty, next.ty
12084    )))
12085}
12086
12087fn storage_compatible_type_change(old: TypeId, new: TypeId) -> bool {
12088    matches!(
12089        (old, new),
12090        (TypeId::Int64, TypeId::TimestampNanos) | (TypeId::TimestampNanos, TypeId::Int64)
12091    )
12092}
12093
12094/// True when every row carries an `Int64` PK value and the sequence is
12095/// strictly increasing — no intra-batch duplicate is possible. The row-major
12096/// mirror of `native_int64_strictly_increasing` (the `bulk_pk_winner_indices`
12097/// fast path), used by `apply_put_rows_inner` to skip upsert probing for
12098/// append-style batches.
12099fn rows_pk_strictly_increasing(rows: &[Row], pk_id: u16) -> bool {
12100    let mut prev: Option<i64> = None;
12101    for r in rows {
12102        match r.columns.get(&pk_id) {
12103            Some(Value::Int64(v)) => {
12104                if prev.is_some_and(|p| p >= *v) {
12105                    return false;
12106                }
12107                prev = Some(*v);
12108            }
12109            _ => return false,
12110        }
12111    }
12112    true
12113}
12114
12115#[allow(clippy::too_many_arguments)]
12116fn index_into(
12117    schema: &Schema,
12118    row: &Row,
12119    hot: &mut HotIndex,
12120    bitmap: &mut HashMap<u16, BitmapIndex>,
12121    ann: &mut HashMap<u16, AnnIndex>,
12122    fm: &mut HashMap<u16, FmIndex>,
12123    sparse: &mut HashMap<u16, SparseIndex>,
12124    minhash: &mut HashMap<u16, MinHashIndex>,
12125) {
12126    for idef in &schema.indexes {
12127        let Some(val) = row.columns.get(&idef.column_id) else {
12128            continue;
12129        };
12130        match idef.kind {
12131            IndexKind::Bitmap => {
12132                if let Some(b) = bitmap.get_mut(&idef.column_id) {
12133                    b.insert(val.encode_key(), row.row_id);
12134                }
12135            }
12136            IndexKind::Ann => {
12137                if let (Some(a), Some(v)) = (ann.get_mut(&idef.column_id), val.as_embedding()) {
12138                    if let Some(meta) = val.generated_embedding_metadata() {
12139                        // P1.5-T3: pending/failed generated vectors stay out of ANN.
12140                        if !crate::embedding_jobs::embedding_status_is_ann_eligible(meta.status) {
12141                            continue;
12142                        }
12143                        if a.bind_or_check_semantic_identity(&meta.semantic_identity)
12144                            .is_err()
12145                        {
12146                            continue;
12147                        }
12148                    }
12149                    a.insert_validated(v, row.row_id);
12150                }
12151            }
12152            IndexKind::FmIndex => {
12153                if let (Some(f), Value::Bytes(b)) = (fm.get_mut(&idef.column_id), val) {
12154                    f.insert(b.clone(), row.row_id);
12155                }
12156            }
12157            IndexKind::Sparse => {
12158                if let (Some(s), Value::Bytes(b)) = (sparse.get_mut(&idef.column_id), val) {
12159                    // A sparse vector is stored as a bincode'd `Vec<(u32, f32)>`
12160                    // in a Bytes column (SPLADE weights in, retrieval out).
12161                    if let Ok(terms) = bincode::deserialize::<Vec<(u32, f32)>>(b) {
12162                        s.insert(&terms, row.row_id);
12163                    }
12164                }
12165            }
12166            IndexKind::MinHash => {
12167                if let (Some(mh), Value::Bytes(b)) = (minhash.get_mut(&idef.column_id), val) {
12168                    // The set is a JSON array (the Kit's `set_similarity` shape);
12169                    // tokenize + hash its members into the MinHash signature.
12170                    let tokens = crate::index::token_hashes_from_bytes(b);
12171                    mh.insert(&tokens, row.row_id);
12172                }
12173            }
12174            _ => {}
12175        }
12176    }
12177    if let Some(pk_col) = schema.primary_key() {
12178        if let Some(pk_val) = row.columns.get(&pk_col.id) {
12179            hot.insert(pk_val.encode_key(), row.row_id);
12180        }
12181    }
12182}
12183
12184/// Index a row into a single specific index (used for partial indexes where
12185/// only matching indexes should receive the row).
12186#[allow(clippy::too_many_arguments)]
12187fn index_into_single(
12188    idef: &IndexDef,
12189    _schema: &Schema,
12190    row: &Row,
12191    _hot: &mut HotIndex,
12192    bitmap: &mut HashMap<u16, BitmapIndex>,
12193    ann: &mut HashMap<u16, AnnIndex>,
12194    fm: &mut HashMap<u16, FmIndex>,
12195    sparse: &mut HashMap<u16, SparseIndex>,
12196    minhash: &mut HashMap<u16, MinHashIndex>,
12197) {
12198    let Some(val) = row.columns.get(&idef.column_id) else {
12199        return;
12200    };
12201    match idef.kind {
12202        IndexKind::Bitmap => {
12203            if let Some(b) = bitmap.get_mut(&idef.column_id) {
12204                b.insert(val.encode_key(), row.row_id);
12205            }
12206        }
12207        IndexKind::Ann => {
12208            if let (Some(a), Some(v)) = (ann.get_mut(&idef.column_id), val.as_embedding()) {
12209                if let Some(meta) = val.generated_embedding_metadata() {
12210                    // P1.5-T3: pending/failed generated vectors stay out of ANN.
12211                    if !crate::embedding_jobs::embedding_status_is_ann_eligible(meta.status) {
12212                        return;
12213                    }
12214                    if a.bind_or_check_semantic_identity(&meta.semantic_identity)
12215                        .is_err()
12216                    {
12217                        return;
12218                    }
12219                }
12220                a.insert_validated(v, row.row_id);
12221            }
12222        }
12223        IndexKind::FmIndex => {
12224            if let (Some(f), Value::Bytes(b)) = (fm.get_mut(&idef.column_id), val) {
12225                f.insert(b.clone(), row.row_id);
12226            }
12227        }
12228        IndexKind::Sparse => {
12229            if let (Some(s), Value::Bytes(b)) = (sparse.get_mut(&idef.column_id), val) {
12230                if let Ok(terms) = bincode::deserialize::<Vec<(u32, f32)>>(b) {
12231                    s.insert(&terms, row.row_id);
12232                }
12233            }
12234        }
12235        IndexKind::MinHash => {
12236            if let (Some(mh), Value::Bytes(b)) = (minhash.get_mut(&idef.column_id), val) {
12237                let tokens = crate::index::token_hashes_from_bytes(b);
12238                mh.insert(&tokens, row.row_id);
12239            }
12240        }
12241        _ => {}
12242    }
12243}
12244
12245/// Evaluate a partial-index predicate against a row. Supports the most common
12246/// patterns: `"column IS NOT NULL"` and `"column IS NULL"`. More complex
12247/// expressions require a full SQL evaluator in core (future work); the
12248/// predicate string is stored verbatim and this function provides a pragmatic
12249/// subset. Returns `true` if the row should be indexed.
12250fn eval_partial_predicate(
12251    pred: &str,
12252    columns_map: &HashMap<u16, &Value>,
12253    name_to_id: &HashMap<&str, u16>,
12254) -> bool {
12255    let lower = pred.trim().to_ascii_lowercase();
12256    // Pattern: "column_name IS NOT NULL"
12257    if let Some(rest) = lower.strip_suffix(" is not null") {
12258        let col_name = rest.trim();
12259        if let Some(col_id) = name_to_id.get(col_name) {
12260            return columns_map
12261                .get(col_id)
12262                .is_some_and(|v| !matches!(v, Value::Null));
12263        }
12264    }
12265    // Pattern: "column_name IS NULL"
12266    if let Some(rest) = lower.strip_suffix(" is null") {
12267        let col_name = rest.trim();
12268        if let Some(col_id) = name_to_id.get(col_name) {
12269            return columns_map
12270                .get(col_id)
12271                .is_none_or(|v| matches!(v, Value::Null));
12272        }
12273    }
12274    // Unknown predicate syntax: index the row (conservative — better to
12275    // over-index than to miss rows).
12276    true
12277}
12278
12279/// Per-element index key for the typed bulk-index path (Phase 14.2): mirrors
12280/// `index_into` on a `tokenized_for_indexes(row)` — encodes the element the way
12281/// [`Value::encode_key`] would, then applies the column's
12282/// `ENCRYPTED_INDEXABLE` tokenization (HMAC-eq / OPE) so bitmap/HOT keys match
12283/// what the incremental path stores. Returns `None` for null slots.
12284#[allow(dead_code)]
12285fn bulk_index_key(
12286    column_keys: &HashMap<u16, ([u8; 32], u8)>,
12287    column_id: u16,
12288    ty: TypeId,
12289    col: &columnar::NativeColumn,
12290    i: usize,
12291) -> Option<Vec<u8>> {
12292    let encoded = columnar::encode_key_native(ty, col, i)?;
12293    {
12294        use crate::encryption::{hmac_token, ope_token_f64, ope_token_i64, SCHEME_HMAC_EQ};
12295        if let Some((key, scheme)) = column_keys.get(&column_id) {
12296            return Some(match (*scheme, col) {
12297                (SCHEME_HMAC_EQ, _) => hmac_token(key, &encoded).to_vec(),
12298                (_, columnar::NativeColumn::Int64 { data, .. }) => {
12299                    ope_token_i64(key, data[i]).to_vec()
12300                }
12301                (_, columnar::NativeColumn::Float64 { data, .. }) => {
12302                    ope_token_f64(key, data[i]).to_vec()
12303                }
12304                _ => hmac_token(key, &encoded).to_vec(),
12305            });
12306        }
12307    }
12308    Some(encoded)
12309}
12310
12311pub(crate) fn write_schema(dir: &Path, schema: &Schema) -> Result<()> {
12312    write_schema_with_after(dir, schema, || {})
12313}
12314
12315pub(crate) fn write_schema_durable(
12316    root: &crate::durable_file::DurableRoot,
12317    schema: &Schema,
12318) -> Result<()> {
12319    write_schema_durable_with_after(root, schema, || {})
12320}
12321
12322fn write_schema_with_after<F>(dir: &Path, schema: &Schema, after_publish: F) -> Result<()>
12323where
12324    F: FnOnce(),
12325{
12326    let json = serde_json::to_string_pretty(schema)
12327        .map_err(|e| MongrelError::Schema(format!("encode schema: {e}")))?;
12328    crate::durable_file::write_atomic_with_after(
12329        &dir.join(SCHEMA_FILENAME),
12330        json.as_bytes(),
12331        after_publish,
12332    )?;
12333    Ok(())
12334}
12335
12336fn write_schema_durable_with_after<F>(
12337    root: &crate::durable_file::DurableRoot,
12338    schema: &Schema,
12339    after_publish: F,
12340) -> Result<()>
12341where
12342    F: FnOnce(),
12343{
12344    let json = serde_json::to_string_pretty(schema)
12345        .map_err(|error| MongrelError::Schema(format!("encode schema: {error}")))?;
12346    root.write_atomic_with_after(SCHEMA_FILENAME, json.as_bytes(), after_publish)?;
12347    Ok(())
12348}
12349
12350fn checkpoint_current_schema(table: &mut Table) -> Result<()> {
12351    let mut schema_published = false;
12352    let schema_result = match table._root_guard.as_deref() {
12353        Some(root) => write_schema_durable_with_after(root, &table.schema, || {
12354            schema_published = true;
12355        }),
12356        None => write_schema_with_after(&table.dir, &table.schema, || {
12357            schema_published = true;
12358        }),
12359    };
12360    if schema_result.is_err() && !schema_published {
12361        return schema_result;
12362    }
12363    match table.persist_manifest(table.current_epoch()) {
12364        Ok(()) => Ok(()),
12365        Err(manifest_error) => Err(match schema_result {
12366            Ok(()) => manifest_error,
12367            Err(schema_error) => MongrelError::Other(format!(
12368                "schema publication sync failed ({schema_error}); matching manifest publication also failed ({manifest_error})"
12369            )),
12370        }),
12371    }
12372}
12373
12374fn read_schema(dir: &Path) -> Result<Schema> {
12375    let file = crate::durable_file::open_regular_nofollow(&dir.join(SCHEMA_FILENAME))?;
12376    read_schema_file(file)
12377}
12378
12379fn read_schema_file(file: std::fs::File) -> Result<Schema> {
12380    const MAX_SCHEMA_BYTES: u64 = 16 * 1024 * 1024;
12381    use std::io::Read;
12382
12383    let length = file.metadata()?.len();
12384    if length > MAX_SCHEMA_BYTES {
12385        return Err(MongrelError::ResourceLimitExceeded {
12386            resource: "schema bytes",
12387            requested: usize::try_from(length).unwrap_or(usize::MAX),
12388            limit: MAX_SCHEMA_BYTES as usize,
12389        });
12390    }
12391    let mut bytes = Vec::with_capacity(length as usize);
12392    file.take(MAX_SCHEMA_BYTES + 1).read_to_end(&mut bytes)?;
12393    if bytes.len() as u64 != length {
12394        return Err(MongrelError::Schema(
12395            "schema length changed while reading".into(),
12396        ));
12397    }
12398    serde_json::from_slice(&bytes).map_err(|e| MongrelError::Schema(format!("decode schema: {e}")))
12399}
12400
12401fn preflight_standalone_open(
12402    dir: &Path,
12403    runs_root: Option<&crate::durable_file::DurableRoot>,
12404    idx_root: Option<&crate::durable_file::DurableRoot>,
12405    manifest: &Manifest,
12406    schema: &Schema,
12407    records: &[crate::wal::Record],
12408    kek: Option<Arc<Kek>>,
12409) -> Result<()> {
12410    crate::wal::validate_shared_transaction_framing(records)?;
12411    if manifest.schema_id > schema.schema_id
12412        || manifest.flushed_epoch > manifest.current_epoch
12413        || manifest.global_idx_epoch > manifest.current_epoch
12414        || manifest.next_row_id == u64::MAX
12415        || manifest.auto_inc_next < 0
12416        || manifest.auto_inc_next == i64::MAX
12417        || (schema.auto_increment_column().is_none() && manifest.auto_inc_next != 0)
12418    {
12419        return Err(MongrelError::InvalidArgument(
12420            "manifest counters or schema identity are invalid".into(),
12421        ));
12422    }
12423    let mut run_ids = HashSet::new();
12424    let mut maximum_row_id = None::<u64>;
12425    for run in &manifest.runs {
12426        if run.run_id >= u64::MAX as u128
12427            || !run_ids.insert(run.run_id)
12428            || run.epoch_created > manifest.current_epoch
12429        {
12430            return Err(MongrelError::InvalidArgument(
12431                "manifest contains an invalid or duplicate active run".into(),
12432            ));
12433        }
12434        let mut reader = match runs_root {
12435            Some(root) => RunReader::open_file(
12436                root.open_regular(format!("r-{}.sr", run.run_id as u64))?,
12437                schema.clone(),
12438                kek.clone(),
12439            )?,
12440            None => RunReader::open(
12441                dir.join(RUNS_DIR)
12442                    .join(format!("r-{}.sr", run.run_id as u64)),
12443                schema.clone(),
12444                kek.clone(),
12445            )?,
12446        };
12447        let header = reader.header();
12448        if header.run_id != run.run_id
12449            || header.level != run.level
12450            || header.row_count != run.row_count
12451            || !header.is_uniform_epoch() && header.epoch_created != run.epoch_created
12452            || header.is_uniform_epoch() && header.epoch_created != 0
12453            || header.schema_id > schema.schema_id
12454        {
12455            return Err(MongrelError::InvalidArgument(format!(
12456                "run {} differs from its manifest",
12457                run.run_id
12458            )));
12459        }
12460        if header.row_count != 0 {
12461            maximum_row_id = Some(
12462                maximum_row_id.map_or(header.max_row_id, |value| value.max(header.max_row_id)),
12463            );
12464        }
12465        reader.validate_all_pages()?;
12466    }
12467    if maximum_row_id.is_some_and(|maximum| manifest.next_row_id <= maximum) {
12468        return Err(MongrelError::InvalidArgument(
12469            "manifest next_row_id does not advance beyond persisted rows".into(),
12470        ));
12471    }
12472    for run in &manifest.retiring {
12473        if run.run_id >= u64::MAX as u128
12474            || run.retire_epoch > manifest.current_epoch
12475            || !run_ids.insert(run.run_id)
12476        {
12477            return Err(MongrelError::InvalidArgument(
12478                "manifest contains an invalid or duplicate retired run".into(),
12479            ));
12480        }
12481    }
12482    let idx_dek = kek.as_ref().map(|key| key.derive_idx_key());
12483    match idx_root {
12484        Some(root) => {
12485            global_idx::read_root(root, manifest.table_id, schema, idx_dek.as_deref())?;
12486        }
12487        None => {
12488            global_idx::read(dir, manifest.table_id, schema, idx_dek.as_deref())?;
12489        }
12490    }
12491
12492    let committed = records
12493        .iter()
12494        .filter_map(|record| match record.op {
12495            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
12496            _ => None,
12497        })
12498        .collect::<HashMap<_, _>>();
12499    for record in records {
12500        let Some(&_commit_epoch) = committed.get(&record.txn_id) else {
12501            continue;
12502        };
12503        match &record.op {
12504            Op::Put { table_id, rows } => {
12505                if *table_id != manifest.table_id {
12506                    return Err(MongrelError::CorruptWal {
12507                        offset: record.seq.0,
12508                        reason: format!(
12509                            "private WAL record references table {table_id}, expected {}",
12510                            manifest.table_id
12511                        ),
12512                    });
12513                }
12514                let rows: Vec<Row> =
12515                    bincode::deserialize(rows).map_err(|error| MongrelError::CorruptWal {
12516                        offset: record.seq.0,
12517                        reason: format!("committed Put payload could not be decoded: {error}"),
12518                    })?;
12519                for row in rows {
12520                    if row.deleted || row.row_id.0 == u64::MAX {
12521                        return Err(MongrelError::CorruptWal {
12522                            offset: record.seq.0,
12523                            reason: "committed Put contains an invalid row identity".into(),
12524                        });
12525                    }
12526                    let cells = row.columns.into_iter().collect::<Vec<_>>();
12527                    schema
12528                        .validate_values(&cells)
12529                        .map_err(|error| MongrelError::CorruptWal {
12530                            offset: record.seq.0,
12531                            reason: format!("committed Put violates table schema: {error}"),
12532                        })?;
12533                    if schema.auto_increment_column().is_some_and(|column| {
12534                        matches!(
12535                            cells.iter().find(|(id, _)| *id == column.id),
12536                            Some((_, Value::Int64(value))) if *value == i64::MAX
12537                        )
12538                    }) {
12539                        return Err(MongrelError::CorruptWal {
12540                            offset: record.seq.0,
12541                            reason: "committed Put exhausts AUTO_INCREMENT".into(),
12542                        });
12543                    }
12544                }
12545            }
12546            Op::Delete { table_id, .. } | Op::TruncateTable { table_id }
12547                if *table_id != manifest.table_id =>
12548            {
12549                return Err(MongrelError::CorruptWal {
12550                    offset: record.seq.0,
12551                    reason: format!(
12552                        "private WAL record references table {table_id}, expected {}",
12553                        manifest.table_id
12554                    ),
12555                });
12556            }
12557            Op::TxnCommit { added_runs, .. } if !added_runs.is_empty() => {
12558                return Err(MongrelError::CorruptWal {
12559                    offset: record.seq.0,
12560                    reason: "private WAL contains shared spilled-run metadata".into(),
12561                });
12562            }
12563            _ => {}
12564        }
12565    }
12566    Ok(())
12567}
12568
12569fn next_wal_segment(wal_dir: &Path) -> Result<PathBuf> {
12570    Ok(wal_dir.join(format!("seg-{:06}.wal", next_wal_number(wal_dir)?)))
12571}
12572
12573fn wal_segment_number(path: &Path) -> Option<u64> {
12574    path.file_stem()
12575        .and_then(|stem| stem.to_str())
12576        .and_then(|stem| stem.strip_prefix("seg-"))
12577        .and_then(|number| number.parse().ok())
12578}
12579
12580fn latest_wal_segment(wal_dir: &Path) -> Result<Option<PathBuf>> {
12581    let n = list_wal_numbers(wal_dir)?;
12582    Ok(n.map(|max| wal_dir.join(format!("seg-{max:06}.wal"))))
12583}
12584
12585fn next_wal_number(wal_dir: &Path) -> Result<u32> {
12586    list_wal_numbers(wal_dir)?
12587        .map(|maximum| {
12588            maximum
12589                .checked_add(1)
12590                .ok_or_else(|| MongrelError::Full("WAL segment namespace exhausted".into()))
12591        })
12592        .unwrap_or(Ok(0))
12593}
12594
12595fn list_wal_numbers(wal_dir: &Path) -> Result<Option<u32>> {
12596    let mut max_n = None;
12597    let entries = match std::fs::read_dir(wal_dir) {
12598        Ok(entries) => entries,
12599        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
12600        Err(error) => return Err(error.into()),
12601    };
12602    for entry in entries {
12603        let entry = entry?;
12604        let fname = entry.file_name();
12605        let Some(s) = fname.to_str() else {
12606            continue;
12607        };
12608        let Some(stripped) = s.strip_prefix("seg-") else {
12609            continue;
12610        };
12611        let Some(number) = stripped.strip_suffix(".wal") else {
12612            return Err(MongrelError::CorruptWal {
12613                offset: 0,
12614                reason: format!("malformed WAL segment name {s:?}"),
12615            });
12616        };
12617        let n = number
12618            .parse::<u32>()
12619            .map_err(|_| MongrelError::CorruptWal {
12620                offset: 0,
12621                reason: format!("malformed WAL segment name {s:?}"),
12622            })?;
12623        if s != format!("seg-{n:06}.wal") || !entry.file_type()?.is_file() {
12624            return Err(MongrelError::CorruptWal {
12625                offset: n as u64,
12626                reason: format!("noncanonical or nonregular WAL segment {s:?}"),
12627            });
12628        }
12629        max_n = Some(max_n.map(|m: u32| m.max(n)).unwrap_or(n));
12630    }
12631    Ok(max_n)
12632}