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, Snapshot};
15use crate::global_idx;
16use crate::index::{
17    AnnIndex, BitmapIndex, ColumnLearnedRange, FmIndex, HotIndex, MinHashIndex, SparseIndex,
18};
19use crate::manifest::{self, Manifest, RunRef};
20use crate::memtable::{Memtable, Row, Value};
21use crate::mutable_run::MutableRun;
22use crate::row_id_set::RowIdSet;
23use crate::rowid::{RowId, RowIdAllocator};
24use crate::schema::{AlterColumn, ColumnDef, ColumnFlags, IndexDef, IndexKind, Schema, TypeId};
25use crate::sorted_run::{RunReader, RunWriter};
26use crate::txn::{GroupCommit, OwnedRow};
27use crate::wal::{Op, SharedWal, Wal};
28use crate::{MongrelError, Result};
29use std::collections::{BTreeMap, HashMap, HashSet};
30use std::path::{Path, PathBuf};
31use std::sync::atomic::AtomicBool;
32use std::sync::Arc;
33use zeroize::Zeroizing;
34
35pub const WAL_DIR: &str = "_wal";
36pub const RUNS_DIR: &str = "_runs";
37pub const CACHE_DIR: &str = "_cache";
38pub const META_DIR: &str = "_meta";
39pub const RCACHE_DIR: &str = "_rcache";
40pub const KEYS_FILENAME: &str = "keys";
41pub const SCHEMA_FILENAME: &str = "schema.json";
42const DEFAULT_SYNC_BYTE_THRESHOLD: u64 = 0; // manual commit only (pure group commit)
43pub(crate) const PAGE_CACHE_CAPACITY: u64 = 64 * 1024 * 1024; // 64 MiB shared page cache
44pub(crate) const DECODED_CACHE_CAPACITY: u64 = 64 * 1024 * 1024; // 64 MiB shared decoded-page cache (Phase 15.4)
45/// Default byte watermark at which the PMA mutable-run tier spills to an
46/// immutable `.sr` sorted run (Phase 11.1). Coalesces many small flushes into
47/// one larger run so the read path merges fewer readers.
48const DEFAULT_MUTABLE_RUN_SPILL_BYTES: u64 = 8 * 1024 * 1024;
49
50/// Engine-managed `AUTO_INCREMENT` counter state for a table (present iff the
51/// schema declares an `AUTO_INCREMENT` primary key).
52///
53/// `next` is the next value to hand out (1-based, monotonic, never reused). It
54/// is `0` while *unseeded* — the counter has never been advanced (fresh table or
55/// a legacy manifest predating `auto_inc_next`). When `seeded` is `false` the
56/// first allocation scans `max(PK)` over all visible rows so the counter never
57/// collides with pre-existing rows; a value of `0` after seeding never happens
58/// (ids are never 0). The manifest persists `next` only when `seeded`, so a
59/// reopen that reads `auto_inc_next > 0` is authoritative.
60///
61/// `seeded == false` but `next > 0` is a transient recovery-only state: WAL
62/// replay may bump `next` past replayed ids without marking it seeded, so the
63/// scan still runs to cover rows that were already flushed to sorted runs.
64#[derive(Clone, Copy, Debug)]
65struct AutoIncState {
66    column_id: u16,
67    next: i64,
68    seeded: bool,
69}
70
71type FilledAutoIncRow = (Vec<(u16, Value)>, Option<i64>);
72
73/// Resolve the auto-increment column (if any) from a schema into initial
74/// counter state. Always called after [`crate::schema::Schema::validate_auto_increment`].
75fn resolve_auto_inc(schema: &Schema) -> Option<AutoIncState> {
76    schema.auto_increment_column().map(|c| AutoIncState {
77        column_id: c.id,
78        next: 0,
79        seeded: false,
80    })
81}
82
83/// When a bulk load (`bulk_load` / `bulk_load_columns` / `bulk_load_fast`)
84/// builds the live in-memory indexes.
85///
86/// The engine is correct under either policy: with [`Self::Deferred`] the
87/// indexes are rebuilt lazily by the first `query`/`flush` (Phase 14.7,
88/// `ensure_indexes_complete`), with [`Self::Eager`] they are built — and
89/// checkpointed to `_idx/global.idx` — inside the bulk load itself. The trade
90/// is *where* the build cost lands: `Deferred` keeps the ingest critical path
91/// minimal (write the run, persist the manifest, return); `Eager` gives
92/// predictable first-query latency at the price of a slower load. Serving
93/// deployments that load then immediately serve point queries (e.g. a warm
94/// daemon) may prefer `Eager`; batch/ETL ingest wants `Deferred`.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
96pub enum IndexBuildPolicy {
97    /// Defer index building to the first query/flush — fastest ingest (default).
98    #[default]
99    Deferred,
100    /// Build and checkpoint indexes inside the bulk load — fastest first query.
101    Eager,
102}
103
104/// An open MongrelDB table.
105pub struct Table {
106    dir: PathBuf,
107    table_id: u64,
108    /// The table's catalog name, set at mount time. Used by the auth
109    /// enforcement layer to check `Select`/`Insert`/`Update`/`Delete`
110    /// permissions against this specific table.
111    name: String,
112    /// Optional auth checker for per-operation enforcement. `None` on
113    /// credentialless databases (the default); `Some` when the database has
114    /// `require_auth = true`. The checker is shared (via `Arc`) so it sees
115    /// live updates to the principal and the `require_auth` flag.
116    auth: Option<Arc<dyn crate::auth_state::TableAuthChecker>>,
117    wal: WalSink,
118    memtable: Memtable,
119    /// PMA-backed mutable-run LSM tier (Phase 11.1). A flush drains the
120    /// memtable into this in-memory sorted tier instead of immediately writing
121    /// a `.sr` run; once it crosses `mutable_run_spill_bytes` it spills to an
122    /// immutable run. Purely in-memory — rebuilt from WAL replay on reopen.
123    mutable_run: MutableRun,
124    /// Byte watermark controlling when `mutable_run` spills to a sorted run.
125    mutable_run_spill_bytes: u64,
126    /// Zstd compression level for compaction output (Phase 18.1: default 3;
127    /// higher = better ratio but slower compaction).
128    compaction_zstd_level: i32,
129    allocator: RowIdAllocator,
130    epoch: Arc<EpochAuthority>,
131    /// Manifest-endorsed epoch at open; used to seed the (shared) epoch
132    /// authority on a fresh open. Updated whenever the manifest is persisted.
133    persisted_epoch: u64,
134    schema: Schema,
135    hot: HotIndex,
136    /// Table Key-Encryption Key (Argon2id+HKDF from the passphrase). Each run
137    /// stores a fresh DEK wrapped by this KEK (see §7). `None` when plaintext.
138    kek: Option<Arc<Kek>>,
139    /// Per-column indexable-encryption keys + scheme (Phase 10.2) for every
140    /// ENCRYPTED_INDEXABLE column, derived deterministically from the KEK so
141    /// tokens are identical across runs. Empty when the table is plaintext.
142    column_keys: HashMap<u16, ([u8; 32], u8)>,
143    run_refs: Vec<RunRef>,
144    /// Runs superseded by compaction, kept on disk for snapshot retention until
145    /// `gc()` reaps them (spec §6.4). Persisted in the manifest (`retiring`).
146    retiring: Vec<crate::manifest::RetiredRun>,
147    next_run_id: u64,
148    sync_byte_threshold: u64,
149    /// Next transaction id to assign to a single-table auto-commit txn
150    /// (`put`/`delete` then `commit`). 0 is reserved for [`wal::SYSTEM_TXN_ID`].
151    /// The Database transaction layer (P2.5) assigns these globally; the
152    /// single-table path uses this local counter.
153    current_txn_id: u64,
154    bitmap: HashMap<u16, BitmapIndex>,
155    ann: HashMap<u16, AnnIndex>,
156    fm: HashMap<u16, FmIndex>,
157    sparse: HashMap<u16, SparseIndex>,
158    minhash: HashMap<u16, MinHashIndex>,
159    /// Per-column learned (PGM) range indexes for `IndexKind::LearnedRange`
160    /// columns, built from the single sorted run.
161    learned_range: HashMap<u16, ColumnLearnedRange>,
162    /// Reverse primary-key map for HOT cleanup on row-id deletes.
163    pk_by_row: HashMap<RowId, Vec<u8>>,
164    /// Refcounted pinned read snapshots (epoch → count); compaction must not GC
165    /// versions an active snapshot still needs.
166    pinned: BTreeMap<Epoch, usize>,
167    /// Live (non-deleted) row count — maintained incrementally for O(1)
168    /// `Table::count()` without a scan.
169    pub(crate) live_count: u64,
170    /// Uniform reservoir sample of row ids for approximate analytics
171    /// (Phase 8.2). Maintained incrementally on insert; repopulated on open.
172    reservoir: crate::reservoir::Reservoir,
173    /// False when `reservoir` needs a full rebuild from `visible_rows` before
174    /// [`Table::approx_aggregate`] can trust it (same lazy pattern as
175    /// [`Table::ensure_indexes_complete`]). Open and WAL-replay leave this
176    /// false instead of eagerly materializing every row — a full-table scan
177    /// no plain insert/update/delete needs — and the first approximate-
178    /// aggregate call pays the rebuild, after which `.offer()` calls maintain
179    /// it incrementally.
180    reservoir_complete: bool,
181    /// True once any row has been deleted. The incremental aggregate cache
182    /// (Phase 8.3) is only valid for append-only tables, so a single delete
183    /// permanently disables incremental maintenance for this table.
184    had_deletes: bool,
185    /// Incremental aggregate cache (Phase 8.3): caller-supplied key → the
186    /// mergeable aggregate state, the row-id watermark it covers, and the
187    /// epoch. A re-query after more inserts processes only the delta and merges.
188    agg_cache: HashMap<u64, CachedAgg>,
189    /// The manifest epoch the on-disk `_idx/global.idx` checkpoint covers (0 if
190    /// there is no checkpoint). Updated by [`Table::checkpoint_indexes`]; persisted
191    /// in the manifest so reopen loads the checkpoint instead of rebuilding.
192    global_idx_epoch: u64,
193    /// False when the live in-memory indexes are known to be incomplete (e.g.
194    /// after [`Table::bulk_load_columns`], which bypasses per-row indexing). A
195    /// flush in that state must NOT checkpoint; reopen rebuilds complete indexes
196    /// from the runs and resets this to true.
197    indexes_complete: bool,
198    /// Where bulk loads put the index-build cost (see [`IndexBuildPolicy`]).
199    index_build_policy: IndexBuildPolicy,
200    /// False when `pk_by_row` may be missing entries for rows present in
201    /// `hot`. Fresh tables start false and puts skip the reverse map — pure
202    /// ingest never pays for it. The first delete that needs it rebuilds it
203    /// from `hot` (the same lazy pattern as `ensure_indexes_complete`), after
204    /// which puts maintain it incrementally so a delete-active workload pays
205    /// the build exactly once.
206    pk_by_row_complete: bool,
207    /// Highest epoch whose data is durable in a sorted run (spec §7.1). Recovery
208    /// skips replaying WAL records whose commit epoch is `<= flushed_epoch`.
209    flushed_epoch: u64,
210    /// Shared, MVCC content-addressed page cache (Phase 9.2). Fed by every
211    /// `RunReader::read_page` so all readers share raw (decrypted) page bytes.
212    page_cache: Arc<crate::cache::Sharded<crate::cache::PageCache>>,
213    /// Global snapshot-retention registry shared across all tables in a
214    /// `Database`. Single-table direct opens get a private one.
215    snapshots: Arc<crate::retention::SnapshotRegistry>,
216    /// Cross-table commit serializer (see [`SharedCtx::commit_lock`]).
217    commit_lock: Arc<parking_lot::Mutex<()>>,
218    /// Shared decoded-page cache (Phase 15.4): the post-decompress/decrypt typed
219    /// page, so repeat scans skip decode. Keyed by `(run_id, column_id, page)`.
220    decoded_cache: Arc<crate::cache::Sharded<crate::cache::DecodedPageCache>>,
221    /// `run_id`s whose on-disk footer checksum has already been verified by a
222    /// `RunReader` construction in this process. `.sr` runs are immutable once
223    /// written, so re-hashing an already-verified run's full body on every
224    /// repeat `open_reader` call (every query, every `remove_hot_for_row`) is
225    /// pure waste for a warm/long-lived handle — this cache lets
226    /// `read_header_cached` skip straight to the cheap header+footer-magic
227    /// check after the first open. Scoped per-`Table` (not shared via
228    /// `SharedCtx`) since `run_id` is only unique within one table's own
229    /// manifest.
230    verified_runs: Arc<parking_lot::Mutex<std::collections::HashSet<u128>>>,
231    /// Table-level result cache (Phase 19.1): `canonical_query_key(conditions,
232    /// projection, epoch)` → the survivor columns as typed `NativeColumn`s. Shared
233    /// by the native `Condition` API and (via `query_cached`) the tool-call path,
234    /// which previously had no caching (only the SQL `MongrelSession` cache did).
235    /// Hardening (c): epoch is no longer in the key; instead, a `commit()`
236    /// invalidates only entries whose footprint or condition-columns intersect
237    /// the committed mutations, tracked in `pending_delete_rids` and
238    /// `pending_put_cols`.
239    result_cache: Arc<parking_lot::Mutex<ResultCache>>,
240    /// WAL DEK (for frame-level encryption). None for plaintext tables.
241    wal_dek: Option<Zeroizing<[u8; DEK_LEN]>>,
242    /// RowIds deleted since the last `commit()` — used by fine-grained cache
243    /// invalidation to check footprint intersection.
244    pending_delete_rids: roaring::RoaringBitmap,
245    /// Column IDs touched by `put`/`put_batch` since the last `commit()` — used
246    /// by conservative insert-newly-matches invalidation.
247    pending_put_cols: std::collections::HashSet<u16>,
248    /// B1/B2: rows staged by `put`/`put_batch` on a mounted (shared-WAL) table
249    /// but not yet applied to the memtable. They are re-stamped to the real
250    /// assigned epoch in `commit` (never a speculative `visible+1`), so a
251    /// concurrent reader can never observe them before their commit epoch.
252    /// Always empty on a standalone (private-WAL) table, which applies inline.
253    pending_rows: Vec<Row>,
254    pending_rows_auto_inc: Vec<bool>,
255    /// B1/B2: tombstones staged on a mounted table, applied at the assigned
256    /// epoch in `commit` (mirror of `pending_rows`).
257    pending_dels: Vec<RowId>,
258    /// B1/B2: truncate staged on a mounted table, applied at the assigned epoch
259    /// in `commit`; standalone tables also defer the physical clear until after
260    /// the private WAL is fsynced.
261    pending_truncate: Option<Epoch>,
262    /// Engine-managed `AUTO_INCREMENT` counter (`None` for tables without an
263    /// auto-increment primary key). See [`AutoIncState`].
264    auto_inc: Option<AutoIncState>,
265}
266
267// `Table` is `Sync`: every field is either plain data, an `Arc`, a `Vec`/`HashMap`
268// of `Sync` data, or a thread-safe interior-mutability cell (`parking_lot::Mutex`,
269// `crossbeam`/`epoch` Arc-shared caches). The only `RefCell`-based type was
270// `FmIndex` (lazy rebuild of the BWT), which now uses a `Mutex`, so a `&Table`
271// can be safely shared across read threads (concurrent mutation still requires
272// the caller's `Mutex<Table>`).
273const _: () = {
274    const fn assert_sync<T: ?Sized + Sync>() {}
275    assert_sync::<Table>();
276};
277
278/// A cached query result — either survivor `Row`s (the tool-call/`query` path)
279/// or typed survivor columns (the pushdown/`query_columns_native` path). One
280/// canonical key maps to exactly one variant (a `query` with no projection vs a
281/// `query_columns_native` with a specific projection produce different keys), so
282/// there is no representation collision.
283enum CachedData {
284    Rows(Arc<Vec<Row>>),
285    Columns(Arc<Vec<(u16, columnar::NativeColumn)>>),
286}
287
288impl CachedData {
289    fn approx_bytes(&self) -> u64 {
290        match self {
291            CachedData::Rows(r) => r.iter().map(|r| r.estimated_bytes()).sum::<u64>(),
292            CachedData::Columns(c) => c
293                .iter()
294                .map(|(_, c)| c.approx_bytes())
295                .sum::<u64>()
296                .saturating_add(c.len() as u64 * 16),
297        }
298    }
299}
300
301/// A cached entry carrying the survivor `RowId` **footprint** (for precise
302/// delete-based invalidation) and the condition column IDs (for conservative
303/// insert-based invalidation). Hardening (c).
304struct CachedEntry {
305    data: CachedData,
306    footprint: roaring::RoaringBitmap,
307    condition_cols: Vec<u16>,
308}
309
310/// Size-bounded **access-order LRU** result cache (Phase 19.1 + hardening (a)).
311/// Every `get_*` promotes the key to the back (most-recently-used); eviction
312/// pops from the front (least-recently-used) — a true LRU, not FIFO.
313///
314/// Hardening (b): an optional on-disk persistent tier (`dir = Some(_)`). On a
315/// memory miss, the cache tries disk before falling through to re-resolution.
316/// On `insert`, the entry is also written to disk atomically (write + fsync +
317/// rename). On `invalidate`/`clear`, the matching disk files are deleted. On
318/// `Table::open`, existing disk entries are pre-loaded so fine-grained invalidation
319/// resumes across restart.
320struct ResultCache {
321    entries: std::collections::HashMap<u64, CachedEntry>,
322    order: std::collections::VecDeque<u64>,
323    bytes: u64,
324    max_bytes: u64,
325    dir: Option<std::path::PathBuf>,
326    #[allow(dead_code)]
327    cache_dek: Option<Zeroizing<[u8; DEK_LEN]>>,
328}
329
330/// Serialised form of a [`CachedEntry`] for the persistent on-disk tier (b).
331#[derive(serde::Serialize, serde::Deserialize)]
332struct SerializedEntry {
333    condition_cols: Vec<u16>,
334    footprint_bits: Vec<u32>,
335    data: SerializedData,
336}
337
338#[derive(serde::Serialize, serde::Deserialize)]
339enum SerializedData {
340    Rows(Vec<Row>),
341    Columns(Vec<(u16, columnar::NativeColumn)>),
342}
343
344impl SerializedEntry {
345    fn from_entry(entry: &CachedEntry) -> Self {
346        let footprint_bits: Vec<u32> = entry.footprint.iter().collect();
347        let data = match &entry.data {
348            CachedData::Rows(r) => SerializedData::Rows((**r).clone()),
349            CachedData::Columns(c) => SerializedData::Columns((**c).clone()),
350        };
351        Self {
352            condition_cols: entry.condition_cols.clone(),
353            footprint_bits,
354            data,
355        }
356    }
357
358    fn into_entry(self) -> Option<CachedEntry> {
359        let footprint: roaring::RoaringBitmap = self.footprint_bits.into_iter().collect();
360        let data = match self.data {
361            SerializedData::Rows(r) => CachedData::Rows(Arc::new(r)),
362            SerializedData::Columns(c) => {
363                // Validate deserialized columns (hardening (b)): reject corrupt
364                // data instead of panicking on access.
365                if !c.iter().all(|(_, col)| col.validate()) {
366                    return None;
367                }
368                CachedData::Columns(Arc::new(c))
369            }
370        };
371        Some(CachedEntry {
372            data,
373            footprint,
374            condition_cols: self.condition_cols,
375        })
376    }
377}
378
379impl ResultCache {
380    const DEFAULT_MAX_BYTES: u64 = 256 * 1024 * 1024;
381
382    fn new() -> Self {
383        Self::with_max_bytes(Self::DEFAULT_MAX_BYTES)
384    }
385
386    fn with_max_bytes(max_bytes: u64) -> Self {
387        Self {
388            entries: std::collections::HashMap::new(),
389            order: std::collections::VecDeque::new(),
390            bytes: 0,
391            max_bytes,
392            dir: None,
393            cache_dek: None,
394        }
395    }
396
397    fn with_dir(mut self, dir: std::path::PathBuf) -> Self {
398        let _ = std::fs::create_dir_all(&dir);
399        self.dir = Some(dir);
400        self
401    }
402
403    fn with_cache_dek(mut self, dek: Option<Zeroizing<[u8; DEK_LEN]>>) -> Self {
404        self.cache_dek = dek;
405        self
406    }
407
408    fn disk_path(&self, key: u64) -> Option<std::path::PathBuf> {
409        self.dir.as_ref().map(|d| d.join(format!("{key:016x}.bin")))
410    }
411
412    /// Atomically write `entry` to disk (write + rename). Best-effort: silently
413    /// ignores I/O errors (the in-memory cache is authoritative; the cache is
414    /// disposable — missing/stale files fall through to re-resolution).
415    fn store_to_disk(&self, key: u64, entry: &CachedEntry) {
416        let Some(path) = self.disk_path(key) else {
417            return;
418        };
419        let serialized = match bincode::serialize(&SerializedEntry::from_entry(entry)) {
420            Ok(s) => s,
421            Err(_) => return,
422        };
423        // Encrypt if a cache DEK is present.
424        let on_disk = if let Some(dek) = &self.cache_dek {
425            match self.encrypt_cache(&serialized, dek) {
426                Some(b) => b,
427                None => return,
428            }
429        } else {
430            serialized
431        };
432        let tmp = path.with_extension("tmp");
433        use std::io::Write;
434        let write = || -> std::io::Result<()> {
435            let mut f = std::fs::File::create(&tmp)?;
436            f.write_all(&on_disk)?;
437            f.flush()?;
438            Ok(())
439        };
440        if write().is_err() {
441            let _ = std::fs::remove_file(&tmp);
442            return;
443        }
444        let _ = std::fs::rename(&tmp, &path);
445    }
446
447    /// Try loading `key` from disk. Returns `None` on miss or error.
448    fn load_from_disk(&self, key: u64) -> Option<CachedEntry> {
449        let path = self.disk_path(key)?;
450        let bytes = std::fs::read(&path).ok()?;
451        let plaintext = if let Some(dek) = &self.cache_dek {
452            self.decrypt_cache(&bytes, dek)?
453        } else {
454            bytes
455        };
456        let serialized: SerializedEntry = bincode::deserialize(&plaintext).ok()?;
457        serialized.into_entry()
458    }
459
460    /// Delete the on-disk file for `key` if it exists. Best-effort.
461    fn remove_from_disk(&self, key: u64) {
462        if let Some(path) = self.disk_path(key) {
463            let _ = std::fs::remove_file(&path);
464        }
465    }
466
467    /// Encrypt cache data: `[nonce: 12B][ciphertext + GCM tag]`.
468    #[cfg(feature = "encryption")]
469    fn encrypt_cache(&self, plaintext: &[u8], dek: &Zeroizing<[u8; DEK_LEN]>) -> Option<Vec<u8>> {
470        use crate::encryption::Cipher;
471        let cipher = crate::encryption::AesCipher::new(&dek[..]).ok()?;
472        let mut nonce = [0u8; 12];
473        crate::encryption::fill_random(&mut nonce);
474        let ct = cipher.encrypt_page(&nonce, plaintext).ok()?;
475        let mut out = Vec::with_capacity(12 + ct.len());
476        out.extend_from_slice(&nonce);
477        out.extend_from_slice(&ct);
478        Some(out)
479    }
480
481    #[cfg(not(feature = "encryption"))]
482    fn encrypt_cache(&self, _plaintext: &[u8], _dek: &Zeroizing<[u8; DEK_LEN]>) -> Option<Vec<u8>> {
483        None
484    }
485
486    /// Decrypt cache data: reads nonce from first 12 bytes.
487    #[cfg(feature = "encryption")]
488    fn decrypt_cache(&self, bytes: &[u8], dek: &Zeroizing<[u8; DEK_LEN]>) -> Option<Vec<u8>> {
489        use crate::encryption::Cipher;
490        if bytes.len() < 28 {
491            return None;
492        }
493        let cipher = crate::encryption::AesCipher::new(&dek[..]).ok()?;
494        let nonce: [u8; 12] = bytes[..12].try_into().ok()?;
495        let ct = &bytes[12..];
496        cipher.decrypt_page(&nonce, ct).ok()
497    }
498
499    #[cfg(not(feature = "encryption"))]
500    fn decrypt_cache(&self, _bytes: &[u8], _dek: &Zeroizing<[u8; DEK_LEN]>) -> Option<Vec<u8>> {
501        None
502    }
503
504    /// Scan the cache directory and pre-load all entries into memory. Called
505    /// once on `Table::open`. Best-effort: corrupt/unreadable files are deleted.
506    fn load_persistent(&mut self) {
507        let Some(dir) = self.dir.as_ref().cloned() else {
508            return;
509        };
510        let entries = match std::fs::read_dir(&dir) {
511            Ok(e) => e,
512            Err(_) => return,
513        };
514        for entry in entries.flatten() {
515            let path = entry.path();
516            // Clean up orphan .tmp files from crashed store_to_disk calls.
517            if path.extension().and_then(|e| e.to_str()) == Some("tmp") {
518                let _ = std::fs::remove_file(&path);
519                continue;
520            }
521            if path.extension().and_then(|e| e.to_str()) != Some("bin") {
522                continue;
523            }
524            let stem = match path.file_stem().and_then(|s| s.to_str()) {
525                Some(s) => s,
526                None => continue,
527            };
528            let key = match u64::from_str_radix(stem, 16) {
529                Ok(k) => k,
530                Err(_) => continue,
531            };
532            let bytes = match std::fs::read(&path) {
533                Ok(b) => b,
534                Err(_) => continue,
535            };
536            // Decrypt if cache DEK is present.
537            let plaintext = if let Some(dek) = &self.cache_dek {
538                match self.decrypt_cache(&bytes, dek) {
539                    Some(p) => p,
540                    None => {
541                        let _ = std::fs::remove_file(&path);
542                        continue;
543                    }
544                }
545            } else {
546                bytes
547            };
548            match bincode::deserialize::<SerializedEntry>(&plaintext) {
549                Ok(serialized) => {
550                    if let Some(entry) = serialized.into_entry() {
551                        self.bytes = self.bytes.saturating_add(entry.data.approx_bytes());
552                        self.entries.insert(key, entry);
553                        self.order.push_back(key);
554                    } else {
555                        let _ = std::fs::remove_file(&path);
556                    }
557                }
558                Err(_) => {
559                    let _ = std::fs::remove_file(&path);
560                }
561            }
562        }
563        self.evict();
564    }
565
566    fn set_max_bytes(&mut self, max_bytes: u64) {
567        self.max_bytes = max_bytes;
568        self.evict();
569    }
570
571    /// Promote `key` to most-recently-used position (back of the deque).
572    fn touch(&mut self, key: u64) {
573        self.order.retain(|k| *k != key);
574        self.order.push_back(key);
575    }
576
577    fn get_rows(&mut self, key: u64) -> Option<Arc<Vec<Row>>> {
578        let res = self.entries.get(&key).and_then(|e| match &e.data {
579            CachedData::Rows(r) => Some(r.clone()),
580            CachedData::Columns(_) => None,
581        });
582        if res.is_some() {
583            self.touch(key);
584            return res;
585        }
586        // Memory miss → try the persistent tier (b).
587        if let Some(entry) = self.load_from_disk(key) {
588            let res = match &entry.data {
589                CachedData::Rows(r) => Some(r.clone()),
590                CachedData::Columns(_) => None,
591            };
592            if res.is_some() {
593                let approx = entry.data.approx_bytes();
594                self.bytes = self.bytes.saturating_add(approx);
595                self.entries.insert(key, entry);
596                self.order.push_back(key);
597                self.evict();
598                return res;
599            }
600        }
601        None
602    }
603
604    fn get_columns(&mut self, key: u64) -> Option<Arc<Vec<(u16, columnar::NativeColumn)>>> {
605        let res = self.entries.get(&key).and_then(|e| match &e.data {
606            CachedData::Columns(c) => Some(c.clone()),
607            CachedData::Rows(_) => None,
608        });
609        if res.is_some() {
610            self.touch(key);
611            return res;
612        }
613        // Memory miss → try the persistent tier (b).
614        if let Some(entry) = self.load_from_disk(key) {
615            let res = match &entry.data {
616                CachedData::Columns(c) => Some(c.clone()),
617                CachedData::Rows(_) => None,
618            };
619            if res.is_some() {
620                let approx = entry.data.approx_bytes();
621                self.bytes = self.bytes.saturating_add(approx);
622                self.entries.insert(key, entry);
623                self.order.push_back(key);
624                self.evict();
625                return res;
626            }
627        }
628        None
629    }
630
631    fn insert(&mut self, key: u64, entry: CachedEntry) {
632        let approx = entry.data.approx_bytes();
633        if self.entries.remove(&key).is_some() {
634            self.order.retain(|k| *k != key);
635            self.bytes = self.entries.values().map(|e| e.data.approx_bytes()).sum();
636        }
637        // Write to the persistent tier (b) before memory insert.
638        self.store_to_disk(key, &entry);
639        self.bytes = self.bytes.saturating_add(approx);
640        self.entries.insert(key, entry);
641        self.order.push_back(key);
642        self.evict();
643    }
644
645    /// Fine-grained invalidation (hardening (c)). Drop only entries that are
646    /// actually affected by the committed mutations:
647    /// - **Delete path**: if `delete_rids` intersects an entry's footprint, a
648    ///   survivor was deleted → stale. If the footprint is empty (multi-run or
649    ///   non-empty memtable — we couldn't resolve it), **any** delete
650    ///   conservatively invalidates the entry (correctness over precision).
651    /// - **Insert path**: if `put_cols` intersects an entry's `condition_cols`,
652    ///   a newly-inserted row might match the query → conservatively stale.
653    fn invalidate(
654        &mut self,
655        delete_rids: &roaring::RoaringBitmap,
656        put_cols: &std::collections::HashSet<u16>,
657    ) {
658        if self.entries.is_empty() {
659            return;
660        }
661        let has_deletes = !delete_rids.is_empty();
662        let to_remove: std::collections::HashSet<u64> = self
663            .entries
664            .iter()
665            .filter(|(_, e)| {
666                let delete_hit = if e.footprint.is_empty() {
667                    has_deletes
668                } else {
669                    e.footprint.intersection_len(delete_rids) > 0
670                };
671                let col_hit = e.condition_cols.iter().any(|c| put_cols.contains(c));
672                delete_hit || col_hit
673            })
674            .map(|(&k, _)| k)
675            .collect();
676        for key in &to_remove {
677            if let Some(e) = self.entries.remove(key) {
678                self.bytes = self.bytes.saturating_sub(e.data.approx_bytes());
679            }
680            self.remove_from_disk(*key);
681        }
682        if !to_remove.is_empty() {
683            self.order.retain(|k| !to_remove.contains(k));
684        }
685    }
686
687    fn clear(&mut self) {
688        // Delete all persistent files (b).
689        if let Some(dir) = &self.dir {
690            if let Ok(entries) = std::fs::read_dir(dir) {
691                for entry in entries.flatten() {
692                    let path = entry.path();
693                    if path.extension().and_then(|e| e.to_str()) == Some("bin") {
694                        let _ = std::fs::remove_file(&path);
695                    }
696                }
697            }
698        }
699        self.entries.clear();
700        self.order.clear();
701        self.bytes = 0;
702    }
703
704    fn evict(&mut self) {
705        while self.bytes > self.max_bytes {
706            let Some(k) = self.order.pop_front() else {
707                break;
708            };
709            if let Some(e) = self.entries.remove(&k) {
710                self.bytes = self.bytes.saturating_sub(e.data.approx_bytes());
711                // Also delete the disk file (hardening (b)): an evicted entry's
712                // disk file must not survive, or invalidate() — which only scans
713                // in-memory entries — would miss it and allow a stale disk hit.
714                self.remove_from_disk(k);
715            }
716        }
717    }
718}
719
720/// Derive per-column indexable-encryption keys (Phase 10.2) for every
721/// ENCRYPTED_INDEXABLE column from the KEK. Scheme is `OPE_RANGE` if the column
722/// has a `LearnedRange` index, else `HMAC_EQ` (equality). Keys are derived
723/// deterministically from the KEK so tokens are stable across runs. Empty when
724/// the table is plaintext (no KEK).
725/// Derive WAL and cache DEKs from the KEK (None when no encryption).
726type DekaOpt = Option<Zeroizing<[u8; DEK_LEN]>>;
727
728fn derive_subkeys(kek: Option<&Kek>, _table_id: u64) -> (DekaOpt, DekaOpt) {
729    let _ = kek;
730    #[cfg(feature = "encryption")]
731    {
732        if let Some(k) = kek {
733            return (
734                Some(k.derive_table_wal_key(_table_id)),
735                Some(k.derive_cache_key()),
736            );
737        }
738    }
739    (None, None)
740}
741
742/// Create a boxed cipher from a DEK (encryption feature only).
743#[cfg(feature = "encryption")]
744fn make_cipher(dek: &Zeroizing<[u8; DEK_LEN]>) -> Box<dyn crate::encryption::Cipher> {
745    Box::new(crate::encryption::AesCipher::new(&dek[..]).expect("DEK is 32 bytes"))
746}
747
748#[cfg(not(feature = "encryption"))]
749fn make_cipher(_dek: &Zeroizing<[u8; DEK_LEN]>) -> Box<dyn crate::encryption::Cipher> {
750    Box::new(crate::encryption::PlaintextCipher)
751}
752
753fn build_column_keys(kek: Option<&Kek>, schema: &Schema) -> HashMap<u16, ([u8; 32], u8)> {
754    let Some(kek) = kek else {
755        return HashMap::new();
756    };
757    #[cfg(feature = "encryption")]
758    {
759        use crate::encryption::{SCHEME_HMAC_EQ, SCHEME_OPE_RANGE};
760        schema
761            .columns
762            .iter()
763            .filter(|c| c.flags.contains(ColumnFlags::ENCRYPTED_INDEXABLE))
764            .map(|c| {
765                let scheme = if schema
766                    .indexes
767                    .iter()
768                    .any(|i| i.column_id == c.id && i.kind == IndexKind::LearnedRange)
769                {
770                    SCHEME_OPE_RANGE
771                } else {
772                    SCHEME_HMAC_EQ
773                };
774                let key: [u8; 32] = *kek.derive_column_key(c.id);
775                (c.id, (key, scheme))
776            })
777            .collect()
778    }
779    #[cfg(not(feature = "encryption"))]
780    {
781        let _ = (kek, schema);
782        HashMap::new()
783    }
784}
785
786/// Shared services injected into every `Table` owned by a `Database`: one epoch
787/// authority (single commit clock), one raw-page cache, one decoded-page cache,
788/// one snapshot-retention registry, and the DB-wide KEK. A directly-opened
789/// single table builds a private `SharedCtx` of its own.
790pub(crate) struct SharedCtx {
791    pub epoch: Arc<EpochAuthority>,
792    pub page_cache: Arc<crate::cache::Sharded<crate::cache::PageCache>>,
793    pub decoded_cache: Arc<crate::cache::Sharded<crate::cache::DecodedPageCache>>,
794    pub snapshots: Arc<crate::retention::SnapshotRegistry>,
795    pub kek: Option<Arc<Kek>>,
796    /// Serializes the commit critical section across all tables sharing this
797    /// context so the dual-counter's in-order-publish invariant holds: the
798    /// assigned ticket is reserved, the WAL fsynced, the manifest persisted,
799    /// and `visible` published as one atomic unit. P3 replaces this with the
800    /// bounded validate-first sequencer + group commit (overlapping fsync).
801    pub commit_lock: Arc<parking_lot::Mutex<()>>,
802    /// B1: when `Some`, the table is mounted in a `Database` and routes every
803    /// write through the one shared WAL (no private `_wal/` dir is created).
804    /// `None` for a directly-opened standalone table, which keeps a private WAL.
805    pub shared: Option<SharedWalCtx>,
806    /// The table's catalog name (for auth enforcement). `None` on standalone
807    /// direct-open tables that have no catalog entry.
808    pub table_name: Option<String>,
809    /// Auth checker for per-operation enforcement. `None` on credentialless
810    /// databases; cloned from the `Database`'s `auth_state` wrapper.
811    pub auth: Option<Arc<dyn crate::auth_state::TableAuthChecker>>,
812}
813
814/// Handles a mounted table needs to write to the database's single shared WAL
815/// (B1): the WAL itself, the group-commit coordinator + poison flag (so a
816/// single-table commit honors the same durability/§9.3e semantics as a cross-
817/// table txn), and the shared txn-id allocator (so auto-commit ids never alias
818/// cross-table ones in the merged log).
819#[derive(Clone)]
820pub(crate) struct SharedWalCtx {
821    pub wal: Arc<parking_lot::Mutex<SharedWal>>,
822    pub group: Arc<GroupCommit>,
823    pub poisoned: Arc<AtomicBool>,
824    pub txn_ids: Arc<parking_lot::Mutex<u64>>,
825}
826
827/// Where a table's WAL records go. A standalone table owns a `Private` WAL; a
828/// `Database`-mounted table writes to the one `Shared` WAL (B1).
829enum WalSink {
830    Private(Wal),
831    Shared(SharedWalCtx),
832}
833
834impl SharedCtx {
835    /// Build a fresh private (standalone) context. `cache_dir = Some(_)` enables
836    /// on-disk page cache persistence (single-table direct open); `None` keeps
837    /// it in-memory (shared across tables in a `Database`).
838    pub(crate) fn new(kek: Option<Arc<Kek>>, cache_dir: Option<PathBuf>) -> Self {
839        // §5.8: shard the caches to reduce lock contention under parallel
840        // rayon scans. The persistent (single-table) path uses 1 shard (no
841        // contention) so its on-disk load/spill stays simple.
842        let n_shards = if cache_dir.is_some() {
843            1
844        } else {
845            crate::cache::CACHE_SHARDS
846        };
847        let per_shard = PAGE_CACHE_CAPACITY / n_shards as u64;
848        let page_cache = if let Some(d) = cache_dir {
849            Arc::new(crate::cache::Sharded::new(1, || {
850                crate::cache::PageCache::new(PAGE_CACHE_CAPACITY).with_persistence(d.clone())
851            }))
852        } else {
853            Arc::new(crate::cache::Sharded::new(n_shards, || {
854                crate::cache::PageCache::new(per_shard)
855            }))
856        };
857        let decoded_per_shard = DECODED_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64;
858        let decoded_cache = Arc::new(crate::cache::Sharded::new(
859            crate::cache::CACHE_SHARDS,
860            || crate::cache::DecodedPageCache::new(decoded_per_shard),
861        ));
862        Self {
863            epoch: Arc::new(EpochAuthority::new(0)),
864            page_cache,
865            decoded_cache,
866            snapshots: Arc::new(crate::retention::SnapshotRegistry::new()),
867            kek,
868            commit_lock: Arc::new(parking_lot::Mutex::new(())),
869            shared: None,
870            table_name: None,
871            auth: None,
872        }
873    }
874}
875
876/// §5.5: estimated per-condition resolution cost for cheap-first conjunction
877/// ordering. Lower is resolved first so a selective O(1) index lookup can
878/// short-circuit an expensive range/FM/vector scan.
879fn condition_cost_rank(c: &crate::query::Condition) -> u8 {
880    use crate::query::Condition;
881    match c {
882        // O(1) index lookups — resolve first.
883        Condition::Pk(_)
884        | Condition::BitmapEq { .. }
885        | Condition::BitmapIn { .. }
886        | Condition::BytesPrefix { .. }
887        | Condition::IsNull { .. }
888        | Condition::IsNotNull { .. } => 0,
889        // Page-pruned scan or LSH candidate lookup.
890        Condition::Range { .. } | Condition::RangeF64 { .. } | Condition::MinHashSimilar { .. } => {
891            1
892        }
893        // FM locate / vector scans — most expensive, resolve last.
894        Condition::FmContains { .. }
895        | Condition::FmContainsAll { .. }
896        | Condition::Ann { .. }
897        | Condition::SparseMatch { .. } => 2,
898    }
899}
900
901impl Table {
902    pub fn create(dir: impl AsRef<Path>, schema: Schema, table_id: u64) -> Result<Self> {
903        let dir = dir.as_ref().to_path_buf();
904        let ctx = SharedCtx::new(None, Some(dir.join(CACHE_DIR)));
905        Self::create_in(&dir, schema, table_id, ctx)
906    }
907
908    /// Create a new encrypted table, deriving the table Key-Encryption Key
909    /// (KEK) from `passphrase` via Argon2id + HKDF (§7). A fresh random salt is
910    /// generated and persisted under `_meta/keys` so the same passphrase
911    /// recreates the KEK on reopen. Each run gets its own wrapped DEK.
912    ///
913    /// **Scope (§7):** encryption is *page-granular* — only sorted-run page
914    /// payloads are encrypted. The live WAL (`_wal/`) holds rows as plaintext
915    /// between `put` and `flush`; call `flush()` (which rotates the WAL) before
916    /// treating sensitive data as fully at-rest-protected. Full WAL encryption
917    /// is deferred.
918    #[cfg(feature = "encryption")]
919    pub fn create_encrypted(
920        dir: impl AsRef<Path>,
921        schema: Schema,
922        table_id: u64,
923        passphrase: &str,
924    ) -> Result<Self> {
925        let dir = dir.as_ref();
926        std::fs::create_dir_all(dir.join(META_DIR))?;
927        let salt = crate::encryption::random_salt();
928        std::fs::write(dir.join(META_DIR).join(KEYS_FILENAME), salt)?;
929        let kek: Arc<Kek> = Arc::new(Kek::derive(passphrase, &salt)?);
930        let ctx = SharedCtx::new(Some(kek), Some(dir.to_path_buf().join(CACHE_DIR)));
931        Self::create_in(dir, schema, table_id, ctx)
932    }
933
934    /// Create a new encrypted table using a raw key (e.g. from a key file)
935    /// instead of a passphrase. Skips Argon2id — the key must already be
936    /// high-entropy (>= 32 bytes of random data). ~0.1ms vs ~50ms for the
937    /// passphrase path.
938    #[cfg(feature = "encryption")]
939    pub fn create_with_key(
940        dir: impl AsRef<Path>,
941        schema: Schema,
942        table_id: u64,
943        key: &[u8],
944    ) -> Result<Self> {
945        let dir = dir.as_ref();
946        std::fs::create_dir_all(dir.join(META_DIR))?;
947        let salt = crate::encryption::random_salt();
948        std::fs::write(dir.join(META_DIR).join(KEYS_FILENAME), salt)?;
949        let kek: Arc<Kek> = Arc::new(Kek::from_raw_key(key, &salt)?);
950        let ctx = SharedCtx::new(Some(kek), Some(dir.to_path_buf().join(CACHE_DIR)));
951        Self::create_in(dir, schema, table_id, ctx)
952    }
953
954    /// Open an existing encrypted table using a raw key.
955    #[cfg(feature = "encryption")]
956    pub fn open_with_key(dir: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
957        let dir = dir.as_ref();
958        let salt_path = dir.join(META_DIR).join(KEYS_FILENAME);
959        let salt_bytes = std::fs::read(&salt_path).map_err(|e| {
960            MongrelError::NotFound(format!(
961                "encryption salt file {:?}: {e} (table not encrypted, or corrupted)",
962                salt_path
963            ))
964        })?;
965        if salt_bytes.len() != crate::encryption::SALT_LEN {
966            return Err(MongrelError::InvalidArgument(format!(
967                "salt file is {} bytes, expected {}",
968                salt_bytes.len(),
969                crate::encryption::SALT_LEN
970            )));
971        }
972        let mut salt = [0u8; crate::encryption::SALT_LEN];
973        salt.copy_from_slice(&salt_bytes);
974        let kek = Arc::new(Kek::from_raw_key(key, &salt)?);
975        let ctx = SharedCtx::new(Some(kek), Some(dir.to_path_buf().join(CACHE_DIR)));
976        Self::open_in(dir, ctx)
977    }
978
979    pub(crate) fn create_in(
980        dir: impl AsRef<Path>,
981        schema: Schema,
982        table_id: u64,
983        ctx: SharedCtx,
984    ) -> Result<Self> {
985        schema.validate_auto_increment()?;
986        let dir = dir.as_ref().to_path_buf();
987        std::fs::create_dir_all(dir.join(RUNS_DIR))?;
988        write_schema(&dir, &schema)?;
989        let (wal_dek, cache_dek) = derive_subkeys(ctx.kek.as_deref(), table_id);
990        // B1: a mounted table routes writes through the shared WAL and never
991        // creates its own `_wal/` dir. A standalone table owns a private WAL.
992        let (wal, current_txn_id) = match ctx.shared.clone() {
993            Some(s) => (WalSink::Shared(s), 0),
994            None => {
995                std::fs::create_dir_all(dir.join(WAL_DIR))?;
996                let mut w = if let Some(ref dk) = wal_dek {
997                    Wal::create_with_cipher(
998                        dir.join(WAL_DIR).join("seg-000000.wal"),
999                        Epoch(0),
1000                        Some(make_cipher(dk)),
1001                        0,
1002                    )?
1003                } else {
1004                    Wal::create(dir.join(WAL_DIR).join("seg-000000.wal"), Epoch(0))?
1005                };
1006                w.set_sync_byte_threshold(DEFAULT_SYNC_BYTE_THRESHOLD);
1007                (WalSink::Private(w), 1)
1008            }
1009        };
1010        let mut manifest = Manifest::new(table_id, schema.schema_id);
1011        // Seal the create-time manifest with the meta DEK so an encrypted table
1012        // reopens even if no write/flush ever re-persists it (otherwise the
1013        // reopen's encrypted manifest read fails to authenticate a plaintext
1014        // blob — see `manifest_meta_dek`).
1015        let manifest_meta_dek = crate::encryption::meta_dek_for(ctx.kek.as_deref());
1016        manifest::write_atomic(&dir, &mut manifest, manifest_meta_dek.as_ref())?;
1017        let (bitmap, ann, fm, sparse, minhash) = empty_indexes(&schema);
1018        let column_keys = build_column_keys(ctx.kek.as_deref(), &schema);
1019        let auto_inc = resolve_auto_inc(&schema);
1020        let rcache_dir = dir.join(RCACHE_DIR);
1021        Ok(Self {
1022            dir,
1023            table_id,
1024            name: ctx.table_name.unwrap_or_default(),
1025            auth: ctx.auth,
1026            wal,
1027            memtable: Memtable::new(),
1028            mutable_run: MutableRun::new(),
1029            mutable_run_spill_bytes: DEFAULT_MUTABLE_RUN_SPILL_BYTES,
1030            compaction_zstd_level: 3,
1031            allocator: RowIdAllocator::new(0),
1032            epoch: ctx.epoch,
1033            persisted_epoch: 0,
1034            schema,
1035            hot: HotIndex::new(),
1036            kek: ctx.kek,
1037            column_keys,
1038            run_refs: Vec::new(),
1039            retiring: Vec::new(),
1040            next_run_id: 1,
1041            sync_byte_threshold: DEFAULT_SYNC_BYTE_THRESHOLD,
1042            current_txn_id,
1043            bitmap,
1044            ann,
1045            fm,
1046            sparse,
1047            minhash,
1048            learned_range: HashMap::new(),
1049            pk_by_row: HashMap::new(),
1050            pinned: BTreeMap::new(),
1051            live_count: 0,
1052            reservoir: crate::reservoir::Reservoir::default(),
1053            reservoir_complete: true,
1054            had_deletes: false,
1055            agg_cache: HashMap::new(),
1056            global_idx_epoch: 0,
1057            indexes_complete: true,
1058            index_build_policy: IndexBuildPolicy::default(),
1059            pk_by_row_complete: false,
1060            flushed_epoch: 0,
1061            page_cache: ctx.page_cache,
1062            decoded_cache: ctx.decoded_cache,
1063            verified_runs: Arc::new(parking_lot::Mutex::new(std::collections::HashSet::new())),
1064            snapshots: ctx.snapshots,
1065            commit_lock: ctx.commit_lock,
1066            result_cache: Arc::new(parking_lot::Mutex::new(
1067                ResultCache::new()
1068                    .with_dir(rcache_dir)
1069                    .with_cache_dek(cache_dek.clone()),
1070            )),
1071            pending_delete_rids: roaring::RoaringBitmap::new(),
1072            pending_put_cols: std::collections::HashSet::new(),
1073            pending_rows: Vec::new(),
1074            pending_rows_auto_inc: Vec::new(),
1075            pending_dels: Vec::new(),
1076            pending_truncate: None,
1077            wal_dek,
1078            auto_inc,
1079        })
1080    }
1081
1082    /// Open an existing table: load the manifest, replay the active WAL segment
1083    /// into the memtable, and rebuild the HOT + secondary indexes from the runs
1084    /// and replayed rows.
1085    pub fn open(dir: impl AsRef<Path>) -> Result<Self> {
1086        let dir = dir.as_ref();
1087        let ctx = SharedCtx::new(None, Some(dir.to_path_buf().join(CACHE_DIR)));
1088        Self::open_in(dir, ctx)
1089    }
1090
1091    /// Open an existing encrypted table. `passphrase` must match the one used at
1092    /// create time (combined with the persisted salt to re-derive the KEK).
1093    #[cfg(feature = "encryption")]
1094    pub fn open_encrypted(dir: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
1095        let dir = dir.as_ref();
1096        let salt_path = dir.join(META_DIR).join(KEYS_FILENAME);
1097        let salt_bytes = std::fs::read(&salt_path).map_err(|e| {
1098            MongrelError::NotFound(format!(
1099                "encryption salt file {:?}: {e} (table not encrypted, or corrupted)",
1100                salt_path
1101            ))
1102        })?;
1103        let salt_len = crate::encryption::SALT_LEN;
1104        if salt_bytes.len() != salt_len {
1105            return Err(MongrelError::InvalidArgument(format!(
1106                "encryption salt is {} bytes, expected {salt_len}",
1107                salt_bytes.len()
1108            )));
1109        }
1110        let mut salt = [0u8; 16];
1111        salt.copy_from_slice(&salt_bytes);
1112        let kek: Arc<Kek> = Arc::new(Kek::derive(passphrase, &salt)?);
1113        let ctx = SharedCtx::new(Some(kek), Some(dir.to_path_buf().join(CACHE_DIR)));
1114        let t = Self::open_in(dir, ctx)?;
1115        Ok(t)
1116    }
1117
1118    pub(crate) fn open_in(dir: impl AsRef<Path>, ctx: SharedCtx) -> Result<Self> {
1119        let dir = dir.as_ref().to_path_buf();
1120        let manifest_meta_dek = crate::encryption::meta_dek_for(ctx.kek.as_deref());
1121        let manifest = manifest::read(&dir, manifest_meta_dek.as_ref())?;
1122        let schema: Schema = read_schema(&dir)?;
1123        let replay_epoch = Epoch(manifest.current_epoch);
1124        let (wal_dek, cache_dek) = derive_subkeys(ctx.kek.as_deref(), manifest.table_id);
1125        // B1: a mounted table has no private WAL — its committed records live in
1126        // the shared WAL and are replayed by `Database::recover_shared_wal`. A
1127        // standalone table replays + reopens its own `_wal/` segment here.
1128        let (wal, replayed, current_txn_id) = match ctx.shared.clone() {
1129            Some(s) => (WalSink::Shared(s), Vec::new(), 0),
1130            None => {
1131                let active = latest_wal_segment(&dir.join(WAL_DIR))?;
1132                // Replay BEFORE truncating: `Wal::create` would erase the segment.
1133                let replayed = match &active {
1134                    Some(path) => {
1135                        let cipher = wal_dek.as_ref().map(|dk| make_cipher(dk));
1136                        crate::wal::replay_with_cipher(path, cipher)?
1137                    }
1138                    None => Vec::new(),
1139                };
1140                let mut w = match &active {
1141                    Some(path) => Wal::create_with_cipher(
1142                        path,
1143                        replay_epoch,
1144                        wal_dek.as_ref().map(|dk| make_cipher(dk)),
1145                        0,
1146                    )?,
1147                    None => Wal::create_with_cipher(
1148                        dir.join(WAL_DIR).join("seg-000000.wal"),
1149                        replay_epoch,
1150                        wal_dek.as_ref().map(|dk| make_cipher(dk)),
1151                        0,
1152                    )?,
1153                };
1154                w.set_sync_byte_threshold(DEFAULT_SYNC_BYTE_THRESHOLD);
1155                (WalSink::Private(w), replayed, 1)
1156            }
1157        };
1158
1159        let mut memtable = Memtable::new();
1160        let mut allocator = RowIdAllocator::new(manifest.next_row_id);
1161        let persisted_epoch = manifest.current_epoch;
1162        // Seed the auto-increment counter from the manifest. `auto_inc_next == 0`
1163        // means unseeded (fresh table, or a legacy manifest migrated forward) —
1164        // the first allocation scans `max(PK)` to avoid colliding with existing
1165        // rows. WAL replay (below) and `recover_apply` additionally bump `next`
1166        // past replayed ids without marking it seeded, so the scan still covers
1167        // any rows that were already flushed to sorted runs.
1168        let mut auto_inc = resolve_auto_inc(&schema).map(|mut s| {
1169            s.next = manifest.auto_inc_next;
1170            s.seeded = manifest.auto_inc_next > 0;
1171            s
1172        });
1173
1174        // 1. Replay is two-phase and TxnCommit-gated: data records (Put/Delete)
1175        //    are staged per `txn_id` and only applied when a durable
1176        //    `TxnCommit{epoch}` for that txn is seen. Uncommitted / aborted /
1177        //    torn-tail txns are discarded. Indexing happens AFTER loading any
1178        //    checkpoint / run data (below) so the newer replayed versions
1179        //    overwrite the older run versions in the HOT index.
1180        let mut staged_puts: HashMap<u64, Vec<Row>> = HashMap::new();
1181        let mut staged_deletes: HashMap<u64, Vec<RowId>> = HashMap::new();
1182        let mut replayed_puts: std::collections::BTreeMap<Epoch, Vec<Row>> =
1183            std::collections::BTreeMap::new();
1184        let mut replayed_deletes: Vec<(RowId, Epoch)> = Vec::new();
1185        let mut saw_delete = false;
1186        for record in replayed {
1187            let txn_id = record.txn_id;
1188            match record.op {
1189                Op::Put { rows, .. } => {
1190                    let rows: Vec<Row> = bincode::deserialize(&rows)?;
1191                    for row in &rows {
1192                        allocator.advance_to(row.row_id);
1193                        if let Some(ai) = auto_inc.as_mut() {
1194                            if let Some(Value::Int64(n)) = row.columns.get(&ai.column_id) {
1195                                if *n + 1 > ai.next {
1196                                    ai.next = *n + 1;
1197                                }
1198                            }
1199                        }
1200                    }
1201                    staged_puts.entry(txn_id).or_default().extend(rows);
1202                }
1203                Op::Delete { row_ids, .. } => {
1204                    staged_deletes.entry(txn_id).or_default().extend(row_ids);
1205                }
1206                Op::TxnCommit { epoch, .. } => {
1207                    let commit_epoch = Epoch(epoch);
1208                    if let Some(puts) = staged_puts.remove(&txn_id) {
1209                        for row in &puts {
1210                            memtable.upsert(row.clone());
1211                        }
1212                        replayed_puts.entry(commit_epoch).or_default().extend(puts);
1213                    }
1214                    if let Some(dels) = staged_deletes.remove(&txn_id) {
1215                        saw_delete = true;
1216                        for rid in dels {
1217                            memtable.tombstone(rid, commit_epoch);
1218                            replayed_deletes.push((rid, commit_epoch));
1219                        }
1220                    }
1221                }
1222                Op::TxnAbort => {
1223                    staged_puts.remove(&txn_id);
1224                    staged_deletes.remove(&txn_id);
1225                }
1226                Op::TruncateTable { .. }
1227                | Op::ExternalTableState { .. }
1228                | Op::Flush { .. }
1229                | Op::Ddl(_) => {}
1230            }
1231        }
1232
1233        let rcache_dir = dir.join(RCACHE_DIR);
1234        let column_keys = build_column_keys(ctx.kek.as_deref(), &schema);
1235        let mut db = Self {
1236            dir,
1237            table_id: manifest.table_id,
1238            name: ctx.table_name.unwrap_or_default(),
1239            auth: ctx.auth,
1240            wal,
1241            memtable,
1242            mutable_run: MutableRun::new(),
1243            mutable_run_spill_bytes: DEFAULT_MUTABLE_RUN_SPILL_BYTES,
1244            compaction_zstd_level: 3,
1245            allocator,
1246            epoch: ctx.epoch,
1247            persisted_epoch,
1248            schema,
1249            hot: HotIndex::new(),
1250            kek: ctx.kek,
1251            column_keys,
1252            run_refs: manifest.runs.clone(),
1253            retiring: manifest.retiring.clone(),
1254            next_run_id: manifest
1255                .runs
1256                .iter()
1257                .map(|r| r.run_id as u64 + 1)
1258                .max()
1259                .unwrap_or(1),
1260            sync_byte_threshold: DEFAULT_SYNC_BYTE_THRESHOLD,
1261            current_txn_id,
1262            bitmap: HashMap::new(),
1263            ann: HashMap::new(),
1264            fm: HashMap::new(),
1265            sparse: HashMap::new(),
1266            minhash: HashMap::new(),
1267            learned_range: HashMap::new(),
1268            pk_by_row: HashMap::new(),
1269            pinned: BTreeMap::new(),
1270            live_count: manifest.live_count,
1271            reservoir: crate::reservoir::Reservoir::default(),
1272            reservoir_complete: false,
1273            had_deletes: saw_delete,
1274            agg_cache: HashMap::new(),
1275            global_idx_epoch: manifest.global_idx_epoch,
1276            indexes_complete: true,
1277            index_build_policy: IndexBuildPolicy::default(),
1278            pk_by_row_complete: false,
1279            flushed_epoch: manifest.flushed_epoch,
1280            page_cache: ctx.page_cache,
1281            decoded_cache: ctx.decoded_cache,
1282            verified_runs: Arc::new(parking_lot::Mutex::new(std::collections::HashSet::new())),
1283            snapshots: ctx.snapshots,
1284            commit_lock: ctx.commit_lock,
1285            result_cache: Arc::new(parking_lot::Mutex::new(
1286                ResultCache::new()
1287                    .with_dir(rcache_dir)
1288                    .with_cache_dek(cache_dek.clone()),
1289            )),
1290            pending_delete_rids: roaring::RoaringBitmap::new(),
1291            pending_put_cols: std::collections::HashSet::new(),
1292            pending_rows: Vec::new(),
1293            pending_rows_auto_inc: Vec::new(),
1294            pending_dels: Vec::new(),
1295            pending_truncate: None,
1296            wal_dek,
1297            auto_inc,
1298        };
1299
1300        // Advance the (possibly shared) epoch authority to this table's manifest
1301        // epoch so rebuild/index reads below observe the recovered watermark.
1302        db.epoch.advance_recovered(Epoch(db.persisted_epoch));
1303
1304        // 2. Fast path: load the persisted global-index checkpoint (Phase 9.1).
1305        //    Valid only when its embedded epoch matches the manifest-endorsed
1306        //    `global_idx_epoch` and every run was created at or before it, so the
1307        //    checkpoint covers all run data. Otherwise rebuild from the runs.
1308        let checkpoint = global_idx::read(&db.dir, db.idx_dek().as_deref())?;
1309        let checkpoint_valid = checkpoint.as_ref().is_some_and(|c| {
1310            c.epoch_built == manifest.global_idx_epoch
1311                && manifest.global_idx_epoch > 0
1312                && manifest
1313                    .runs
1314                    .iter()
1315                    .all(|r| r.epoch_created <= manifest.global_idx_epoch)
1316        });
1317        if let Some(loaded) = checkpoint {
1318            if checkpoint_valid {
1319                db.hot = loaded.hot;
1320                db.bitmap = loaded.bitmap;
1321                db.ann = loaded.ann;
1322                db.fm = loaded.fm;
1323                db.sparse = loaded.sparse;
1324                db.minhash = loaded.minhash;
1325                db.learned_range = loaded.learned_range;
1326                // `pk_by_row` stays lazy (`pk_by_row_complete == false`): the
1327                // first delete rebuilds it from the loaded HOT.
1328            }
1329        }
1330        if !checkpoint_valid {
1331            let (bitmap, ann, fm, sparse, minhash) = empty_indexes(&db.schema);
1332            db.bitmap = bitmap;
1333            db.ann = ann;
1334            db.fm = fm;
1335            db.sparse = sparse;
1336            db.minhash = minhash;
1337            db.rebuild_indexes_from_runs()?;
1338            db.build_learned_ranges()?;
1339        }
1340
1341        // 3. Index the replayed WAL rows on top so updates overwrite. Within a
1342        //    single transaction epoch duplicate PKs are upserted: only the last
1343        //    winner is indexed, losers are tombstoned in the already-replayed
1344        //    memtable.
1345        for (epoch, group) in replayed_puts {
1346            let (losers, winner_pks) = db.partition_pk_winners(&group);
1347            for (key, &row_id) in &winner_pks {
1348                if let Some(old_rid) = db.hot.get(key) {
1349                    if old_rid != row_id {
1350                        db.tombstone_row(old_rid, epoch, false);
1351                    }
1352                }
1353            }
1354            for &loser_rid in &losers {
1355                db.tombstone_row(loser_rid, epoch, false);
1356            }
1357            for (key, row_id) in winner_pks {
1358                db.insert_hot_pk(key, row_id);
1359            }
1360            if db.schema.primary_key().is_none() {
1361                for r in &group {
1362                    db.hot.insert(r.row_id.0.to_be_bytes().to_vec(), r.row_id);
1363                }
1364            }
1365            for r in &group {
1366                if !losers.contains(&r.row_id) {
1367                    db.index_row(r);
1368                }
1369            }
1370        }
1371        // Apply replayed deletes after the puts: a delete targets a specific row
1372        // id and only removes the HOT entry if it still points to that id, so a
1373        // newer upsert for the same PK is not accidentally erased.
1374        for (rid, epoch) in &replayed_deletes {
1375            db.remove_hot_for_row(*rid, *epoch);
1376        }
1377
1378        // The reservoir stays lazy (`reservoir_complete == false`, set above):
1379        // rebuilding it means materializing every visible row, which no plain
1380        // open/insert/update/delete needs. `ensure_reservoir_complete` pays
1381        // that cost on the first `approx_aggregate` call instead.
1382        // Load the persistent result-cache tier (hardening (b)) so fine-grained
1383        // invalidation resumes across restart.
1384        db.result_cache.lock().load_persistent();
1385        Ok(db)
1386    }
1387
1388    /// Rebuild `reservoir` from every visible row if it isn't already
1389    /// complete (lazy — same pattern as [`Self::ensure_indexes_complete`]).
1390    /// Open and WAL replay leave the reservoir stale rather than eagerly
1391    /// paying a full-table scan; this pays it once, on the first
1392    /// [`Self::approx_aggregate`] call.
1393    fn ensure_reservoir_complete(&mut self) -> Result<()> {
1394        if self.reservoir_complete {
1395            return Ok(());
1396        }
1397        self.rebuild_reservoir()?;
1398        self.reservoir_complete = true;
1399        Ok(())
1400    }
1401
1402    /// Repopulate the reservoir sample from all visible rows (used on open so a
1403    /// reopened table has an analytics sample without further inserts).
1404    fn rebuild_reservoir(&mut self) -> Result<()> {
1405        let snap = self.snapshot();
1406        let rows = self.visible_rows(snap)?;
1407        self.reservoir.reset();
1408        for r in rows {
1409            self.reservoir.offer(r.row_id.0);
1410        }
1411        Ok(())
1412    }
1413
1414    fn rebuild_indexes_from_runs(&mut self) -> Result<()> {
1415        self.hot = HotIndex::new();
1416        self.pk_by_row.clear();
1417        let (bitmap, ann, fm, sparse, minhash) = empty_indexes(&self.schema);
1418        self.bitmap = bitmap;
1419        self.ann = ann;
1420        self.fm = fm;
1421        self.sparse = sparse;
1422        self.minhash = minhash;
1423        let snapshot = Epoch(u64::MAX);
1424        for rr in self.run_refs.clone() {
1425            let mut reader = self.open_reader(rr.run_id)?;
1426            for row in reader.visible_rows(snapshot)? {
1427                let tok_row = self.tokenized_for_indexes(&row);
1428                index_into(
1429                    &self.schema,
1430                    &tok_row,
1431                    &mut self.hot,
1432                    &mut self.bitmap,
1433                    &mut self.ann,
1434                    &mut self.fm,
1435                    &mut self.sparse,
1436                    &mut self.minhash,
1437                );
1438            }
1439        }
1440        for row in self.mutable_run.visible_versions(snapshot) {
1441            if row.deleted {
1442                self.remove_hot_for_row(row.row_id, snapshot);
1443            } else {
1444                self.index_row(&row);
1445            }
1446        }
1447        for row in self.memtable.visible_versions(snapshot) {
1448            if row.deleted {
1449                self.remove_hot_for_row(row.row_id, snapshot);
1450            } else {
1451                self.index_row(&row);
1452            }
1453        }
1454        self.refresh_pk_by_row_from_hot();
1455        Ok(())
1456    }
1457
1458    fn refresh_pk_by_row_from_hot(&mut self) {
1459        self.pk_by_row_complete = true;
1460        if self.schema.primary_key().is_none() {
1461            self.pk_by_row.clear();
1462            return;
1463        }
1464        // `.collect()` drives `HashMap`'s bulk-build `FromIterator` (reserves
1465        // once from the exact-size iterator), instead of growing-and-rehashing
1466        // through a one-at-a-time `insert()` loop — same fix as
1467        // `HotIndex::from_entries`, same hot path (first delete after a put
1468        // streak rebuilds this from the full HOT index).
1469        self.pk_by_row = self
1470            .hot
1471            .entries()
1472            .into_iter()
1473            .map(|(key, row_id)| (row_id, key))
1474            .collect();
1475    }
1476
1477    fn insert_hot_pk(&mut self, key: Vec<u8>, row_id: RowId) {
1478        if self.schema.primary_key().is_some() {
1479            self.pk_by_row.insert(row_id, key.clone());
1480        }
1481        self.hot.insert(key, row_id);
1482    }
1483
1484    /// (Re)build per-column learned (PGM) range indexes for `LearnedRange`
1485    /// columns from the single sorted run. Serves `Condition::Range` sub-linearly
1486    /// on the fast path; no-op when there isn't exactly one run.
1487    pub(crate) fn build_learned_ranges(&mut self) -> Result<()> {
1488        self.learned_range.clear();
1489        if self.run_refs.len() != 1 {
1490            return Ok(());
1491        }
1492        let cols: Vec<u16> = self
1493            .schema
1494            .indexes
1495            .iter()
1496            .filter(|i| i.kind == IndexKind::LearnedRange)
1497            .map(|i| i.column_id)
1498            .collect();
1499        if cols.is_empty() {
1500            return Ok(());
1501        }
1502        let mut reader = self.open_reader(self.run_refs[0].run_id)?;
1503        let row_ids: Vec<u64> = match reader.column_native(crate::sorted_run::SYS_ROW_ID)? {
1504            columnar::NativeColumn::Int64 { data, .. } => data.iter().map(|x| *x as u64).collect(),
1505            _ => return Ok(()),
1506        };
1507        for cid in cols {
1508            let ty = self
1509                .schema
1510                .columns
1511                .iter()
1512                .find(|c| c.id == cid)
1513                .map(|c| c.ty)
1514                .unwrap_or(TypeId::Int64);
1515            match ty {
1516                TypeId::Int64 | TypeId::TimestampNanos | TypeId::Date32 => {
1517                    if let columnar::NativeColumn::Int64 { data, .. } = reader.column_native(cid)? {
1518                        let pairs: Vec<(i64, u64)> = data
1519                            .iter()
1520                            .zip(row_ids.iter())
1521                            .map(|(v, r)| (*v, *r))
1522                            .collect();
1523                        self.learned_range
1524                            .insert(cid, ColumnLearnedRange::build_i64(&pairs));
1525                    }
1526                }
1527                TypeId::Float64 => {
1528                    if let columnar::NativeColumn::Float64 { data, .. } =
1529                        reader.column_native(cid)?
1530                    {
1531                        let pairs: Vec<(f64, u64)> = data
1532                            .iter()
1533                            .zip(row_ids.iter())
1534                            .map(|(v, r)| (*v, *r))
1535                            .collect();
1536                        self.learned_range
1537                            .insert(cid, ColumnLearnedRange::build_f64(&pairs));
1538                    }
1539                }
1540                _ => {}
1541            }
1542        }
1543        Ok(())
1544    }
1545
1546    /// Phase 14.7: if the live indexes are known incomplete (after a bulk
1547    /// ingest that deferred index building — see [`IndexBuildPolicy`]),
1548    /// rebuild them from the runs now. Called lazily by `query` /
1549    /// `query_columns_native` / `flush`; public so external index consumers
1550    /// (SQL scans, joins, PK point lookups on a shared handle) can pay the
1551    /// one-time build before reading a `&self` index view.
1552    pub fn ensure_indexes_complete(&mut self) -> Result<()> {
1553        if self.indexes_complete {
1554            crate::trace::QueryTrace::record(|t| {
1555                t.index_rebuild = crate::trace::IndexRebuild::AlreadyComplete;
1556            });
1557            return Ok(());
1558        }
1559        crate::trace::QueryTrace::record(|t| {
1560            t.index_rebuild = crate::trace::IndexRebuild::Rebuilt;
1561        });
1562        self.rebuild_indexes_from_runs()?;
1563        self.build_learned_ranges()?;
1564        self.indexes_complete = true;
1565        let epoch = self.current_epoch();
1566        self.checkpoint_indexes(epoch);
1567        Ok(())
1568    }
1569
1570    fn pending_epoch(&self) -> Epoch {
1571        Epoch(self.epoch.visible().0 + 1)
1572    }
1573
1574    /// True when this table is mounted in a `Database` (writes route through the
1575    /// shared WAL).
1576    fn is_shared(&self) -> bool {
1577        matches!(self.wal, WalSink::Shared(_))
1578    }
1579
1580    /// Return the current auto-commit txn id, allocating a fresh one from the
1581    /// shared allocator on a mounted table when a new span starts (sentinel 0).
1582    /// A standalone table uses its private monotonic counter (never 0).
1583    fn ensure_txn_id(&mut self) -> u64 {
1584        if self.current_txn_id == 0 {
1585            let id = match &self.wal {
1586                WalSink::Shared(s) => {
1587                    let mut g = s.txn_ids.lock();
1588                    let v = *g;
1589                    *g = g.wrapping_add(1);
1590                    v
1591                }
1592                WalSink::Private(_) => 1,
1593            };
1594            self.current_txn_id = id;
1595        }
1596        self.current_txn_id
1597    }
1598
1599    /// Append a data record (`Put`/`Delete`) for the current auto-commit txn to
1600    /// whichever WAL backs this table.
1601    fn wal_append_data(&mut self, op: Op) -> Result<()> {
1602        let txn_id = self.ensure_txn_id();
1603        let table_id = self.table_id;
1604        match &mut self.wal {
1605            WalSink::Private(w) => {
1606                w.append_txn(txn_id, op)?;
1607            }
1608            WalSink::Shared(s) => {
1609                s.wal.lock().append(txn_id, table_id, op)?;
1610            }
1611        }
1612        Ok(())
1613    }
1614
1615    /// Upsert a row. Allocates a [`RowId`], appends a (non-fsynced) WAL record,
1616    /// and updates the memtable + indexes. Returns the new row id. Durability
1617    /// arrives at the next [`Table::commit`] (or [`Table::flush`]).
1618    ///
1619    /// For an `AUTO_INCREMENT` primary key, omit the column (or pass
1620    /// Auth enforcement helpers. Each delegates to the optional
1621    /// [`TableAuthChecker`] (set at mount time from the `Database`'s auth
1622    /// state). On a credentialless database (`auth = None`), these are
1623    /// no-ops. The `name` field provides the table name for the permission
1624    /// check without needing a reference back to `Database`.
1625    fn require(&self, perm: crate::auth_state::RequiredPermission) -> Result<()> {
1626        match &self.auth {
1627            Some(checker) => checker.check(&self.name, perm),
1628            None => Ok(()),
1629        }
1630    }
1631    /// Check `Select` permission on this table. Public so that read entry
1632    /// points that don't go through `Table::query` (e.g. `MongrelProvider::scan`,
1633    /// `Table::count`) can enforce before reading. On a credentialless database
1634    /// this is a no-op.
1635    pub fn require_select(&self) -> Result<()> {
1636        self.require(crate::auth_state::RequiredPermission::Select)
1637    }
1638    fn require_insert(&self) -> Result<()> {
1639        self.require(crate::auth_state::RequiredPermission::Insert)
1640    }
1641    /// Currently unused on `Table` directly (updates go through `Transaction`),
1642    /// but kept for API completeness — the four `require_*` helpers mirror the
1643    /// four table-level permission kinds.
1644    #[allow(dead_code)]
1645    fn require_update(&self) -> Result<()> {
1646        self.require(crate::auth_state::RequiredPermission::Update)
1647    }
1648    fn require_delete(&self) -> Result<()> {
1649        self.require(crate::auth_state::RequiredPermission::Delete)
1650    }
1651
1652    /// [`Value::Null`]) and the engine assigns the next counter value; use
1653    /// [`Table::put_returning`] to learn that assigned value.
1654    pub fn put(&mut self, columns: Vec<(u16, Value)>) -> Result<RowId> {
1655        self.require_insert()?;
1656        Ok(self.put_returning(columns)?.0)
1657    }
1658
1659    /// Like [`Table::put`] but also returns the engine-assigned `AUTO_INCREMENT`
1660    /// value (`Some` only when the column was omitted/null and the engine filled
1661    /// it; `None` when the table has no auto-increment column or the caller
1662    /// supplied an explicit value).
1663    pub fn put_returning(
1664        &mut self,
1665        mut columns: Vec<(u16, Value)>,
1666    ) -> Result<(RowId, Option<i64>)> {
1667        self.require_insert()?;
1668        let assigned = self.fill_auto_inc(&mut columns)?;
1669        self.schema.validate_not_null(&columns)?;
1670        // For clustered (WITHOUT ROWID) tables, derive RowId deterministically
1671        // from the PK value so the same PK always maps to the same row (no
1672        // allocator waste, idempotent upserts). For standard tables, use the
1673        // monotonic allocator.
1674        let row_id = if self.schema.clustered {
1675            self.derive_clustered_row_id(&columns)?
1676        } else {
1677            self.allocator.alloc()
1678        };
1679        let epoch = self.pending_epoch();
1680        let mut row = Row::new(row_id, epoch);
1681        for (col_id, val) in columns {
1682            row.columns.insert(col_id, val);
1683        }
1684        self.commit_rows(vec![row], assigned.is_some())?;
1685        Ok((row_id, assigned))
1686    }
1687
1688    /// Bulk upsert: many rows under a single WAL record + one index pass. Far
1689    /// cheaper than `put` in a loop for batch ingest.
1690    pub fn put_batch(&mut self, batch: Vec<Vec<(u16, Value)>>) -> Result<Vec<RowId>> {
1691        self.require_insert()?;
1692        Ok(self
1693            .put_batch_returning(batch)?
1694            .into_iter()
1695            .map(|(r, _)| r)
1696            .collect())
1697    }
1698
1699    /// Like [`Table::put_batch`] but each entry is paired with the engine-
1700    /// assigned `AUTO_INCREMENT` value (`Some` only when filled by the engine).
1701    pub fn put_batch_returning(
1702        &mut self,
1703        batch: Vec<Vec<(u16, Value)>>,
1704    ) -> Result<Vec<(RowId, Option<i64>)>> {
1705        let mut filled: Vec<FilledAutoIncRow> = Vec::with_capacity(batch.len());
1706        for mut cols in batch {
1707            let assigned = self.fill_auto_inc(&mut cols)?;
1708            filled.push((cols, assigned));
1709        }
1710        for (cols, _) in &filled {
1711            self.schema.validate_not_null(cols)?;
1712        }
1713        let epoch = self.pending_epoch();
1714        let mut rows = Vec::with_capacity(filled.len());
1715        let mut ids = Vec::with_capacity(filled.len());
1716        for (cols, assigned) in filled {
1717            let row_id = if self.schema.clustered {
1718                self.derive_clustered_row_id(&cols)?
1719            } else {
1720                self.allocator.alloc()
1721            };
1722            let mut row = Row::new(row_id, epoch);
1723            for (c, v) in cols {
1724                row.columns.insert(c, v);
1725            }
1726            ids.push((row_id, assigned));
1727            rows.push(row);
1728        }
1729        let all_auto_generated = ids.iter().all(|(_, assigned)| assigned.is_some());
1730        self.commit_rows(rows, all_auto_generated)?;
1731        Ok(ids)
1732    }
1733
1734    /// Fill the `AUTO_INCREMENT` column for an upcoming row. When the column is
1735    /// omitted or [`Value::Null`] the next counter value is allocated and the
1736    /// cell is appended/replaced in `columns`; an explicit `Int64` is honored
1737    /// and advances the counter past it. Returns `Some(value)` when the engine
1738    /// allocated (so the caller can surface it), `None` otherwise.
1739    pub fn fill_auto_inc(&mut self, columns: &mut Vec<(u16, Value)>) -> Result<Option<i64>> {
1740        let Some(cid) = self.auto_inc.as_ref().map(|a| a.column_id) else {
1741            return Ok(None);
1742        };
1743        let pos = columns.iter().position(|(c, _)| *c == cid);
1744        let assigned = match pos {
1745            Some(i) => match &columns[i].1 {
1746                Value::Null => {
1747                    let next = self.alloc_auto_inc_value()?;
1748                    columns[i].1 = Value::Int64(next);
1749                    Some(next)
1750                }
1751                Value::Int64(n) => {
1752                    self.advance_auto_inc_past(*n)?;
1753                    None
1754                }
1755                other => {
1756                    return Err(MongrelError::InvalidArgument(format!(
1757                        "AUTO_INCREMENT column {cid} must be Int64 or NULL, got {:?}",
1758                        other
1759                    )))
1760                }
1761            },
1762            None => {
1763                let next = self.alloc_auto_inc_value()?;
1764                columns.push((cid, Value::Int64(next)));
1765                Some(next)
1766            }
1767        };
1768        Ok(assigned)
1769    }
1770
1771    /// Allocate the next identity value, seeding the counter first if needed.
1772    fn alloc_auto_inc_value(&mut self) -> Result<i64> {
1773        self.ensure_auto_inc_seeded()?;
1774        // Borrow checker: re-read after the mutable `ensure` call returns.
1775        let ai = self.auto_inc.as_mut().expect("auto-inc column present");
1776        let v = ai.next;
1777        ai.next = ai.next.saturating_add(1);
1778        Ok(v)
1779    }
1780
1781    /// Advance the counter past an explicit id, seeding first if needed so a
1782    /// pre-existing higher id elsewhere is never ignored.
1783    fn advance_auto_inc_past(&mut self, used: i64) -> Result<()> {
1784        self.ensure_auto_inc_seeded()?;
1785        let ai = self.auto_inc.as_mut().expect("auto-inc column present");
1786        let floor = used.saturating_add(1).max(1);
1787        if ai.next < floor {
1788            ai.next = floor;
1789        }
1790        Ok(())
1791    }
1792
1793    /// Seed the counter on first use by scanning `max(PK)` over all visible
1794    /// rows, so an upgraded table (legacy client-assigned ids, or a manifest
1795    /// migrated from `auto_inc_next == 0`) never hands out a colliding id.
1796    /// Idempotent: a no-op once seeded.
1797    fn ensure_auto_inc_seeded(&mut self) -> Result<()> {
1798        let needs_seed = match self.auto_inc {
1799            Some(ai) => !ai.seeded,
1800            None => return Ok(()),
1801        };
1802        if !needs_seed {
1803            return Ok(());
1804        }
1805        if self.seed_empty_auto_inc() {
1806            return Ok(());
1807        }
1808        let cid = self
1809            .auto_inc
1810            .as_ref()
1811            .expect("auto-inc column present")
1812            .column_id;
1813        let max = self.scan_max_int64(cid)?;
1814        let ai = self.auto_inc.as_mut().expect("auto-inc column present");
1815        let floor = max.saturating_add(1).max(1);
1816        if ai.next < floor {
1817            ai.next = floor;
1818        }
1819        ai.seeded = true;
1820        Ok(())
1821    }
1822
1823    fn alloc_auto_inc_range(&mut self, n: usize) -> Result<Option<i64>> {
1824        if n == 0 || self.auto_inc.is_none() {
1825            return Ok(None);
1826        }
1827        self.ensure_auto_inc_seeded()?;
1828        let ai = self.auto_inc.as_mut().expect("auto-inc column present");
1829        let start = ai.next;
1830        ai.next = ai.next.saturating_add(n as i64);
1831        Ok(Some(start))
1832    }
1833
1834    /// One-time `max(Int64 column)` over all MVCC-visible rows. Used to seed the
1835    /// auto-increment counter. Runs at most once per table (the manifest then
1836    /// checkpoints the seeded counter).
1837    fn scan_max_int64(&mut self, column_id: u16) -> Result<i64> {
1838        let mut max: i64 = 0;
1839        for r in self.memtable.visible_versions(Epoch(u64::MAX)) {
1840            if let Some(Value::Int64(n)) = r.columns.get(&column_id) {
1841                if *n > max {
1842                    max = *n;
1843                }
1844            }
1845        }
1846        for r in self.mutable_run.visible_versions(Epoch(u64::MAX)) {
1847            if let Some(Value::Int64(n)) = r.columns.get(&column_id) {
1848                if *n > max {
1849                    max = *n;
1850                }
1851            }
1852        }
1853        for rr in self.run_refs.clone() {
1854            let reader = self.open_reader(rr.run_id)?;
1855            if let Some(stats) = reader.column_page_stats(column_id) {
1856                for s in stats {
1857                    if let Some(n) = crate::sorted_run::be_i64(s.max.as_deref()) {
1858                        if n > max {
1859                            max = n;
1860                        }
1861                    }
1862                }
1863            } else if reader.has_column(column_id) {
1864                if let columnar::NativeColumn::Int64 { data, validity } =
1865                    reader.column_native_shared(column_id)?
1866                {
1867                    for (i, n) in data.iter().enumerate() {
1868                        if (validity.is_empty() || columnar::validity_bit(&validity, i)) && *n > max
1869                        {
1870                            max = *n;
1871                        }
1872                    }
1873                }
1874            }
1875        }
1876        Ok(max)
1877    }
1878
1879    fn seed_empty_auto_inc(&mut self) -> bool {
1880        let Some(ai) = self.auto_inc.as_mut() else {
1881            return false;
1882        };
1883        if ai.seeded || self.live_count != 0 {
1884            return false;
1885        }
1886        if ai.next < 1 {
1887            ai.next = 1;
1888        }
1889        ai.seeded = true;
1890        true
1891    }
1892
1893    fn advance_auto_inc_from_native_columns(
1894        &mut self,
1895        columns: &[(u16, columnar::NativeColumn)],
1896        n: usize,
1897        live_before: u64,
1898    ) -> Result<()> {
1899        let Some(ai) = self.auto_inc.as_mut() else {
1900            return Ok(());
1901        };
1902        let Some((_, col)) = columns.iter().find(|(cid, _)| *cid == ai.column_id) else {
1903            return Ok(());
1904        };
1905        let columnar::NativeColumn::Int64 { data, validity } = col else {
1906            return Err(MongrelError::InvalidArgument(format!(
1907                "AUTO_INCREMENT column {} must be Int64",
1908                ai.column_id
1909            )));
1910        };
1911        let max = if native_int64_strictly_increasing(col, n) {
1912            data.get(n.saturating_sub(1)).copied()
1913        } else {
1914            data.iter()
1915                .take(n)
1916                .enumerate()
1917                .filter_map(|(i, v)| {
1918                    if validity.is_empty() || columnar::validity_bit(validity, i) {
1919                        Some(*v)
1920                    } else {
1921                        None
1922                    }
1923                })
1924                .max()
1925        };
1926        if let Some(max) = max {
1927            let floor = max.saturating_add(1).max(1);
1928            if ai.next < floor {
1929                ai.next = floor;
1930            }
1931            if ai.seeded || live_before == 0 {
1932                ai.seeded = true;
1933            }
1934        }
1935        Ok(())
1936    }
1937
1938    fn fill_auto_inc_native_columns(
1939        &mut self,
1940        columns: &mut Vec<(u16, columnar::NativeColumn)>,
1941        n: usize,
1942    ) -> Result<()> {
1943        let Some(cid) = self.auto_inc.as_ref().map(|a| a.column_id) else {
1944            return Ok(());
1945        };
1946        let Some(pos) = columns.iter().position(|(id, _)| *id == cid) else {
1947            if let Some(start) = self.alloc_auto_inc_range(n)? {
1948                columns.push((
1949                    cid,
1950                    columnar::NativeColumn::Int64 {
1951                        data: (start..start.saturating_add(n as i64)).collect(),
1952                        validity: vec![0xFF; n.div_ceil(8)],
1953                    },
1954                ));
1955            }
1956            return Ok(());
1957        };
1958
1959        let columnar::NativeColumn::Int64 { data, validity } = &mut columns[pos].1 else {
1960            return Err(MongrelError::InvalidArgument(format!(
1961                "AUTO_INCREMENT column {cid} must be Int64"
1962            )));
1963        };
1964        if data.len() < n {
1965            return Err(MongrelError::InvalidArgument(format!(
1966                "AUTO_INCREMENT column {cid} has {} rows, expected {n}",
1967                data.len()
1968            )));
1969        }
1970        if columnar::all_non_null(validity, n) {
1971            return Ok(());
1972        }
1973        if validity.iter().all(|b| *b == 0) {
1974            if let Some(start) = self.alloc_auto_inc_range(n)? {
1975                for (i, slot) in data.iter_mut().take(n).enumerate() {
1976                    *slot = start.saturating_add(i as i64);
1977                }
1978                *validity = vec![0xFF; n.div_ceil(8)];
1979            }
1980            return Ok(());
1981        }
1982
1983        let new_validity = vec![0xFF; data.len().div_ceil(8)];
1984        for (i, slot) in data.iter_mut().enumerate().take(n) {
1985            if columnar::validity_bit(validity, i) {
1986                self.advance_auto_inc_past(*slot)?;
1987            } else {
1988                *slot = self.alloc_auto_inc_value()?;
1989            }
1990        }
1991        *validity = new_validity;
1992        Ok(())
1993    }
1994
1995    /// Reserve (but do not insert) the next `AUTO_INCREMENT` value, advancing
1996    /// the in-memory counter. Returns `None` when the table has no
1997    /// auto-increment column.
1998    ///
1999    /// This is the escape hatch for callers that stage the row with an explicit
2000    /// id inside a cross-table [`crate::Transaction`] — where the engine cannot
2001    /// fill the column on the `put` path (the row id + cells are only assembled
2002    /// at commit). Unlike the old Kit `__kit_sequences` sequence row, the
2003    /// reservation is a pure in-memory counter bump: no hot row, no second
2004    /// commit. It becomes durable when a row carrying the reserved id commits
2005    /// (the counter is checkpointed to the manifest in the same commit); an
2006    /// aborted reservation simply leaves a gap, which the never-reuse rule
2007    /// permits.
2008    pub fn reserve_auto_inc(&mut self) -> Result<Option<i64>> {
2009        if self.auto_inc.is_none() {
2010            return Ok(None);
2011        }
2012        Ok(Some(self.alloc_auto_inc_value()?))
2013    }
2014
2015    /// Append `rows` under one WAL record. On a standalone table they are folded
2016    /// into the memtable + indexes immediately (single clock — no speculative-
2017    /// epoch hazard). On a mounted table (B1/B2) they are staged in
2018    /// `pending_rows` and applied at the real assigned epoch in `commit`, so a
2019    /// concurrent reader can never see them before their commit epoch.
2020    fn commit_rows(&mut self, rows: Vec<Row>, auto_inc_generated: bool) -> Result<()> {
2021        let payload = bincode::serialize(&rows)?;
2022        self.wal_append_data(Op::Put {
2023            table_id: self.table_id,
2024            rows: payload,
2025        })?;
2026        if self.is_shared() {
2027            self.pending_rows_auto_inc
2028                .extend(std::iter::repeat(auto_inc_generated).take(rows.len()));
2029            self.pending_rows.extend(rows);
2030        } else {
2031            self.apply_put_rows_inner(rows, !auto_inc_generated)?;
2032        }
2033        Ok(())
2034    }
2035
2036    /// Apply already-durable put rows to the memtable + indexes + allocator +
2037    /// live count WITHOUT appending to the per-table WAL (the WAL — shared or
2038    /// per-table — is the caller's responsibility). Used by the cross-table
2039    /// `Transaction` commit path (P2.5) after it has written the shared WAL.
2040    pub(crate) fn apply_put_rows(&mut self, rows: Vec<Row>) -> Result<()> {
2041        self.apply_put_rows_inner(rows, true)
2042    }
2043
2044    fn apply_put_rows_inner(&mut self, rows: Vec<Row>, check_existing_pk: bool) -> Result<()> {
2045        if check_existing_pk {
2046            self.ensure_indexes_complete()?;
2047        }
2048        // Single-row puts — the hot operational path — cannot contain an
2049        // intra-batch duplicate, so the winner/loser partition maps are pure
2050        // overhead. Same semantics as the batch path below with `losers = ∅`.
2051        if rows.len() == 1 {
2052            let row = rows.into_iter().next().expect("len checked");
2053            return self.apply_put_row_single(row, check_existing_pk);
2054        }
2055        // One pass per row: track mutated columns, tombstone the previous
2056        // owner of the row's PK, index (which places the HOT entry), sample,
2057        // and materialize. Each row is applied completely — including its
2058        // memtable upsert — before the next row processes, so "the last row
2059        // wins" falls out naturally for an intra-batch duplicate PK: the
2060        // earlier row is already materialized and gets tombstoned like any
2061        // other displaced owner (same visible state as pre-partitioning the
2062        // batch into winners and losers, without materializing a winner map
2063        // over the whole batch).
2064        //
2065        // Upsert probing is skipped entirely when no PK owner can be
2066        // displaced: `check_existing_pk == false` means every PK is a fresh
2067        // engine-assigned AUTO_INCREMENT value; an empty HOT index plus
2068        // strictly-increasing batch PKs (the append-style batch, mirroring
2069        // `bulk_pk_winner_indices`' fast path) rules out both pre-existing
2070        // owners and intra-batch duplicates.
2071        let pk_id = self.schema.primary_key().map(|c| c.id);
2072        let probe = match pk_id {
2073            Some(pid) => {
2074                check_existing_pk
2075                    && !(self.hot.is_empty() && rows_pk_strictly_increasing(&rows, pid))
2076            }
2077            None => false,
2078        };
2079        // The PK reverse map is maintained inline only once a delete has built
2080        // it (`pk_by_row_complete`); ingest-only tables never pay for it.
2081        let maintain_pk_by_row = pk_id.is_some() && self.pk_by_row_complete;
2082        for r in rows {
2083            for &cid in r.columns.keys() {
2084                self.pending_put_cols.insert(cid);
2085            }
2086            match pk_id {
2087                Some(pid) if probe || maintain_pk_by_row => {
2088                    if let Some(pk_val) = r.columns.get(&pid) {
2089                        let key = self.index_lookup_key(pid, pk_val);
2090                        if probe {
2091                            if let Some(old_rid) = self.hot.get(&key) {
2092                                if old_rid != r.row_id {
2093                                    self.tombstone_row(old_rid, r.committed_epoch, true);
2094                                }
2095                            }
2096                        }
2097                        if maintain_pk_by_row {
2098                            self.pk_by_row.insert(r.row_id, key);
2099                        }
2100                    }
2101                }
2102                Some(_) => {}
2103                None => {
2104                    self.hot.insert(r.row_id.0.to_be_bytes().to_vec(), r.row_id);
2105                }
2106            }
2107            self.index_row(&r);
2108            self.reservoir.offer(r.row_id.0);
2109            self.memtable.upsert(r);
2110            // Count as each row lands so a later duplicate's tombstone
2111            // decrement (in `tombstone_row`) sees an up-to-date value.
2112            self.live_count = self.live_count.saturating_add(1);
2113        }
2114        Ok(())
2115    }
2116
2117    /// One-row specialization of [`Table::apply_put_rows_inner`]: identical
2118    /// upsert semantics (tombstone the previous PK owner, insert into HOT,
2119    /// index, sample, materialize) without the per-batch winner/loser maps.
2120    fn apply_put_row_single(&mut self, row: Row, check_existing_pk: bool) -> Result<()> {
2121        for &cid in row.columns.keys() {
2122            self.pending_put_cols.insert(cid);
2123        }
2124        let epoch = row.committed_epoch;
2125        if let Some(pk_col) = self.schema.primary_key() {
2126            let pk_id = pk_col.id;
2127            if let Some(pk_val) = row.columns.get(&pk_id) {
2128                // `index_row` below writes the HOT entry (`index_into` covers
2129                // the PK). The reverse map is maintained inline only once a
2130                // delete has built it; ingest-only tables never pay for it.
2131                let maintain_pk_by_row = self.pk_by_row_complete;
2132                if check_existing_pk || maintain_pk_by_row {
2133                    let key = self.index_lookup_key(pk_id, pk_val);
2134                    if check_existing_pk {
2135                        if let Some(old_rid) = self.hot.get(&key) {
2136                            if old_rid != row.row_id {
2137                                self.tombstone_row(old_rid, epoch, true);
2138                            }
2139                        }
2140                    }
2141                    if maintain_pk_by_row {
2142                        self.pk_by_row.insert(row.row_id, key);
2143                    }
2144                }
2145            }
2146        } else {
2147            self.hot
2148                .insert(row.row_id.0.to_be_bytes().to_vec(), row.row_id);
2149        }
2150        self.index_row(&row);
2151        self.reservoir.offer(row.row_id.0);
2152        self.memtable.upsert(row);
2153        self.live_count = self.live_count.saturating_add(1);
2154        Ok(())
2155    }
2156
2157    /// Allocate a fresh row id (advancing the table's allocator). Used by the
2158    /// cross-table `Transaction` to assign ids before sealing a row.
2159    pub(crate) fn alloc_row_id(&mut self) -> RowId {
2160        self.allocator.alloc()
2161    }
2162
2163    /// For clustered (WITHOUT ROWID) tables: derive a deterministic `RowId`
2164    /// from the primary-key value so the same PK always maps to the same row.
2165    /// Uses a stable hash of the PK's `encode_key()` bytes, cast to `u64`.
2166    /// This gives WITHOUT ROWID tables idempotent upsert semantics (same PK →
2167    /// same RowId, no allocator waste) without changing the storage format.
2168    fn derive_clustered_row_id(&self, columns: &[(u16, Value)]) -> Result<RowId> {
2169        let pk = self.schema.primary_key().ok_or_else(|| {
2170            MongrelError::Schema("clustered table requires a single-column primary key".into())
2171        })?;
2172        let pk_val = columns
2173            .iter()
2174            .find(|(id, _)| *id == pk.id)
2175            .map(|(_, v)| v)
2176            .ok_or_else(|| {
2177                MongrelError::Schema(format!(
2178                    "clustered table missing primary key column {} ({})",
2179                    pk.id, pk.name
2180                ))
2181            })?;
2182        let key_bytes = pk_val.encode_key();
2183        // Stable hash (FNV-1a 64-bit) — deterministic across runs and processes.
2184        let mut hash: u64 = 0xcbf29ce484222325;
2185        for &b in &key_bytes {
2186            hash ^= b as u64;
2187            hash = hash.wrapping_mul(0x100000001b3);
2188        }
2189        // Ensure non-zero (RowId 0 is valid but we want to avoid collision with
2190        // allocator-generated ids which start at 0 for non-clustered tables).
2191        Ok(RowId(hash.max(1)))
2192    }
2193
2194    /// Apply the metadata for rows that were spilled to a linked uniform-epoch
2195    /// run (P3.4): update the HOT + secondary indexes, the reservoir, the
2196    /// allocator high-water mark, and `live_count` — but **do NOT** insert the
2197    /// rows into the memtable. The rows are served from the linked run (which the
2198    /// scan/merge path reads at the run's commit epoch), so materializing them in
2199    /// the memtable too would defeat the point of spilling (peak memory stays
2200    /// bounded). Caller must have linked the run before reads can resolve indexes.
2201    pub(crate) fn apply_run_metadata(&mut self, rows: &[Row]) -> Result<()> {
2202        self.ensure_indexes_complete()?;
2203        let n = rows.len();
2204        for r in rows {
2205            for &cid in r.columns.keys() {
2206                self.pending_put_cols.insert(cid);
2207            }
2208        }
2209        let (losers, winner_pks) = self.partition_pk_winners(rows);
2210        let epoch = rows.first().map(|r| r.committed_epoch).unwrap_or(Epoch(0));
2211        // Tombstone pre-existing rows that conflict with winners.
2212        for (key, &row_id) in &winner_pks {
2213            if let Some(old_rid) = self.hot.get(key) {
2214                if old_rid != row_id {
2215                    self.tombstone_row(old_rid, epoch, true);
2216                }
2217            }
2218        }
2219        // Hide duplicate-PK rows inside this uniform-epoch run by tombstoning
2220        // their row ids in the memtable overlay (the overlay wins over the run).
2221        for &loser_rid in &losers {
2222            self.tombstone_row(loser_rid, epoch, false);
2223        }
2224        // Insert the winners into HOT.
2225        for (key, row_id) in winner_pks {
2226            self.insert_hot_pk(key, row_id);
2227        }
2228        if self.schema.primary_key().is_none() {
2229            for r in rows {
2230                self.hot.insert(r.row_id.0.to_be_bytes().to_vec(), r.row_id);
2231            }
2232        }
2233        for r in rows {
2234            self.allocator.advance_to(r.row_id);
2235            if !losers.contains(&r.row_id) {
2236                self.index_row(r);
2237            }
2238        }
2239        for r in rows {
2240            if !losers.contains(&r.row_id) {
2241                self.reservoir.offer(r.row_id.0);
2242            }
2243        }
2244        self.live_count = self.live_count.saturating_add((n - losers.len()) as u64);
2245        Ok(())
2246    }
2247
2248    /// Apply already-committed puts + tombstones during shared-WAL recovery
2249    /// (spec §15 pass 2). Advances the allocator, upserts/tombstones the
2250    /// memtable, and indexes the rows — but does NOT touch `live_count` (the
2251    /// manifest is authoritative) and does NOT append to the WAL.
2252    pub(crate) fn recover_apply(
2253        &mut self,
2254        rows: Vec<Row>,
2255        deletes: Vec<(RowId, Epoch)>,
2256    ) -> Result<()> {
2257        // Rows from different transactions have different epochs and can be
2258        // upserted sequentially. Rows inside one transaction share an epoch, so
2259        // duplicate PKs within that transaction must keep only the last winner.
2260        let mut by_epoch: std::collections::BTreeMap<Epoch, Vec<Row>> =
2261            std::collections::BTreeMap::new();
2262        for row in rows {
2263            self.allocator.advance_to(row.row_id);
2264            // Mirror the row-id advance for the AUTO_INCREMENT counter: WAL
2265            // replay must not hand out an id a recovered row already claimed.
2266            // `seeded` is intentionally left untouched so a still-unseeded
2267            // counter still scans `max(PK)` to cover already-flushed rows.
2268            if let Some(ai) = self.auto_inc.as_mut() {
2269                if let Some(Value::Int64(n)) = row.columns.get(&ai.column_id) {
2270                    if *n + 1 > ai.next {
2271                        ai.next = *n + 1;
2272                    }
2273                }
2274            }
2275            by_epoch.entry(row.committed_epoch).or_default().push(row);
2276        }
2277        for (epoch, group) in by_epoch {
2278            let (losers, winner_pks) = self.partition_pk_winners(&group);
2279            // Tombstone pre-existing PK owners.
2280            for (key, &row_id) in &winner_pks {
2281                if let Some(old_rid) = self.hot.get(key) {
2282                    if old_rid != row_id {
2283                        self.tombstone_row(old_rid, epoch, false);
2284                    }
2285                }
2286            }
2287            for (key, row_id) in winner_pks {
2288                self.insert_hot_pk(key, row_id);
2289            }
2290            if self.schema.primary_key().is_none() {
2291                for r in &group {
2292                    self.hot.insert(r.row_id.0.to_be_bytes().to_vec(), r.row_id);
2293                }
2294            }
2295            for r in &group {
2296                if !losers.contains(&r.row_id) {
2297                    self.memtable.upsert(r.clone());
2298                    self.index_row(r);
2299                }
2300            }
2301        }
2302        for (rid, epoch) in deletes {
2303            self.memtable.tombstone(rid, epoch);
2304            self.remove_hot_for_row(rid, epoch);
2305        }
2306        // Reservoir stays lazy — see `ensure_reservoir_complete` — rather than
2307        // eagerly materializing every row on every WAL-replay batch.
2308        self.reservoir_complete = false;
2309        Ok(())
2310    }
2311
2312    /// Highest epoch whose data is durable in a sorted run (spec §7.1).
2313    pub(crate) fn flushed_epoch(&self) -> u64 {
2314        self.flushed_epoch
2315    }
2316
2317    /// Validate that `cells` satisfy the schema's NOT NULL constraints.
2318    pub(crate) fn validate_cells_not_null(&self, cells: &[(u16, Value)]) -> Result<()> {
2319        self.schema.validate_not_null(cells)
2320    }
2321
2322    /// Column-major NOT NULL validation for the bulk-load paths. Every schema
2323    /// column that is not marked NULLABLE must be present in `columns` and have
2324    /// no null validity bits over its first `n` rows.
2325    fn validate_columns_not_null(
2326        &self,
2327        columns: &[(u16, columnar::NativeColumn)],
2328        n: usize,
2329    ) -> Result<()> {
2330        let by_id: HashMap<u16, &columnar::NativeColumn> =
2331            columns.iter().map(|(id, c)| (*id, c)).collect();
2332        for col in &self.schema.columns {
2333            if col.flags.contains(ColumnFlags::NULLABLE) {
2334                continue;
2335            }
2336            match by_id.get(&col.id) {
2337                None => {
2338                    return Err(MongrelError::InvalidArgument(format!(
2339                        "column '{}' ({}) is NOT NULL but was omitted from the bulk load",
2340                        col.name, col.id
2341                    )));
2342                }
2343                Some(c) => {
2344                    if c.null_count(n) != 0 {
2345                        return Err(MongrelError::InvalidArgument(format!(
2346                            "column '{}' ({}) is NOT NULL but the bulk load contains nulls",
2347                            col.name, col.id
2348                        )));
2349                    }
2350                }
2351            }
2352        }
2353        Ok(())
2354    }
2355
2356    /// For a bulk-loaded batch, compute the row indices that survive primary-
2357    /// key upsert: for each PK value the last occurrence wins, earlier
2358    /// duplicates are dropped. Rows with a null PK value are always kept. Returns
2359    /// `None` when there is no primary key or no compaction is needed.
2360    fn bulk_pk_winner_indices(
2361        &self,
2362        columns: &[(u16, columnar::NativeColumn)],
2363        n: usize,
2364    ) -> Option<Vec<usize>> {
2365        let pk_col = self.schema.primary_key()?;
2366        let pk_id = pk_col.id;
2367        let pk_ty = pk_col.ty;
2368        let by_id: HashMap<u16, &columnar::NativeColumn> =
2369            columns.iter().map(|(id, c)| (*id, c)).collect();
2370        let pk_native = by_id.get(&pk_id)?;
2371        if native_int64_strictly_increasing(pk_native, n) {
2372            return None;
2373        }
2374        // key -> index of the last row that carried that PK value.
2375        let mut last: HashMap<Vec<u8>, usize> = HashMap::new();
2376        let mut null_pk_rows: Vec<usize> = Vec::new();
2377        for i in 0..n {
2378            match bulk_index_key(&self.column_keys, pk_id, pk_ty, pk_native, i) {
2379                Some(key) => {
2380                    last.insert(key, i);
2381                }
2382                None => null_pk_rows.push(i),
2383            }
2384        }
2385        let mut winners: HashSet<usize> = last.values().copied().collect();
2386        for i in null_pk_rows {
2387            winners.insert(i);
2388        }
2389        Some((0..n).filter(|i| winners.contains(i)).collect())
2390    }
2391
2392    /// Logically delete `row_id` (effective at the next commit).
2393    pub fn delete(&mut self, row_id: RowId) -> Result<()> {
2394        self.require_delete()?;
2395        let epoch = self.pending_epoch();
2396        self.wal_append_data(Op::Delete {
2397            table_id: self.table_id,
2398            row_ids: vec![row_id],
2399        })?;
2400        if self.is_shared() {
2401            self.pending_dels.push(row_id);
2402        } else {
2403            self.apply_delete(row_id, epoch);
2404        }
2405        Ok(())
2406    }
2407
2408    pub fn delete_returning(&mut self, row_id: RowId) -> Result<Option<OwnedRow>> {
2409        let pre = self.get(row_id, self.snapshot());
2410        self.delete(row_id)?;
2411        Ok(pre.map(|row| {
2412            let mut columns: Vec<_> = row.columns.into_iter().collect();
2413            columns.sort_by_key(|(id, _)| *id);
2414            OwnedRow { columns }
2415        }))
2416    }
2417
2418    /// Durably remove every row in the table once the current write span commits.
2419    pub fn truncate(&mut self) -> Result<()> {
2420        self.require_delete()?;
2421        let epoch = self.pending_epoch();
2422        self.wal_append_data(Op::TruncateTable {
2423            table_id: self.table_id,
2424        })?;
2425        self.pending_rows.clear();
2426        self.pending_rows_auto_inc.clear();
2427        self.pending_dels.clear();
2428        self.pending_truncate = Some(epoch);
2429        Ok(())
2430    }
2431
2432    /// Apply an already-durable truncate without appending to the WAL.
2433    pub(crate) fn apply_truncate(&mut self, _epoch: Epoch) -> Result<()> {
2434        for rr in std::mem::take(&mut self.run_refs) {
2435            let _ = std::fs::remove_file(self.run_path(rr.run_id as u64));
2436        }
2437        for r in std::mem::take(&mut self.retiring) {
2438            let _ = std::fs::remove_file(self.run_path(r.run_id as u64));
2439        }
2440        self.memtable = Memtable::new();
2441        self.mutable_run = MutableRun::new();
2442        self.hot = HotIndex::new();
2443        let (bitmap, ann, fm, sparse, minhash) = empty_indexes(&self.schema);
2444        self.bitmap = bitmap;
2445        self.ann = ann;
2446        self.fm = fm;
2447        self.sparse = sparse;
2448        self.minhash = minhash;
2449        self.learned_range.clear();
2450        self.pk_by_row.clear();
2451        self.pk_by_row_complete = false;
2452        self.live_count = 0;
2453        self.reservoir = crate::reservoir::Reservoir::default();
2454        self.reservoir_complete = true;
2455        self.had_deletes = true;
2456        self.agg_cache.clear();
2457        self.global_idx_epoch = 0;
2458        self.indexes_complete = true;
2459        self.pending_delete_rids.clear();
2460        self.pending_put_cols.clear();
2461        self.pending_rows.clear();
2462        self.pending_rows_auto_inc.clear();
2463        self.pending_dels.clear();
2464        self.clear_result_cache();
2465        self.invalidate_index_checkpoint();
2466        Ok(())
2467    }
2468
2469    /// Apply a tombstone (already-durable on the WAL) at `epoch` without
2470    /// appending to the per-table WAL. Used by the cross-table `Transaction`.
2471    pub(crate) fn apply_delete(&mut self, row_id: RowId, epoch: Epoch) {
2472        self.remove_hot_for_row(row_id, epoch);
2473        self.tombstone_row(row_id, epoch, true);
2474    }
2475
2476    /// Tombstone `row_id` at `epoch`. When `adjust_live_count` is true the
2477    /// table's `live_count` is decremented (used on the live write path); during
2478    /// recovery the manifest is authoritative so the flag is false.
2479    fn tombstone_row(&mut self, row_id: RowId, epoch: Epoch, adjust_live_count: bool) {
2480        let tombstone = Row {
2481            row_id,
2482            committed_epoch: epoch,
2483            columns: std::collections::HashMap::new(),
2484            deleted: true,
2485        };
2486        self.memtable.upsert(tombstone);
2487        self.pk_by_row.remove(&row_id);
2488        if adjust_live_count {
2489            self.live_count = self.live_count.saturating_sub(1);
2490        }
2491        // Track for fine-grained cache invalidation (c).
2492        self.pending_delete_rids.insert(row_id.0 as u32);
2493        // A delete makes the incremental aggregate cache (row-id watermark
2494        // delta) unsafe — permanently disable it for this table.
2495        self.had_deletes = true;
2496        self.agg_cache.clear();
2497    }
2498
2499    /// If `row_id` has a primary-key value and the HOT index currently maps
2500    /// that PK to this row id, remove the entry. Keeps the PK→RowId mapping
2501    /// consistent after deletes and before upserts.
2502    fn remove_hot_for_row(&mut self, row_id: RowId, epoch: Epoch) {
2503        let Some(pk_col) = self.schema.primary_key() else {
2504            return;
2505        };
2506        // Warm path: a prior delete in this process already paid the
2507        // reverse-map rebuild below, so it's kept up to date — O(1).
2508        if self.pk_by_row_complete {
2509            if let Some(key) = self.pk_by_row.remove(&row_id) {
2510                if self.hot.get(&key) == Some(row_id) {
2511                    self.hot.remove(&key);
2512                }
2513            }
2514            return;
2515        }
2516        // Cold path (the common case: a short-lived process — CLI,
2517        // NAPI-per-call — that deletes once and exits): derive the PK
2518        // straight from the row's own pre-delete version via a targeted
2519        // get_version lookup (memtable -> mutable_run -> runs, the same
2520        // page-pruned lookup `Table::get` uses) instead of paying
2521        // `refresh_pk_by_row_from_hot`'s O(table-size) rebuild for a single
2522        // delete. `pk_by_row` is deliberately left incomplete here — same
2523        // "puts leave the reverse map stale" tradeoff, extended to this path.
2524        //
2525        // Look up at `epoch - 1`, not `epoch`: on the live-delete call site
2526        // this delete's own tombstone hasn't landed yet either way, but on
2527        // the WAL-replay call sites (`recover_apply`, `open_in`) the
2528        // memtable tombstone for this exact row/epoch is already applied
2529        // before this runs. Querying `epoch` would see that tombstone
2530        // (empty columns) and fall through to the full rebuild every time a
2531        // replayed delete exists; `epoch - 1` is still >= any real prior
2532        // version's committed_epoch (epochs are unique and monotonic), so it
2533        // finds the same pre-delete row either way.
2534        let lookup_epoch = Epoch(epoch.0.saturating_sub(1));
2535        if self.indexes_complete {
2536            let pk_val = self
2537                .memtable
2538                .get_version(row_id, lookup_epoch)
2539                .and_then(|(_, r)| r.columns.get(&pk_col.id).cloned())
2540                .or_else(|| {
2541                    self.mutable_run
2542                        .get_version(row_id, lookup_epoch)
2543                        .filter(|(_, r)| !r.deleted)
2544                        .and_then(|(_, r)| r.columns.get(&pk_col.id).cloned())
2545                })
2546                .or_else(|| {
2547                    self.run_refs.iter().find_map(|rr| {
2548                        let mut reader = self.open_reader(rr.run_id).ok()?;
2549                        let (_, deleted, val) = reader
2550                            .get_version_column(row_id, lookup_epoch, pk_col.id)
2551                            .ok()??;
2552                        if deleted {
2553                            return None;
2554                        }
2555                        val
2556                    })
2557                });
2558            if let Some(pk_val) = pk_val {
2559                let key = self.index_lookup_key(pk_col.id, &pk_val);
2560                if self.hot.get(&key) == Some(row_id) {
2561                    self.hot.remove(&key);
2562                }
2563                return;
2564            }
2565        }
2566        // Fallback: full reverse-map rebuild, guaranteed correct. Reached
2567        // when indexes aren't complete yet, or the row was already gone by
2568        // the time this ran (e.g. already tombstoned in an overlay ahead of
2569        // this HOT cleanup, as `rebuild_indexes_from_runs` does).
2570        self.refresh_pk_by_row_from_hot();
2571        if let Some(key) = self.pk_by_row.remove(&row_id) {
2572            if self.hot.get(&key) == Some(row_id) {
2573                self.hot.remove(&key);
2574            }
2575        }
2576    }
2577
2578    /// For a batch of rows that share the same commit epoch, decide which rows
2579    /// win for each primary-key value. Returns the set of "loser" row ids that
2580    /// must be skipped/overwritten, and a map from PK lookup key to the winning
2581    /// row id. Rows without a PK value are always winners.
2582    fn partition_pk_winners(
2583        &self,
2584        rows: &[Row],
2585    ) -> (
2586        std::collections::HashSet<RowId>,
2587        std::collections::HashMap<Vec<u8>, RowId>,
2588    ) {
2589        let mut losers = std::collections::HashSet::new();
2590        let Some(pk_col) = self.schema.primary_key() else {
2591            return (losers, std::collections::HashMap::new());
2592        };
2593        let pk_id = pk_col.id;
2594        let mut winners: std::collections::HashMap<Vec<u8>, RowId> =
2595            std::collections::HashMap::new();
2596        for r in rows {
2597            let Some(pk_val) = r.columns.get(&pk_id) else {
2598                continue;
2599            };
2600            let key = self.index_lookup_key(pk_id, pk_val);
2601            if let Some(&old_rid) = winners.get(&key) {
2602                losers.insert(old_rid);
2603            }
2604            winners.insert(key, r.row_id);
2605        }
2606        (losers, winners)
2607    }
2608
2609    fn index_row(&mut self, row: &Row) {
2610        if row.deleted {
2611            return;
2612        }
2613        // Partial index filtering: skip rows that don't match any index's
2614        // predicate. The predicate is a SQL WHERE clause string evaluated
2615        // against the row's column values. For now, we support a simple
2616        // "column_name IS NOT NULL" and "column_name = value" syntax that
2617        // covers the common partial-index patterns (e.g. WHERE deleted_at
2618        // IS NULL). More complex predicates require a full expression
2619        // evaluator in core (future work).
2620        let any_predicate = self
2621            .schema
2622            .indexes
2623            .iter()
2624            .any(|idx| idx.predicate.is_some());
2625        if any_predicate {
2626            let columns_map: HashMap<u16, &Value> =
2627                row.columns.iter().map(|(k, v)| (*k, v)).collect();
2628            let name_to_id: HashMap<&str, u16> = self
2629                .schema
2630                .columns
2631                .iter()
2632                .map(|c| (c.name.as_str(), c.id))
2633                .collect();
2634            for idx in &self.schema.indexes {
2635                if let Some(pred) = &idx.predicate {
2636                    if !eval_partial_predicate(pred, &columns_map, &name_to_id) {
2637                        continue; // skip this index for this row
2638                    }
2639                }
2640                // Index the row into this specific index only.
2641                index_into_single(
2642                    idx,
2643                    &self.schema,
2644                    row,
2645                    &mut self.hot,
2646                    &mut self.bitmap,
2647                    &mut self.ann,
2648                    &mut self.fm,
2649                    &mut self.sparse,
2650                    &mut self.minhash,
2651                );
2652            }
2653            return;
2654        }
2655        // Plaintext tables index the row as-is; only ENCRYPTED_INDEXABLE
2656        // columns need the tokenized copy (`tokenized_for_indexes` clones the
2657        // whole row, which would tax every put on unencrypted tables).
2658        if self.column_keys.is_empty() {
2659            index_into(
2660                &self.schema,
2661                row,
2662                &mut self.hot,
2663                &mut self.bitmap,
2664                &mut self.ann,
2665                &mut self.fm,
2666                &mut self.sparse,
2667                &mut self.minhash,
2668            );
2669            return;
2670        }
2671        let effective_row = self.tokenized_for_indexes(row);
2672        index_into(
2673            &self.schema,
2674            &effective_row,
2675            &mut self.hot,
2676            &mut self.bitmap,
2677            &mut self.ann,
2678            &mut self.fm,
2679            &mut self.sparse,
2680            &mut self.minhash,
2681        );
2682    }
2683
2684    /// Produce the row view that indexes should see. For ENCRYPTED_INDEXABLE
2685    /// equality (HMAC-eq) columns the plaintext value is replaced by its token,
2686    /// so the bitmap/HOT indexes store tokens. OPE-range columns keep their raw
2687    /// value (their range index is rebuilt from runs over plaintext). Plaintext
2688    /// tables return the row unchanged.
2689    fn tokenized_for_indexes(&self, row: &Row) -> Row {
2690        if self.column_keys.is_empty() {
2691            return row.clone();
2692        }
2693        #[cfg(feature = "encryption")]
2694        {
2695            use crate::encryption::SCHEME_HMAC_EQ;
2696            let mut tok = row.clone();
2697            for (&cid, &(_, scheme)) in &self.column_keys {
2698                if scheme != SCHEME_HMAC_EQ {
2699                    continue;
2700                }
2701                if let Some(v) = tok.columns.get(&cid).cloned() {
2702                    if let Some(t) = self.tokenize_value(cid, &v) {
2703                        tok.columns.insert(cid, t);
2704                    }
2705                }
2706            }
2707            tok
2708        }
2709        #[cfg(not(feature = "encryption"))]
2710        {
2711            row.clone()
2712        }
2713    }
2714
2715    /// Group-commit: make all pending writes durable, advance the epoch so they
2716    /// become visible, and persist the manifest. Dispatches on the WAL sink: a
2717    /// standalone table fsyncs its private WAL; a mounted table seals into the
2718    /// shared WAL and defers the fsync to the group-commit coordinator (B1).
2719    pub fn commit(&mut self) -> Result<Epoch> {
2720        if self.is_shared() {
2721            self.commit_shared()
2722        } else {
2723            self.commit_private()
2724        }
2725    }
2726
2727    /// Standalone commit: fsync the private WAL under the commit lock.
2728    fn commit_private(&mut self) -> Result<Epoch> {
2729        // Serialize the assign→fsync→publish critical section across all tables
2730        // sharing the epoch authority so `visible` is published strictly in
2731        // assigned order (the dual-counter invariant).
2732        let commit_lock = Arc::clone(&self.commit_lock);
2733        let _g = commit_lock.lock();
2734        let new_epoch = self.epoch.bump_assigned();
2735        let txn_id = self.current_txn_id;
2736        // Seal the staged records under a TxnCommit marker carrying the commit
2737        // epoch, then a single group fsync. Recovery applies only records whose
2738        // txn has a durable TxnCommit (uncommitted/torn tails are discarded).
2739        match &mut self.wal {
2740            WalSink::Private(w) => {
2741                w.append_txn(
2742                    txn_id,
2743                    Op::TxnCommit {
2744                        epoch: new_epoch.0,
2745                        added_runs: Vec::new(),
2746                    },
2747                )?;
2748                w.sync()?;
2749            }
2750            WalSink::Shared(_) => unreachable!("commit_private on a shared sink"),
2751        }
2752        // The truncate record is now durable; apply the physical clear.
2753        if let Some(epoch) = self.pending_truncate.take() {
2754            self.apply_truncate(epoch)?;
2755        }
2756        self.invalidate_pending_cache();
2757        self.persist_manifest(new_epoch)?;
2758        // Publish through the shared in-order gate so a `Table::commit` can never
2759        // advance the watermark past an in-flight cross-table transaction's
2760        // lower assigned epoch whose writes are not yet applied (spec §9.3e).
2761        self.epoch.publish_in_order(new_epoch);
2762        self.current_txn_id += 1;
2763        Ok(new_epoch)
2764    }
2765
2766    /// Mounted commit (B1/B2): mirror the cross-table sequencer. Seal a
2767    /// `TxnCommit` into the shared WAL under the WAL lock (assigning the epoch in
2768    /// WAL-append order), make it durable via the group-commit coordinator (one
2769    /// leader fsync for the whole batch), then apply the staged rows at the
2770    /// assigned epoch and publish in order. Honors the shared poison flag.
2771    fn commit_shared(&mut self) -> Result<Epoch> {
2772        use std::sync::atomic::Ordering;
2773        let s = match &self.wal {
2774            WalSink::Shared(s) => s.clone(),
2775            WalSink::Private(_) => unreachable!("commit_shared on a private sink"),
2776        };
2777        if s.poisoned.load(Ordering::Relaxed) {
2778            return Err(MongrelError::Other(
2779                "database poisoned by fsync error".into(),
2780            ));
2781        }
2782        // Serialize the whole single-table commit critical section (assign →
2783        // durable → publish) under the shared commit lock so concurrent
2784        // `Table::commit`s publish strictly in assigned order and each returns
2785        // only once its epoch is visible (read-your-writes after commit). The
2786        // fsync still defers to the group-commit coordinator, which can batch a
2787        // held commit with concurrent cross-table `transaction()` committers.
2788        let commit_lock = Arc::clone(&self.commit_lock);
2789        let _g = commit_lock.lock();
2790        // Always seal a txn (allocating an id if this span had no writes) so the
2791        // epoch advances monotonically like the standalone path.
2792        let txn_id = self.ensure_txn_id();
2793        let (new_epoch, commit_seq) = {
2794            let mut wal = s.wal.lock();
2795            let new_epoch = self.epoch.bump_assigned();
2796            let seq = wal.append_commit(txn_id, new_epoch, &[])?;
2797            (new_epoch, seq)
2798        };
2799        s.group
2800            .await_durable(&s.wal, commit_seq)
2801            .inspect_err(|_| s.poisoned.store(true, Ordering::Relaxed))?;
2802
2803        // Apply staged truncate/rows/tombstones at the real assigned epoch (B2): nothing
2804        // was stamped speculatively, and nothing is visible until publish below.
2805        if self.pending_truncate.take().is_some() {
2806            self.apply_truncate(new_epoch)?;
2807        }
2808        let mut rows = std::mem::take(&mut self.pending_rows);
2809        if !rows.is_empty() {
2810            for r in &mut rows {
2811                r.committed_epoch = new_epoch;
2812            }
2813            let auto_inc_flags = std::mem::take(&mut self.pending_rows_auto_inc);
2814            let all_auto_generated =
2815                auto_inc_flags.len() == rows.len() && auto_inc_flags.iter().all(|b| *b);
2816            self.apply_put_rows_inner(rows, !all_auto_generated)?;
2817        } else {
2818            self.pending_rows_auto_inc.clear();
2819        }
2820        let dels = std::mem::take(&mut self.pending_dels);
2821        for rid in dels {
2822            self.apply_delete(rid, new_epoch);
2823        }
2824
2825        self.invalidate_pending_cache();
2826        self.persist_manifest(new_epoch)?;
2827        self.epoch.publish_in_order(new_epoch);
2828        // Next auto-commit span allocates a fresh shared txn id.
2829        self.current_txn_id = 0;
2830        Ok(new_epoch)
2831    }
2832
2833    /// Commit, then drain the memtable into the mutable-run LSM tier (Phase
2834    /// 11.1). The tier absorbs flushes in place and only spills to an immutable
2835    /// `.sr` sorted run once it crosses the spill watermark — coalescing many
2836    /// small flushes into fewer, larger runs. While the tier holds un-spilled
2837    /// data the WAL is **not** rotated: the Flush marker / WAL rotation is
2838    /// deferred until the data is durably in a run, so crash recovery replays
2839    /// those rows back into the memtable (the tier rebuilds from replay).
2840    pub fn flush(&mut self) -> Result<Epoch> {
2841        self.ensure_indexes_complete()?;
2842        let epoch = self.commit()?;
2843        let rows = self.memtable.drain_sorted();
2844        if !rows.is_empty() {
2845            self.mutable_run.insert_many(rows);
2846        }
2847        if self.mutable_run.approx_bytes() >= self.mutable_run_spill_bytes {
2848            self.spill_mutable_run(epoch)?;
2849            // The tier is now empty and its data is durably in a run → safe to
2850            // mark the WAL flushed (and, for a private WAL, rotate to a fresh
2851            // segment so the flushed records aren't replayed).
2852            self.mark_flushed(epoch)?;
2853            self.persist_manifest(epoch)?;
2854            self.build_learned_ranges()?;
2855            // Memtable is drained and runs are stable → checkpoint the indexes so
2856            // the next open skips the full run scan (Phase 9.1).
2857            self.checkpoint_indexes(epoch);
2858        }
2859        // else: data coalesced in the in-memory tier; the WAL still covers it
2860        // and the manifest epoch was already persisted by `commit`.
2861        Ok(epoch)
2862    }
2863
2864    /// Force a full flush to a `.sr` sorted run regardless of the spill
2865    /// threshold. Temporarily lowers `mutable_run_spill_bytes` to 1 so the
2866    /// threshold check in [`Self::flush`] always fires. Used by
2867    /// [`Self::close`] and the Kit's flush-on-close path (§4.4) so a
2868    /// short-lived process (CLI, one-shot script) leaves all pending writes
2869    /// durable in a run — keeping WAL segment count bounded across repeated
2870    /// invocations. Best-effort: errors are propagated but the threshold is
2871    /// always restored.
2872    pub fn force_flush(&mut self) -> Result<Epoch> {
2873        let saved = self.mutable_run_spill_bytes;
2874        self.mutable_run_spill_bytes = 1;
2875        let result = self.flush();
2876        self.mutable_run_spill_bytes = saved;
2877        result
2878    }
2879
2880    /// Best-effort close: force-flush any pending writes to a sorted run so
2881    /// the WAL segments can be reaped on the next open. Never panics — a
2882    /// flush error is logged and returned but the threshold is always
2883    /// restored. Call this as the last action before a short-lived process
2884    /// exits (CLI, one-shot script). Not needed for the daemon (its
2885    /// background auto-compactor handles run management). (§4.4)
2886    pub fn close(&mut self) -> Result<()> {
2887        if self.memtable_len() > 0 || self.mutable_run_len() > 0 {
2888            self.force_flush()?;
2889        }
2890        Ok(())
2891    }
2892
2893    /// Mark `epoch` as flushed: append a `Flush` marker to the WAL, advance
2894    /// `flushed_epoch`, and — for a private WAL only — rotate to a fresh segment
2895    /// so the now-durable-in-a-run records are not replayed. A mounted table's
2896    /// shared WAL is never rotated per-table; recovery skips its already-flushed
2897    /// records via the manifest `flushed_epoch` gate, and segment GC (B3c) reaps
2898    /// them once every table has flushed past them.
2899    fn mark_flushed(&mut self, epoch: Epoch) -> Result<()> {
2900        let op = Op::Flush {
2901            table_id: self.table_id,
2902            flushed_epoch: epoch.0,
2903        };
2904        match &mut self.wal {
2905            WalSink::Private(w) => {
2906                w.append_system(op)?;
2907                w.sync()?;
2908            }
2909            WalSink::Shared(s) => {
2910                // Informational in the shared log (recovery gates on the manifest
2911                // `flushed_epoch`); not separately fsynced — the run + manifest
2912                // are the durability point and the underlying rows were already
2913                // fsynced at their commit.
2914                s.wal.lock().append_system(op)?;
2915            }
2916        }
2917        self.flushed_epoch = epoch.0;
2918        if matches!(self.wal, WalSink::Private(_)) {
2919            self.rotate_wal(epoch)?;
2920        }
2921        Ok(())
2922    }
2923
2924    /// Spill the mutable-run tier to a new immutable level-0 sorted run. The
2925    /// caller owns the Flush-marker / WAL-rotation / manifest steps (only valid
2926    /// once all in-flight data is in runs). No-op when the tier is empty.
2927    fn spill_mutable_run(&mut self, epoch: Epoch) -> Result<()> {
2928        let rows = self.mutable_run.drain_sorted();
2929        if rows.is_empty() {
2930            return Ok(());
2931        }
2932        let run_id = self.next_run_id;
2933        self.next_run_id += 1;
2934        let path = self.run_path(run_id);
2935        let mut writer = RunWriter::new(&self.schema, run_id as u128, epoch, 0);
2936        if let Some(kek) = &self.kek {
2937            writer = writer.with_encryption(kek.as_ref(), self.indexable_column_specs());
2938        }
2939        let header = writer.write(&path, &rows)?;
2940        self.run_refs.push(RunRef {
2941            run_id: run_id as u128,
2942            level: 0,
2943            epoch_created: epoch.0,
2944            row_count: header.row_count,
2945        });
2946        Ok(())
2947    }
2948
2949    /// Tune the mutable-run spill watermark (bytes). A smaller threshold spills
2950    /// sooner (more, smaller runs — closer to the pre-Phase-11.1 behavior); a
2951    /// larger one coalesces more flushes in memory.
2952    pub fn set_mutable_run_spill_bytes(&mut self, bytes: u64) {
2953        self.mutable_run_spill_bytes = bytes.max(1);
2954    }
2955
2956    /// Set the zstd compression level for compaction output (Phase 18.1).
2957    /// Default 3; higher values give better compression ratio at the cost of
2958    /// slower compaction.
2959    pub fn set_compaction_zstd_level(&mut self, level: i32) {
2960        self.compaction_zstd_level = level;
2961    }
2962
2963    /// Set the result-cache byte budget (Phase 19.1 hardening (a)). Entries are
2964    /// evicted in access-order LRU past this limit. Takes effect immediately
2965    /// (may evict entries if the new limit is smaller than the current footprint).
2966    pub fn set_result_cache_max_bytes(&mut self, max_bytes: u64) {
2967        self.result_cache.lock().set_max_bytes(max_bytes);
2968    }
2969
2970    /// Drop every cached result (used by compaction, schema evolution, and bulk
2971    /// load — paths that change run layout or data without going through the
2972    /// fine-grained `pending_*` tracking).
2973    pub(crate) fn clear_result_cache(&mut self) {
2974        self.result_cache.lock().clear();
2975    }
2976
2977    /// Number of versions currently held in the mutable-run tier.
2978    pub fn mutable_run_len(&self) -> usize {
2979        self.mutable_run.len()
2980    }
2981
2982    /// Drain every version from the mutable-run tier (ascending `(RowId,
2983    /// Epoch)` order). Used by compaction to fold the tier into its merge.
2984    pub(crate) fn drain_mutable_run(&mut self) -> Vec<Row> {
2985        self.mutable_run.drain_sorted()
2986    }
2987
2988    /// Bulk-load: write `batch` directly to a new sorted run, bypassing the WAL
2989    /// and the memtable entirely (no per-row bincode, no skip-list inserts). The
2990    /// run + a rotated WAL + the manifest are fsynced once — the fast ingest
2991    /// path for large analytical loads. Indexes are still maintained.
2992    pub fn bulk_load(&mut self, batch: Vec<Vec<(u16, Value)>>) -> Result<Epoch> {
2993        let epoch = self.commit()?;
2994        let n = batch.len();
2995        if n == 0 {
2996            return Ok(epoch);
2997        }
2998        let live_before = self.live_count;
2999        // Spill any pending mutable-run data first: bulk_load writes a Flush
3000        // marker + rotates the WAL below, which is only safe once all in-flight
3001        // data is durably in a run.
3002        self.spill_mutable_run(epoch)?;
3003        let eager_index_build = self.index_build_policy == IndexBuildPolicy::Eager
3004            && self.indexes_complete
3005            && self.run_refs.is_empty()
3006            && self.memtable.is_empty()
3007            && self.mutable_run.is_empty();
3008        // Phase 14.7: route the legacy Value API through the same parallel
3009        // encode + typed batch-index path as `bulk_load_columns`. Transpose the
3010        // row-major sparse batch → column-major typed columns (in parallel),
3011        // then `write_native` + `index_columns_bulk`, instead of per-row
3012        // `Row { HashMap }` + `index_into` + the sequential `Value` writer.
3013        let mut user_columns: Vec<(u16, columnar::NativeColumn)> = {
3014            use rayon::prelude::*;
3015            self.schema
3016                .columns
3017                .par_iter()
3018                .map(|cdef| (cdef.id, columnar::rows_to_native(cdef.ty, &batch, cdef.id)))
3019                .collect::<Vec<_>>()
3020        };
3021        drop(batch);
3022        // Enforce NOT NULL constraints and primary-key upsert semantics before
3023        // any row id is allocated or bytes hit the run file. Losers of a
3024        // duplicate primary key are dropped from the encoded run entirely so
3025        // the dedup survives reopen (no ephemeral memtable tombstone).
3026        self.fill_auto_inc_native_columns(&mut user_columns, n)?;
3027        self.validate_columns_not_null(&user_columns, n)?;
3028        let winner_idx = self
3029            .bulk_pk_winner_indices(&user_columns, n)
3030            .and_then(|idx| if idx.len() == n { None } else { Some(idx) });
3031        let (write_columns, write_n): (Vec<(u16, columnar::NativeColumn)>, usize) =
3032            match winner_idx.as_deref() {
3033                Some(idx) => {
3034                    let compacted = user_columns
3035                        .iter()
3036                        .map(|(id, c)| (*id, c.gather(idx)))
3037                        .collect();
3038                    (compacted, idx.len())
3039                }
3040                None => (std::mem::take(&mut user_columns), n),
3041            };
3042        self.advance_auto_inc_from_native_columns(&write_columns, write_n, live_before)?;
3043        let first = self.allocator.alloc_range(write_n as u64).0;
3044        for rid in first..first + write_n as u64 {
3045            self.reservoir.offer(rid);
3046        }
3047        let run_id = self.next_run_id;
3048        self.next_run_id += 1;
3049        let path = self.run_path(run_id);
3050        let mut writer = RunWriter::new(&self.schema, run_id as u128, epoch, 0)
3051            .clean(true)
3052            .with_lz4()
3053            .with_native_endian();
3054        if let Some(kek) = &self.kek {
3055            writer = writer.with_encryption(kek.as_ref(), self.indexable_column_specs());
3056        }
3057        let header = writer.write_native(&path, &write_columns, write_n, first)?;
3058        self.run_refs.push(RunRef {
3059            run_id: run_id as u128,
3060            level: 0,
3061            epoch_created: epoch.0,
3062            row_count: header.row_count,
3063        });
3064        self.live_count = self.live_count.saturating_add(write_n as u64);
3065        if eager_index_build {
3066            let row_ids: Vec<u64> = (first..first + write_n as u64).collect();
3067            self.index_columns_bulk(&write_columns, &row_ids);
3068            self.indexes_complete = true;
3069            self.build_learned_ranges()?;
3070        } else {
3071            self.indexes_complete = false;
3072        }
3073        self.mark_flushed(epoch)?;
3074        self.persist_manifest(epoch)?;
3075        if eager_index_build {
3076            self.checkpoint_indexes(epoch);
3077        }
3078        self.clear_result_cache();
3079        Ok(epoch)
3080    }
3081
3082    /// Rotate the private WAL to a fresh segment. Only valid for a standalone
3083    /// table — a mounted table never rotates the shared WAL per-table.
3084    fn rotate_wal(&mut self, epoch: Epoch) -> Result<()> {
3085        let segment = next_wal_segment(&self.dir.join(WAL_DIR))?;
3086        let cipher = self.wal_dek.as_ref().map(|dk| make_cipher(dk));
3087        // The segment number (from the filename) namespaces nonces under the
3088        // constant WAL DEK — pass it through to the writer.
3089        let segment_no = segment
3090            .file_stem()
3091            .and_then(|s| s.to_str())
3092            .and_then(|s| s.strip_prefix("seg-"))
3093            .and_then(|s| s.parse::<u64>().ok())
3094            .unwrap_or(0);
3095        let mut wal = Wal::create_with_cipher(segment, epoch, cipher, segment_no)?;
3096        wal.set_sync_byte_threshold(self.sync_byte_threshold);
3097        wal.sync()?;
3098        self.wal = WalSink::Private(wal);
3099        Ok(())
3100    }
3101
3102    /// Fine-grained result-cache invalidation (hardening (c)): drop only
3103    /// entries whose footprint intersects a deleted RowId or whose
3104    /// condition-columns intersect a mutated column, then clear the pending
3105    /// sets. Called by `commit` and the cross-table transaction path.
3106    pub(crate) fn invalidate_pending_cache(&mut self) {
3107        self.result_cache
3108            .lock()
3109            .invalidate(&self.pending_delete_rids, &self.pending_put_cols);
3110        self.pending_delete_rids.clear();
3111        self.pending_put_cols.clear();
3112    }
3113
3114    pub(crate) fn persist_manifest(&self, epoch: Epoch) -> Result<()> {
3115        let mut m = Manifest::new(self.table_id, self.schema.schema_id);
3116        m.current_epoch = epoch.0;
3117        m.next_row_id = self.allocator.current().0;
3118        m.runs = self.run_refs.clone();
3119        m.live_count = self.live_count;
3120        m.global_idx_epoch = self.global_idx_epoch;
3121        m.flushed_epoch = self.flushed_epoch;
3122        m.retiring = self.retiring.clone();
3123        // Persist the authoritative counter only when seeded; otherwise write 0
3124        // so the next open still scans `max(PK)` on first use (an unseeded
3125        // lower bound from WAL replay is not safe to trust across a flush).
3126        m.auto_inc_next = match self.auto_inc {
3127            Some(ai) if ai.seeded => ai.next,
3128            _ => 0,
3129        };
3130        let meta_dek = self.manifest_meta_dek();
3131        manifest::write_atomic(&self.dir, &mut m, meta_dek.as_ref())?;
3132        Ok(())
3133    }
3134
3135    /// Checkpoint the in-memory secondary indexes to `_idx/global.idx` and stamp
3136    /// the manifest's `global_idx_epoch` (Phase 9.1). Call after the runs are
3137    /// stable and the memtable is drained (flush/bulk-load/compact) so the
3138    /// checkpoint exactly matches the run data; subsequent [`Table::open`] loads it
3139    /// directly instead of scanning every run.
3140    pub(crate) fn checkpoint_indexes(&mut self, epoch: Epoch) {
3141        // Never persist an incomplete index set (e.g. after bulk_load_columns,
3142        // which bypasses per-row indexing) — reopen rebuilds from the runs.
3143        if !self.indexes_complete {
3144            return;
3145        }
3146        let snap = global_idx::IndexSnapshot {
3147            hot: &self.hot,
3148            bitmap: &self.bitmap,
3149            ann: &self.ann,
3150            fm: &self.fm,
3151            sparse: &self.sparse,
3152            minhash: &self.minhash,
3153            learned_range: &self.learned_range,
3154        };
3155        // Best-effort: a failed checkpoint just means the next open rebuilds.
3156        let idx_dek = self.idx_dek();
3157        if global_idx::write_atomic(&self.dir, self.table_id, epoch.0, snap, idx_dek.as_deref())
3158            .is_ok()
3159        {
3160            self.global_idx_epoch = epoch.0;
3161            let _ = self.persist_manifest(epoch);
3162        }
3163    }
3164
3165    /// Drop any on-disk index checkpoint so the next open rebuilds from runs
3166    /// (used when the live indexes are known stale, e.g. compaction to empty).
3167    pub(crate) fn invalidate_index_checkpoint(&mut self) {
3168        self.global_idx_epoch = 0;
3169        global_idx::remove(&self.dir);
3170        let _ = self.persist_manifest(self.epoch.visible());
3171    }
3172
3173    /// Read the row at `row_id` visible to `snapshot`, merging the newest
3174    /// version across the memtable and all sorted runs.
3175    pub fn get(&self, row_id: RowId, snapshot: Snapshot) -> Option<Row> {
3176        let mut best: Option<(Epoch, Row)> = self.memtable.get_version(row_id, snapshot.epoch);
3177        if let Some((epoch, row)) = self.mutable_run.get_version(row_id, snapshot.epoch) {
3178            if best.as_ref().map(|(be, _)| epoch > *be).unwrap_or(true) {
3179                best = Some((epoch, row));
3180            }
3181        }
3182        for rr in &self.run_refs {
3183            let Ok(mut reader) = self.open_reader(rr.run_id) else {
3184                continue;
3185            };
3186            let Ok(Some((epoch, row))) = reader.get_version(row_id, snapshot.epoch) else {
3187                continue;
3188            };
3189            if best.as_ref().map(|(be, _)| epoch > *be).unwrap_or(true) {
3190                best = Some((epoch, row));
3191            }
3192        }
3193        match best {
3194            Some((_, r)) if r.deleted => None,
3195            Some((_, r)) => Some(r),
3196            None => None,
3197        }
3198    }
3199
3200    /// All rows visible at `snapshot` (newest version per `RowId`, tombstones
3201    /// dropped), merged across the memtable, the mutable-run tier, and all
3202    /// runs. Ascending `RowId`.
3203    pub fn visible_rows(&self, snapshot: Snapshot) -> Result<Vec<Row>> {
3204        let mut best: HashMap<u64, (Epoch, Row)> = HashMap::new();
3205        let mut fold = |row: Row| {
3206            best.entry(row.row_id.0)
3207                .and_modify(|e| {
3208                    if row.committed_epoch > e.0 {
3209                        *e = (row.committed_epoch, row.clone());
3210                    }
3211                })
3212                .or_insert_with(|| (row.committed_epoch, row));
3213        };
3214        for row in self.memtable.visible_versions(snapshot.epoch) {
3215            fold(row);
3216        }
3217        for row in self.mutable_run.visible_versions(snapshot.epoch) {
3218            fold(row);
3219        }
3220        for rr in &self.run_refs {
3221            let mut reader = self.open_reader(rr.run_id)?;
3222            for row in reader.visible_versions(snapshot.epoch)? {
3223                fold(row);
3224            }
3225        }
3226        let mut out: Vec<Row> = best
3227            .into_values()
3228            .filter_map(|(_, r)| if r.deleted { None } else { Some(r) })
3229            .collect();
3230        out.sort_by_key(|r| r.row_id);
3231        Ok(out)
3232    }
3233
3234    /// Visible data as columns (column_id → values) rather than rows — the
3235    /// vectorized scan path. Fast path: when the memtable is empty and there is
3236    /// exactly one run (the common post-flush analytical case), it computes the
3237    /// visible index set once and gathers each column, with **no per-row
3238    /// `HashMap`/`Row` materialization**. Falls back to [`Self::visible_rows`]
3239    /// pivoted to columns when the memtable is live or runs overlap.
3240    pub fn visible_columns(&self, snapshot: Snapshot) -> Result<Vec<(u16, Vec<Value>)>> {
3241        if self.memtable.is_empty() && self.mutable_run.is_empty() && self.run_refs.len() == 1 {
3242            let rr = self.run_refs[0].clone();
3243            let mut reader = self.open_reader(rr.run_id)?;
3244            let idxs = reader.visible_indices(snapshot.epoch)?;
3245            let mut cols = Vec::with_capacity(self.schema.columns.len());
3246            for cdef in &self.schema.columns {
3247                cols.push((cdef.id, reader.gather_column(cdef.id, &idxs)?));
3248            }
3249            return Ok(cols);
3250        }
3251        // Fallback: row merge, then pivot to columns.
3252        let rows = self.visible_rows(snapshot)?;
3253        let mut cols: Vec<(u16, Vec<Value>)> = self
3254            .schema
3255            .columns
3256            .iter()
3257            .map(|c| (c.id, Vec::with_capacity(rows.len())))
3258            .collect();
3259        for r in &rows {
3260            for (cid, vec) in cols.iter_mut() {
3261                vec.push(r.columns.get(cid).cloned().unwrap_or(Value::Null));
3262            }
3263        }
3264        Ok(cols)
3265    }
3266
3267    /// Resolve a primary-key value to a row id (latest version).
3268    pub fn lookup_pk(&self, key: &[u8]) -> Option<RowId> {
3269        self.hot.get(key)
3270    }
3271
3272    /// Run a conjunctive query over the shared row-id space: each condition
3273    /// yields a candidate row-id set, the sets are intersected, and the
3274    /// survivors are materialized at the current snapshot. This is the AI-native
3275    /// "compose primitives" surface (`semsearch ∩ fm_contains ∩ cat_in`).
3276    pub fn query(&mut self, q: &crate::query::Query) -> Result<Vec<Row>> {
3277        self.require_select()?;
3278        self.ensure_indexes_complete()?;
3279        let snapshot = self.snapshot();
3280        crate::trace::QueryTrace::record(|t| {
3281            t.run_count = self.run_refs.len();
3282            t.memtable_rows = self.memtable.len();
3283            t.mutable_run_rows = self.mutable_run.len();
3284        });
3285        // A conjunction with no predicates matches every visible row (the
3286        // documented "Empty ⇒ all rows" contract); `intersect_sets` of zero
3287        // sets would otherwise wrongly yield the empty set.
3288        if q.conditions.is_empty() {
3289            crate::trace::QueryTrace::record(|t| {
3290                t.scan_mode = crate::trace::ScanMode::Materialized;
3291                t.row_materialized = true;
3292            });
3293            return self.visible_rows(snapshot);
3294        }
3295        crate::trace::QueryTrace::record(|t| {
3296            t.conditions_pushed = q.conditions.len();
3297            t.scan_mode = crate::trace::ScanMode::Materialized;
3298            t.row_materialized = true;
3299        });
3300        // §5.5: resolve conditions CHEAP-FIRST and early-exit the moment a
3301        // condition yields an empty survivor set. Previously every condition
3302        // (including an expensive range/FM page scan) was resolved before
3303        // `intersect_many` noticed an empty set; now a selective bitmap/PK that
3304        // eliminates all rows short-circuits the rest. Correctness is unchanged
3305        // (intersection with an empty set is empty either way).
3306        let mut ordered: Vec<&crate::query::Condition> = q.conditions.iter().collect();
3307        ordered.sort_by_key(|c| condition_cost_rank(c));
3308        let mut sets: Vec<RowIdSet> = Vec::with_capacity(ordered.len());
3309        for c in &ordered {
3310            let s = self.resolve_condition(c, snapshot)?;
3311            let empty = s.is_empty();
3312            sets.push(s);
3313            if empty {
3314                break;
3315            }
3316        }
3317        let rids = RowIdSet::intersect_many(sets).into_sorted_vec();
3318        self.rows_for_rids(&rids, snapshot)
3319    }
3320
3321    /// Materialize the MVCC-visible, non-deleted rows for `rids` at `snapshot`,
3322    /// preserving the input order. Rows whose newest visible version is a
3323    /// tombstone, or that no longer exist, are omitted. Shared by index-served
3324    /// [`query`] and the Phase 8.1 FK-join path.
3325    pub fn rows_for_rids(&self, rids: &[u64], snapshot: Snapshot) -> Result<Vec<Row>> {
3326        use std::collections::HashMap;
3327        let mut rows = Vec::with_capacity(rids.len());
3328        // Overlay (memtable + mutable-run) newest visible version per rid —
3329        // these shadow any stale version stored in a run. A rid may have an
3330        // older version in the mutable-run tier and a newer one in the memtable
3331        // (an update after a flush), so keep the **newest by epoch** across both
3332        // tiers, not whichever is inserted last.
3333        //
3334        // `rids` is already index-resolved (the caller's condition set), so it
3335        // is normally tiny relative to the memtable/mutable-run tiers — a
3336        // single-row PK/unique check feeding insert/update/delete resolves to
3337        // 0 or 1 rid. Materializing every version in both tiers (the old
3338        // behavior) cost O(tier size) regardless, which meant an unrelated
3339        // full-table-sized scan (plus the drop cost of the resulting map) on
3340        // every point lookup once the table grew large. Below the crossover,
3341        // a direct per-rid probe (`get_version`, O(log tier size) each) wins;
3342        // once `rids` approaches tier size, one linear materializing pass
3343        // beats `rids.len()` separate probes, so fall back to it.
3344        let tier_size = self.memtable.len() + self.mutable_run.len();
3345        let mut overlay: HashMap<u64, Row> = HashMap::with_capacity(rids.len());
3346        if rids.len().saturating_mul(24) < tier_size {
3347            for &rid in rids {
3348                let mem = self.memtable.get_version(RowId(rid), snapshot.epoch);
3349                let mrun = self.mutable_run.get_version(RowId(rid), snapshot.epoch);
3350                let newest = match (mem, mrun) {
3351                    (Some((me, mr)), Some((re, rr))) => Some(if me >= re { mr } else { rr }),
3352                    (Some((_, mr)), None) => Some(mr),
3353                    (None, Some((_, rr))) => Some(rr),
3354                    (None, None) => None,
3355                };
3356                if let Some(row) = newest {
3357                    overlay.insert(rid, row);
3358                }
3359            }
3360        } else {
3361            let fold_newest = |row: Row, overlay: &mut HashMap<u64, Row>| {
3362                overlay
3363                    .entry(row.row_id.0)
3364                    .and_modify(|e| {
3365                        if row.committed_epoch > e.committed_epoch {
3366                            *e = row.clone();
3367                        }
3368                    })
3369                    .or_insert(row);
3370            };
3371            for row in self.memtable.visible_versions(snapshot.epoch) {
3372                fold_newest(row, &mut overlay);
3373            }
3374            for row in self.mutable_run.visible_versions(snapshot.epoch) {
3375                fold_newest(row, &mut overlay);
3376            }
3377        }
3378        if self.run_refs.len() == 1 {
3379            let mut reader = self.open_reader(self.run_refs[0].run_id)?;
3380            // Same crossover as the overlay above: `visible_positions_with_rids`
3381            // decodes/scans the run's *entire* row-id column regardless of
3382            // `rids.len()`, so a point lookup (0 or 1 rid, the common
3383            // insert/update/delete case) paid an O(run size) tax for a single
3384            // row. Below the crossover, `get_version`'s page-pruned lookup
3385            // (`SYS_ROW_ID` pages carry exact row-id bounds) resolves each rid
3386            // by decoding only its page, no whole-column decode.
3387            if rids.len().saturating_mul(24) < reader.row_count() {
3388                for &rid in rids {
3389                    if let Some(r) = overlay.get(&rid) {
3390                        if !r.deleted {
3391                            rows.push(r.clone());
3392                        }
3393                        continue;
3394                    }
3395                    if let Some((_, row)) = reader.get_version(RowId(rid), snapshot.epoch)? {
3396                        if !row.deleted {
3397                            rows.push(row);
3398                        }
3399                    }
3400                }
3401                return Ok(rows);
3402            }
3403            // Phase 16.3b: decode the system columns ONCE (via the clean-run-
3404            // shortcut visibility pass) and binary-search each requested rid,
3405            // instead of `get_version`-per-rid which re-decoded + cloned the
3406            // full system columns on every call (the ~350 ms native-query tax).
3407            // Phase 16.3b finish: batch the survivor positions into ONE
3408            // `materialize_batch` call so user columns are decoded once each via
3409            // the typed, page-cached path (not a per-rid `Vec<Value>` decode +
3410            // `.cloned()`).
3411            let (positions, vis_rids) = reader.visible_positions_with_rids(snapshot.epoch)?;
3412            // First pass: classify each input rid (overlay / run position /
3413            // not-found), recording the run positions to fetch in input order.
3414            enum Src {
3415                Overlay,
3416                Run,
3417            }
3418            let mut plan: Vec<Src> = Vec::with_capacity(rids.len());
3419            let mut fetch: Vec<usize> = Vec::with_capacity(rids.len());
3420            for rid in rids {
3421                if overlay.contains_key(rid) {
3422                    plan.push(Src::Overlay);
3423                    continue;
3424                }
3425                match vis_rids.binary_search(&(*rid as i64)) {
3426                    Ok(i) => {
3427                        plan.push(Src::Run);
3428                        fetch.push(positions[i]);
3429                    }
3430                    Err(_) => { /* not found — omitted from output */ }
3431                }
3432            }
3433            let fetched = reader.materialize_batch(&fetch)?;
3434            let mut fetched_iter = fetched.into_iter();
3435            for (rid, src) in rids.iter().zip(plan) {
3436                match src {
3437                    Src::Overlay => {
3438                        if let Some(r) = overlay.get(rid) {
3439                            if !r.deleted {
3440                                rows.push(r.clone());
3441                            }
3442                        }
3443                    }
3444                    Src::Run => {
3445                        if let Some(row) = fetched_iter.next() {
3446                            if !row.deleted {
3447                                rows.push(row);
3448                            }
3449                        }
3450                    }
3451                }
3452            }
3453            return Ok(rows);
3454        }
3455        // Multi-run: one reader per run; newest visible version across all runs
3456        // + the overlay. (Per-rid `get_version` here is unavoidable without a
3457        // cross-run merge, but multi-run is the uncommon cold case.)
3458        let mut readers: Vec<_> = self
3459            .run_refs
3460            .iter()
3461            .map(|rr| self.open_reader(rr.run_id))
3462            .collect::<Result<Vec<_>>>()?;
3463        for rid in rids {
3464            if let Some(r) = overlay.get(rid) {
3465                if !r.deleted {
3466                    rows.push(r.clone());
3467                }
3468                continue;
3469            }
3470            let mut best: Option<(Epoch, Row)> = None;
3471            for reader in readers.iter_mut() {
3472                if let Ok(Some((epoch, row))) = reader.get_version(RowId(*rid), snapshot.epoch) {
3473                    if best.as_ref().map(|(be, _)| epoch > *be).unwrap_or(true) {
3474                        best = Some((epoch, row));
3475                    }
3476                }
3477            }
3478            if let Some((_, r)) = best {
3479                if !r.deleted {
3480                    rows.push(r);
3481                }
3482            }
3483        }
3484        Ok(rows)
3485    }
3486
3487    /// Resolve the referencing (FK) side of a primary-key ↔ foreign-key join as
3488    /// a row-id set (Phase 8.1): union the roaring-bitmap entries of
3489    /// `fk_column_id` for every value in `pk_values` — the surviving
3490    /// primary-key values — then intersect with `fk_conditions`, i.e. any
3491    /// FK-side predicates (`ann_search ∩ fm_contains`, bitmap equality, range,
3492    /// …). Returns the survivor row-ids ascending. Requires a bitmap index on
3493    /// `fk_column_id`; returns an empty set when there is none.
3494    /// Whether live indexes are complete (Phase 14.7 + 17.2: the broadcast
3495    /// join path checks this before using the HOT index).
3496    pub fn indexes_complete(&self) -> bool {
3497        self.indexes_complete
3498    }
3499
3500    /// Where bulk loads put the index-build cost (see [`IndexBuildPolicy`]).
3501    pub fn index_build_policy(&self) -> IndexBuildPolicy {
3502        self.index_build_policy
3503    }
3504
3505    /// Set the bulk-load index-build policy. Takes effect on the next
3506    /// `bulk_load` / `bulk_load_columns` / `bulk_load_fast`; never changes
3507    /// already-built indexes.
3508    pub fn set_index_build_policy(&mut self, policy: IndexBuildPolicy) {
3509        self.index_build_policy = policy;
3510    }
3511
3512    /// Phase 17.2: broadcast join — return the distinct values in this table's
3513    /// bitmap index for `column_id` that also exist as a key in `pk_db`'s HOT
3514    /// index. Avoids loading the entire PK table when the FK column has low
3515    /// cardinality. Returns `None` if no bitmap index exists for the column.
3516    pub fn broadcast_join_values(&self, column_id: u16, pk_db: &Table) -> Option<Vec<Vec<u8>>> {
3517        // A deferred bulk load leaves the bitmap unbuilt — its (empty) key set
3518        // would silently produce an empty join. Decline; the caller falls back
3519        // to the PK-side query path, which completes indexes lazily.
3520        if !self.indexes_complete {
3521            return None;
3522        }
3523        let b = self.bitmap.get(&column_id)?;
3524        let result: Vec<Vec<u8>> = b
3525            .keys()
3526            .into_iter()
3527            .filter(|k| pk_db.hot.get(k.as_slice()).is_some())
3528            .cloned()
3529            .collect();
3530        Some(result)
3531    }
3532
3533    pub fn fk_join_row_ids(
3534        &self,
3535        fk_column_id: u16,
3536        pk_values: &[Vec<u8>],
3537        fk_conditions: &[crate::query::Condition],
3538        snapshot: Snapshot,
3539    ) -> Result<Vec<u64>> {
3540        let Some(b) = self.bitmap.get(&fk_column_id) else {
3541            return Ok(Vec::new());
3542        };
3543        let mut join_set = {
3544            let mut acc = roaring::RoaringBitmap::new();
3545            for v in pk_values {
3546                acc |= b.get(v);
3547            }
3548            RowIdSet::from_roaring(acc)
3549        };
3550        if !fk_conditions.is_empty() {
3551            let mut sets: Vec<RowIdSet> = Vec::with_capacity(fk_conditions.len() + 1);
3552            sets.push(join_set);
3553            for c in fk_conditions {
3554                sets.push(self.resolve_condition(c, snapshot)?);
3555            }
3556            join_set = RowIdSet::intersect_many(sets);
3557        }
3558        Ok(join_set.into_sorted_vec())
3559    }
3560
3561    /// Like [`fk_join_row_ids`] but returns only the **cardinality** of the FK
3562    /// survivor set — without materializing or sorting it. For a bare
3563    /// `COUNT(*)` join with no FK-side filter this is O(1) on the bitmap union
3564    /// (Phase 17.4): the prior path built a `HashSet<u64>` + `Vec<u64>` +
3565    /// `sort_unstable` over up to N rows only to read `.len()`.
3566    pub fn fk_join_count(
3567        &self,
3568        fk_column_id: u16,
3569        pk_values: &[Vec<u8>],
3570        fk_conditions: &[crate::query::Condition],
3571        snapshot: Snapshot,
3572    ) -> Result<u64> {
3573        let Some(b) = self.bitmap.get(&fk_column_id) else {
3574            return Ok(0);
3575        };
3576        let mut acc = roaring::RoaringBitmap::new();
3577        for v in pk_values {
3578            acc |= b.get(v);
3579        }
3580        if fk_conditions.is_empty() {
3581            return Ok(acc.len());
3582        }
3583        let mut sets: Vec<RowIdSet> = Vec::with_capacity(fk_conditions.len() + 1);
3584        sets.push(RowIdSet::from_roaring(acc));
3585        for c in fk_conditions {
3586            sets.push(self.resolve_condition(c, snapshot)?);
3587        }
3588        Ok(RowIdSet::intersect_many(sets).len() as u64)
3589    }
3590
3591    /// Resolve a single condition to its row-id set. Index-served conditions use
3592    /// the in-memory indexes; `Range`/`RangeF64` prefer the learned (PGM) index
3593    /// or the reader's page-index-skipping path on the single-run fast path, and
3594    /// only fall back to a `visible_rows` scan off the fast path (multi-run).
3595    fn resolve_condition(
3596        &self,
3597        c: &crate::query::Condition,
3598        snapshot: Snapshot,
3599    ) -> Result<RowIdSet> {
3600        use crate::query::Condition;
3601        Ok(match c {
3602            Condition::Pk(key) => {
3603                let lookup = self
3604                    .schema
3605                    .primary_key()
3606                    .map(|pk| self.index_lookup_key_bytes(pk.id, key))
3607                    .unwrap_or_else(|| key.clone());
3608                self.hot
3609                    .get(&lookup)
3610                    .map(|r| RowIdSet::one(r.0))
3611                    .unwrap_or_else(RowIdSet::empty)
3612            }
3613            Condition::BitmapEq { column_id, value } => {
3614                let lookup = self.index_lookup_key_bytes(*column_id, value);
3615                self.bitmap
3616                    .get(column_id)
3617                    .map(|b| RowIdSet::from_roaring(b.get(&lookup)))
3618                    .unwrap_or_else(RowIdSet::empty)
3619            }
3620            Condition::BitmapIn { column_id, values } => {
3621                let bm = self.bitmap.get(column_id);
3622                let mut acc = roaring::RoaringBitmap::new();
3623                if let Some(b) = bm {
3624                    for v in values {
3625                        let lookup = self.index_lookup_key_bytes(*column_id, v);
3626                        acc |= b.get(&lookup);
3627                    }
3628                }
3629                RowIdSet::from_roaring(acc)
3630            }
3631            Condition::BytesPrefix { column_id, prefix } => {
3632                // §5.6: enumerate bitmap keys sharing the prefix for an exact
3633                // prefix match (anchored `LIKE 'prefix%'`), tighter than the
3634                // FM substring superset. The caller only emits this when the
3635                // column has a bitmap index.
3636                if let Some(b) = self.bitmap.get(column_id) {
3637                    let lookup_prefix = self.index_lookup_key_bytes(*column_id, prefix);
3638                    let mut acc = roaring::RoaringBitmap::new();
3639                    for key in b.keys() {
3640                        if key.starts_with(&lookup_prefix) {
3641                            acc |= b.get(key);
3642                        }
3643                    }
3644                    RowIdSet::from_roaring(acc)
3645                } else {
3646                    RowIdSet::empty()
3647                }
3648            }
3649            Condition::FmContains { column_id, pattern } => self
3650                .fm
3651                .get(column_id)
3652                .map(|f| {
3653                    RowIdSet::from_unsorted(f.locate(pattern).into_iter().map(|r| r.0).collect())
3654                })
3655                .unwrap_or_else(RowIdSet::empty),
3656            Condition::FmContainsAll {
3657                column_id,
3658                patterns,
3659            } => {
3660                // Multi-segment intersection (Priority 12): resolve each segment
3661                // via FM and intersect — much tighter than the single longest.
3662                if let Some(f) = self.fm.get(column_id) {
3663                    let sets: Vec<RowIdSet> = patterns
3664                        .iter()
3665                        .map(|pat| {
3666                            RowIdSet::from_unsorted(
3667                                f.locate(pat).into_iter().map(|r| r.0).collect(),
3668                            )
3669                        })
3670                        .collect();
3671                    RowIdSet::intersect_many(sets)
3672                } else {
3673                    RowIdSet::empty()
3674                }
3675            }
3676            Condition::Ann {
3677                column_id,
3678                query,
3679                k,
3680            } => self
3681                .ann
3682                .get(column_id)
3683                .map(|a| {
3684                    RowIdSet::from_unsorted(
3685                        a.search(query, *k).into_iter().map(|(r, _)| r.0).collect(),
3686                    )
3687                })
3688                .unwrap_or_else(RowIdSet::empty),
3689            Condition::SparseMatch {
3690                column_id,
3691                query,
3692                k,
3693            } => self
3694                .sparse
3695                .get(column_id)
3696                .map(|s| {
3697                    RowIdSet::from_unsorted(
3698                        s.search(query, *k).into_iter().map(|(r, _)| r.0).collect(),
3699                    )
3700                })
3701                .unwrap_or_else(RowIdSet::empty),
3702            Condition::MinHashSimilar {
3703                column_id,
3704                query,
3705                k,
3706            } => self
3707                .minhash
3708                .get(column_id)
3709                .map(|mh| {
3710                    RowIdSet::from_unsorted(
3711                        mh.search(query, *k).into_iter().map(|(r, _)| r.0).collect(),
3712                    )
3713                })
3714                .unwrap_or_else(RowIdSet::empty),
3715            Condition::Range { column_id, lo, hi } => {
3716                // Build the candidate set from the durable tier — the learned
3717                // index (built from sorted runs) or a single page-pruned run —
3718                // then merge the memtable/mutable-run overlay. An overlay row
3719                // supersedes its run version (it may have been updated out of
3720                // range or deleted), so overlay rids are dropped from the run
3721                // set and re-evaluated from the overlay directly. Without this
3722                // merge, rows still in the memtable are invisible to a ranged
3723                // read whenever a LearnedRange index is present.
3724                let mut set = if let Some(li) = self.learned_range.get(column_id) {
3725                    RowIdSet::from_unsorted(li.range(*lo, *hi).into_iter().collect())
3726                } else if self.run_refs.len() == 1 {
3727                    let mut r = self.open_reader(self.run_refs[0].run_id)?;
3728                    r.range_row_id_set_i64(*column_id, *lo, *hi)?
3729                } else {
3730                    return self.range_scan_i64(*column_id, *lo, *hi, snapshot);
3731                };
3732                set.remove_many(self.overlay_rid_set(snapshot));
3733                self.range_scan_overlay_i64(&mut set, *column_id, *lo, *hi, snapshot);
3734                set
3735            }
3736            Condition::RangeF64 {
3737                column_id,
3738                lo,
3739                lo_inclusive,
3740                hi,
3741                hi_inclusive,
3742            } => {
3743                // See the `Range` arm: merge the overlay over the durable
3744                // candidate set so memtable/mutable-run rows are visible.
3745                let mut set = if let Some(li) = self.learned_range.get(column_id) {
3746                    RowIdSet::from_unsorted(
3747                        li.range_f64(*lo, *lo_inclusive, *hi, *hi_inclusive)
3748                            .into_iter()
3749                            .collect(),
3750                    )
3751                } else if self.run_refs.len() == 1 {
3752                    let mut r = self.open_reader(self.run_refs[0].run_id)?;
3753                    r.range_row_id_set_f64(*column_id, *lo, *lo_inclusive, *hi, *hi_inclusive)?
3754                } else {
3755                    return self.range_scan_f64(
3756                        *column_id,
3757                        *lo,
3758                        *lo_inclusive,
3759                        *hi,
3760                        *hi_inclusive,
3761                        snapshot,
3762                    );
3763                };
3764                set.remove_many(self.overlay_rid_set(snapshot));
3765                self.range_scan_overlay_f64(
3766                    &mut set,
3767                    *column_id,
3768                    *lo,
3769                    *lo_inclusive,
3770                    *hi,
3771                    *hi_inclusive,
3772                    snapshot,
3773                );
3774                set
3775            }
3776            Condition::IsNull { column_id } => {
3777                let mut set = if self.run_refs.len() == 1 {
3778                    let mut r = self.open_reader(self.run_refs[0].run_id)?;
3779                    r.null_row_id_set(*column_id, true)?
3780                } else {
3781                    return self.null_scan(*column_id, true, snapshot);
3782                };
3783                set.remove_many(self.overlay_rid_set(snapshot));
3784                self.null_scan_overlay(&mut set, *column_id, true, snapshot);
3785                set
3786            }
3787            Condition::IsNotNull { column_id } => {
3788                let mut set = if self.run_refs.len() == 1 {
3789                    let mut r = self.open_reader(self.run_refs[0].run_id)?;
3790                    r.null_row_id_set(*column_id, false)?
3791                } else {
3792                    return self.null_scan(*column_id, false, snapshot);
3793                };
3794                set.remove_many(self.overlay_rid_set(snapshot));
3795                self.null_scan_overlay(&mut set, *column_id, false, snapshot);
3796                set
3797            }
3798        })
3799    }
3800
3801    /// Vectorized range scan for Int64 columns (Phase 13.2 / 16.3). Resolves the
3802    /// survivor set via the reader's **page-pruned** path — pages whose `[min,max]`
3803    /// excludes `[lo,hi]` are never decoded — restricted to MVCC-visible rows.
3804    /// This is layout-independent: correct under any memtable / multi-run state,
3805    /// so it is always safe to call (no "single clean run" gate). Overlay rows
3806    /// (memtable / mutable-run) are excluded from the run portion and checked
3807    /// directly via [`Self::range_scan_overlay_i64`].
3808    fn range_scan_i64(
3809        &self,
3810        column_id: u16,
3811        lo: i64,
3812        hi: i64,
3813        snapshot: Snapshot,
3814    ) -> Result<RowIdSet> {
3815        let mut row_ids = Vec::new();
3816        let overlay_rids = self.overlay_rid_set(snapshot);
3817        for rr in &self.run_refs {
3818            let mut reader = self.open_reader(rr.run_id)?;
3819            let matched = reader.range_row_ids_visible_i64(column_id, lo, hi, snapshot.epoch)?;
3820            for rid in matched {
3821                if !overlay_rids.contains(&rid) {
3822                    row_ids.push(rid);
3823                }
3824            }
3825        }
3826        let mut s = RowIdSet::from_unsorted(row_ids);
3827        self.range_scan_overlay_i64(&mut s, column_id, lo, hi, snapshot);
3828        Ok(s)
3829    }
3830
3831    /// Float64 analogue of [`Self::range_scan_i64`] with per-bound inclusivity
3832    /// (Phase 13.2 / 16.3).
3833    fn range_scan_f64(
3834        &self,
3835        column_id: u16,
3836        lo: f64,
3837        lo_inclusive: bool,
3838        hi: f64,
3839        hi_inclusive: bool,
3840        snapshot: Snapshot,
3841    ) -> Result<RowIdSet> {
3842        let mut row_ids = Vec::new();
3843        let overlay_rids = self.overlay_rid_set(snapshot);
3844        for rr in &self.run_refs {
3845            let mut reader = self.open_reader(rr.run_id)?;
3846            let matched = reader.range_row_ids_visible_f64(
3847                column_id,
3848                lo,
3849                lo_inclusive,
3850                hi,
3851                hi_inclusive,
3852                snapshot.epoch,
3853            )?;
3854            for rid in matched {
3855                if !overlay_rids.contains(&rid) {
3856                    row_ids.push(rid);
3857                }
3858            }
3859        }
3860        let mut s = RowIdSet::from_unsorted(row_ids);
3861        self.range_scan_overlay_f64(
3862            &mut s,
3863            column_id,
3864            lo,
3865            lo_inclusive,
3866            hi,
3867            hi_inclusive,
3868            snapshot,
3869        );
3870        Ok(s)
3871    }
3872
3873    /// Collect the set of row-ids visible in the memtable / mutable-run overlay.
3874    fn overlay_rid_set(&self, snapshot: Snapshot) -> HashSet<u64> {
3875        let mut s = HashSet::new();
3876        for row in self.memtable.visible_versions(snapshot.epoch) {
3877            s.insert(row.row_id.0);
3878        }
3879        for row in self.mutable_run.visible_versions(snapshot.epoch) {
3880            s.insert(row.row_id.0);
3881        }
3882        s
3883    }
3884
3885    fn range_scan_overlay_i64(
3886        &self,
3887        s: &mut RowIdSet,
3888        column_id: u16,
3889        lo: i64,
3890        hi: i64,
3891        snapshot: Snapshot,
3892    ) {
3893        // Collapse both overlay tiers to the newest visible version per row id
3894        // (the memtable supersedes the mutable run) before range-checking, so a
3895        // stale in-range mutable-run version cannot shadow a newer out-of-range
3896        // memtable version of the same row.
3897        let mut newest: HashMap<u64, &Row> = HashMap::new();
3898        let mutable = self.mutable_run.visible_versions(snapshot.epoch);
3899        let memtable = self.memtable.visible_versions(snapshot.epoch);
3900        for r in &mutable {
3901            newest.entry(r.row_id.0).or_insert(r);
3902        }
3903        for r in &memtable {
3904            newest.insert(r.row_id.0, r);
3905        }
3906        for row in newest.values() {
3907            if !row.deleted {
3908                if let Some(Value::Int64(v)) = row.columns.get(&column_id) {
3909                    if *v >= lo && *v <= hi {
3910                        s.insert(row.row_id.0);
3911                    }
3912                }
3913            }
3914        }
3915    }
3916
3917    #[allow(clippy::too_many_arguments)]
3918    fn range_scan_overlay_f64(
3919        &self,
3920        s: &mut RowIdSet,
3921        column_id: u16,
3922        lo: f64,
3923        lo_inclusive: bool,
3924        hi: f64,
3925        hi_inclusive: bool,
3926        snapshot: Snapshot,
3927    ) {
3928        // See `range_scan_overlay_i64`: dedup to the newest version per row id
3929        // across the memtable + mutable run before range-checking.
3930        let mut newest: HashMap<u64, &Row> = HashMap::new();
3931        let mutable = self.mutable_run.visible_versions(snapshot.epoch);
3932        let memtable = self.memtable.visible_versions(snapshot.epoch);
3933        for r in &mutable {
3934            newest.entry(r.row_id.0).or_insert(r);
3935        }
3936        for r in &memtable {
3937            newest.insert(r.row_id.0, r);
3938        }
3939        for row in newest.values() {
3940            if !row.deleted {
3941                if let Some(Value::Float64(v)) = row.columns.get(&column_id) {
3942                    let ok_lo = if lo_inclusive { *v >= lo } else { *v > lo };
3943                    let ok_hi = if hi_inclusive { *v <= hi } else { *v < hi };
3944                    if ok_lo && ok_hi {
3945                        s.insert(row.row_id.0);
3946                    }
3947                }
3948            }
3949        }
3950    }
3951
3952    /// Multi-run fallback for `IS NULL` / `IS NOT NULL`. Calls each run's
3953    /// MVCC-aware null scan and merges with the overlay.
3954    fn null_scan(&self, column_id: u16, want_nulls: bool, snapshot: Snapshot) -> Result<RowIdSet> {
3955        let mut row_ids = Vec::new();
3956        let overlay_rids = self.overlay_rid_set(snapshot);
3957        for rr in &self.run_refs {
3958            let mut reader = self.open_reader(rr.run_id)?;
3959            let matched = reader.null_row_ids_visible(column_id, want_nulls, snapshot.epoch)?;
3960            for rid in matched {
3961                if !overlay_rids.contains(&rid) {
3962                    row_ids.push(rid);
3963                }
3964            }
3965        }
3966        let mut s = RowIdSet::from_unsorted(row_ids);
3967        self.null_scan_overlay(&mut s, column_id, want_nulls, snapshot);
3968        Ok(s)
3969    }
3970
3971    /// Merge overlay rows for `IS NULL` / `IS NOT NULL`. An overlay row
3972    /// supersedes its run version, so overlay rids are removed from the run
3973    /// set and re-evaluated from the overlay values directly.
3974    fn null_scan_overlay(
3975        &self,
3976        s: &mut RowIdSet,
3977        column_id: u16,
3978        want_nulls: bool,
3979        snapshot: Snapshot,
3980    ) {
3981        let mut newest: HashMap<u64, &Row> = HashMap::new();
3982        let mutable = self.mutable_run.visible_versions(snapshot.epoch);
3983        let memtable = self.memtable.visible_versions(snapshot.epoch);
3984        for r in &mutable {
3985            newest.entry(r.row_id.0).or_insert(r);
3986        }
3987        for r in &memtable {
3988            newest.insert(r.row_id.0, r);
3989        }
3990        for row in newest.values() {
3991            if row.deleted {
3992                continue;
3993            }
3994            let is_null = !row.columns.contains_key(&column_id)
3995                || matches!(row.columns.get(&column_id), Some(Value::Null) | None);
3996            if is_null == want_nulls {
3997                s.insert(row.row_id.0);
3998            }
3999        }
4000    }
4001
4002    pub fn snapshot(&self) -> Snapshot {
4003        Snapshot::at(self.epoch.visible())
4004    }
4005
4006    /// Pin the current epoch as a read snapshot; compaction will preserve the
4007    /// versions it needs until [`Table::unpin_snapshot`] is called.
4008    pub fn pin_snapshot(&mut self) -> Snapshot {
4009        let e = self.epoch.visible();
4010        *self.pinned.entry(e).or_insert(0) += 1;
4011        Snapshot::at(e)
4012    }
4013
4014    /// Release a pinned snapshot.
4015    pub fn unpin_snapshot(&mut self, snap: Snapshot) {
4016        if let Some(count) = self.pinned.get_mut(&snap.epoch) {
4017            *count -= 1;
4018            if *count == 0 {
4019                self.pinned.remove(&snap.epoch);
4020            }
4021        }
4022    }
4023
4024    /// Oldest pinned snapshot epoch, or `None` if no snapshot is active.
4025    /// Lowest snapshot epoch that compaction must preserve a version for, or
4026    /// `None` when no reader is pinned anywhere. Considers BOTH the single-table
4027    /// local pin set (`self.pinned`, used by the standalone `pin_snapshot` API)
4028    /// AND the shared `Database` snapshot registry (`db.snapshot()` readers) —
4029    /// otherwise a multi-table reader's version could be dropped by a compaction
4030    /// triggered on its table (the registry-gated reaper would then keep the
4031    /// old run *files*, but readers only scan the merged run, so the version
4032    /// would still be lost).
4033    pub(crate) fn min_active_snapshot(&self) -> Option<Epoch> {
4034        let local = self.pinned.keys().next().copied();
4035        let global = self.snapshots.min_pinned();
4036        match (local, global) {
4037            (Some(a), Some(b)) => Some(a.min(b)),
4038            (Some(a), None) => Some(a),
4039            (None, b) => b,
4040        }
4041    }
4042
4043    pub fn current_epoch(&self) -> Epoch {
4044        self.epoch.visible()
4045    }
4046
4047    pub fn memtable_len(&self) -> usize {
4048        self.memtable.len()
4049    }
4050
4051    /// Live (non-deleted) row count, O(1) from a manifest-maintained counter —
4052    /// the metadata `COUNT(*)` fast path (no scan).
4053    pub fn count(&self) -> u64 {
4054        self.live_count
4055    }
4056
4057    /// Count rows matching an index-backed conjunctive predicate without
4058    /// materializing projected columns. Returns `None` when a condition cannot
4059    /// be served by the native predicate resolver.
4060    pub fn count_conditions(
4061        &mut self,
4062        conditions: &[crate::query::Condition],
4063        snapshot: Snapshot,
4064    ) -> Result<Option<u64>> {
4065        use crate::query::Condition;
4066        if conditions.is_empty() {
4067            return Ok(Some(self.live_count));
4068        }
4069        let served = |c: &Condition| {
4070            matches!(
4071                c,
4072                Condition::Pk(_)
4073                    | Condition::BitmapEq { .. }
4074                    | Condition::BitmapIn { .. }
4075                    | Condition::BytesPrefix { .. }
4076                    | Condition::FmContains { .. }
4077                    | Condition::FmContainsAll { .. }
4078                    | Condition::Ann { .. }
4079                    | Condition::Range { .. }
4080                    | Condition::RangeF64 { .. }
4081                    | Condition::SparseMatch { .. }
4082                    | Condition::MinHashSimilar { .. }
4083                    | Condition::IsNull { .. }
4084                    | Condition::IsNotNull { .. }
4085            )
4086        };
4087        if !conditions.iter().all(served) {
4088            return Ok(None);
4089        }
4090        self.ensure_indexes_complete()?;
4091        let mut sets = Vec::with_capacity(conditions.len());
4092        for condition in conditions {
4093            sets.push(self.resolve_condition(condition, snapshot)?);
4094        }
4095        let mut rids = RowIdSet::intersect_many(sets);
4096        // §5.1: the in-memory indexes (bitmap/FM/ANN/sparse/minhash) are
4097        // append-only across puts (`index_row` adds entries but
4098        // `tombstone_row` never removes them), so deletes and PK-displacing
4099        // updates leave behind entries for now-tombstoned row-ids. The
4100        // materialize paths (`query`, `query_columns_native`) already drop
4101        // these via MVCC visibility during row fetch; only the count fast
4102        // path trusts raw index cardinality, so prune tombstoned overlay
4103        // row-ids here. On a clean table (empty overlay) the bitmap was
4104        // rebuilt at flush and is authoritative — the prune is skipped.
4105        if !self.memtable.is_empty() || !self.mutable_run.is_empty() {
4106            rids.remove_many(self.overlay_tombstoned_rids(snapshot));
4107        }
4108        let count = rids.len() as u64;
4109        crate::trace::QueryTrace::record(|t| {
4110            t.scan_mode = crate::trace::ScanMode::CountSurvivors;
4111            t.survivor_count = Some(count as usize);
4112            t.conditions_pushed = conditions.len();
4113        });
4114        Ok(Some(count))
4115    }
4116
4117    /// Row-ids whose newest visible overlay version is a tombstone. Used to
4118    /// prune stale entries left behind by the append-only in-memory indexes
4119    /// (see `count_conditions`). Only unflushed tombstones matter — a flush
4120    /// rebuilds indexes from runs and excludes tombstoned rows. (§5.1)
4121    fn overlay_tombstoned_rids(&self, snapshot: Snapshot) -> Vec<u64> {
4122        let mut out = Vec::new();
4123        for row in self.memtable.visible_versions(snapshot.epoch) {
4124            if row.deleted {
4125                out.push(row.row_id.0);
4126            }
4127        }
4128        for row in self.mutable_run.visible_versions(snapshot.epoch) {
4129            if row.deleted {
4130                out.push(row.row_id.0);
4131            }
4132        }
4133        out
4134    }
4135
4136    /// Bulk-load typed columns straight to a new run — the fast ingest path.
4137    /// Bypasses the WAL, the memtable, and the `Value` enum entirely; writes one
4138    /// compressed run (delta for sorted Int64, dictionary for low-card Bytes)
4139    /// with **LZ4** (Phase 15.3 — fast decode for scan-heavy analytical runs),
4140    /// rotates the WAL, and persists the manifest in a single fsync group.
4141    /// Index building follows [`Table::index_build_policy`]: deferred to the
4142    /// first query/flush by default, or bulk-built inline from the typed
4143    /// columns (Phase 14.2) under [`IndexBuildPolicy::Eager`].
4144    pub fn bulk_load_columns(
4145        &mut self,
4146        user_columns: Vec<(u16, columnar::NativeColumn)>,
4147    ) -> Result<Epoch> {
4148        self.bulk_load_columns_with(user_columns, 3, false, true)
4149    }
4150
4151    /// Maximal-throughput bulk ingest (Phase 14.4): skip zstd entirely and write
4152    /// raw `ALGO_PLAIN` pages. ~3–4× the encode throughput of
4153    /// [`Self::bulk_load_columns`] at ~3–4× the on-disk size — the right choice
4154    /// when ingest latency dominates and a background compaction will re-compress
4155    /// later. Indexing, WAL rotation, and the manifest are identical to
4156    /// [`Self::bulk_load_columns`].
4157    pub fn bulk_load_fast(
4158        &mut self,
4159        user_columns: Vec<(u16, columnar::NativeColumn)>,
4160    ) -> Result<Epoch> {
4161        self.bulk_load_columns_with(user_columns, -1, true, false)
4162    }
4163
4164    fn bulk_load_columns_with(
4165        &mut self,
4166        mut user_columns: Vec<(u16, columnar::NativeColumn)>,
4167        zstd_level: i32,
4168        force_plain: bool,
4169        lz4: bool,
4170    ) -> Result<Epoch> {
4171        let epoch = self.commit()?;
4172        let n = user_columns.first().map(|(_, c)| c.len()).unwrap_or(0);
4173        if n == 0 {
4174            return Ok(epoch);
4175        }
4176        let live_before = self.live_count;
4177        // Spill pending mutable-run data before the Flush marker + WAL rotation.
4178        self.spill_mutable_run(epoch)?;
4179        let eager_index_build = self.index_build_policy == IndexBuildPolicy::Eager
4180            && self.indexes_complete
4181            && self.run_refs.is_empty()
4182            && self.memtable.is_empty()
4183            && self.mutable_run.is_empty();
4184        // Enforce NOT NULL constraints and primary-key upsert semantics before
4185        // any row id is allocated or bytes hit the run file.
4186        self.fill_auto_inc_native_columns(&mut user_columns, n)?;
4187        self.validate_columns_not_null(&user_columns, n)?;
4188        let winner_idx = self
4189            .bulk_pk_winner_indices(&user_columns, n)
4190            .and_then(|idx| if idx.len() == n { None } else { Some(idx) });
4191        let (write_columns, write_n): (Vec<(u16, columnar::NativeColumn)>, usize) =
4192            match winner_idx.as_deref() {
4193                Some(idx) => {
4194                    let compacted = user_columns
4195                        .iter()
4196                        .map(|(id, c)| (*id, c.gather(idx)))
4197                        .collect();
4198                    (compacted, idx.len())
4199                }
4200                None => (user_columns, n),
4201            };
4202        self.advance_auto_inc_from_native_columns(&write_columns, write_n, live_before)?;
4203        let first = self.allocator.alloc_range(write_n as u64).0;
4204        for rid in first..first + write_n as u64 {
4205            self.reservoir.offer(rid);
4206        }
4207        let run_id = self.next_run_id;
4208        self.next_run_id += 1;
4209        let path = self.run_path(run_id);
4210        let mut writer =
4211            RunWriter::new(&self.schema, run_id as u128, epoch, 0).with_native_endian();
4212        if force_plain {
4213            writer = writer.with_plain();
4214        } else if lz4 {
4215            // Phase 15.3: bulk-loaded analytical runs are scan-heavy, so encode
4216            // them with LZ4 (3–5× faster decode, ~10% worse ratio than zstd).
4217            writer = writer.with_lz4();
4218        } else {
4219            writer = writer.with_zstd_level(zstd_level);
4220        }
4221        if let Some(kek) = &self.kek {
4222            writer = writer.with_encryption(kek.as_ref(), self.indexable_column_specs());
4223        }
4224        let header = writer.write_native(&path, &write_columns, write_n, first)?;
4225        self.run_refs.push(RunRef {
4226            run_id: run_id as u128,
4227            level: 0,
4228            epoch_created: epoch.0,
4229            row_count: header.row_count,
4230        });
4231        self.live_count = self.live_count.saturating_add(write_n as u64);
4232        if eager_index_build {
4233            let row_ids: Vec<u64> = (first..first + write_n as u64).collect();
4234            self.index_columns_bulk(&write_columns, &row_ids);
4235            self.indexes_complete = true;
4236            self.build_learned_ranges()?;
4237        } else {
4238            // Phase 14.7: defer index building off the ingest critical path for
4239            // non-empty tables where cross-run PK/update semantics must be
4240            // reconstructed from durable state.
4241            self.indexes_complete = false;
4242        }
4243        self.mark_flushed(epoch)?;
4244        self.persist_manifest(epoch)?;
4245        if eager_index_build {
4246            self.checkpoint_indexes(epoch);
4247        }
4248        self.clear_result_cache();
4249        Ok(epoch)
4250    }
4251
4252    /// Bulk-build the live in-memory indexes (HOT/bitmap/FM/sparse) straight
4253    /// from typed columns — the deferred batch-indexing path (Phase 14.2).
4254    ///
4255    /// Replaces the per-row `index_into` loop: no `Row`, no per-row
4256    /// `HashMap<u16, Value>`, no `Value` enum. Index keys are computed directly
4257    /// from the typed buffers via [`columnar::encode_key_native`], tokenized for
4258    /// `ENCRYPTED_INDEXABLE` columns the same way `index_into` on a tokenized
4259    /// row would. FM is appended dirty and rebuilt once on the next query; the
4260    /// others are populated in a single typed pass. Entries are merged into the
4261    /// existing indexes so this is correct under multi-run loads and partial
4262    /// reindexes.
4263    ///
4264    /// `row_ids[i]` is the `RowId` of element `i` of every column. ANN
4265    /// (`IndexKind::Ann`) is intentionally skipped: the native codec carries no
4266    /// embeddings, so an `Embedding` column can never reach this path (a native
4267    /// bulk load of an embedding schema fails at encode). LearnedRange is built
4268    /// separately from the runs by [`Self::build_learned_ranges`].
4269    fn index_columns_bulk(&mut self, columns: &[(u16, columnar::NativeColumn)], row_ids: &[u64]) {
4270        let n = row_ids.len();
4271        if n == 0 {
4272            return;
4273        }
4274        let by_id: std::collections::HashMap<u16, &columnar::NativeColumn> =
4275            columns.iter().map(|(id, c)| (*id, c)).collect();
4276        let ty_of: std::collections::HashMap<u16, TypeId> =
4277            self.schema.columns.iter().map(|c| (c.id, c.ty)).collect();
4278        let pk_id = self.schema.primary_key().map(|c| c.id);
4279
4280        for (i, &rid) in row_ids.iter().enumerate() {
4281            let row_id = RowId(rid);
4282            if let Some(pid) = pk_id {
4283                if let Some(col) = by_id.get(&pid) {
4284                    let ty = ty_of.get(&pid).copied().unwrap_or(TypeId::Int64);
4285                    if let Some(key) = bulk_index_key(&self.column_keys, pid, ty, col, i) {
4286                        self.insert_hot_pk(key, row_id);
4287                    }
4288                }
4289            }
4290            for idef in &self.schema.indexes {
4291                let Some(col) = by_id.get(&idef.column_id) else {
4292                    continue;
4293                };
4294                let ty = ty_of.get(&idef.column_id).copied().unwrap_or(TypeId::Int64);
4295                match idef.kind {
4296                    IndexKind::Bitmap => {
4297                        if let Some(b) = self.bitmap.get_mut(&idef.column_id) {
4298                            if let Some(key) =
4299                                bulk_index_key(&self.column_keys, idef.column_id, ty, col, i)
4300                            {
4301                                b.insert(key, row_id);
4302                            }
4303                        }
4304                    }
4305                    IndexKind::FmIndex => {
4306                        if let Some(f) = self.fm.get_mut(&idef.column_id) {
4307                            if let Some(bytes) = columnar::native_bytes_at(col, i) {
4308                                f.insert(bytes.to_vec(), row_id);
4309                            }
4310                        }
4311                    }
4312                    IndexKind::Sparse => {
4313                        if let Some(s) = self.sparse.get_mut(&idef.column_id) {
4314                            if let Some(bytes) = columnar::native_bytes_at(col, i) {
4315                                if let Ok(terms) = bincode::deserialize::<Vec<(u32, f32)>>(bytes) {
4316                                    s.insert(&terms, row_id);
4317                                }
4318                            }
4319                        }
4320                    }
4321                    IndexKind::MinHash => {
4322                        if let Some(mh) = self.minhash.get_mut(&idef.column_id) {
4323                            if let Some(bytes) = columnar::native_bytes_at(col, i) {
4324                                let tokens = crate::index::token_hashes_from_bytes(bytes);
4325                                mh.insert(&tokens, row_id);
4326                            }
4327                        }
4328                    }
4329                    _ => {}
4330                }
4331            }
4332        }
4333    }
4334
4335    /// no `Value`). Fast path: empty memtable + single run decodes columns
4336    /// directly and gathers visible indices; falls back to the `Value` path
4337    /// pivoted to native columns otherwise. `projection` (a set of column ids)
4338    /// limits decoding to the requested columns — `None` ⇒ all user columns.
4339    pub fn visible_columns_native(
4340        &self,
4341        snapshot: Snapshot,
4342        projection: Option<&[u16]>,
4343    ) -> Result<Vec<(u16, columnar::NativeColumn)>> {
4344        let wanted: Vec<u16> = match projection {
4345            Some(p) => p.to_vec(),
4346            None => self.schema.columns.iter().map(|c| c.id).collect(),
4347        };
4348        if self.memtable.is_empty() && self.mutable_run.is_empty() && self.run_refs.len() == 1 {
4349            let rr = self.run_refs[0].clone();
4350            let mut reader = self.open_reader(rr.run_id)?;
4351            let idxs = reader.visible_indices_native(snapshot.epoch)?;
4352            let all_visible = idxs.len() == reader.row_count();
4353            // Phase 15.1: decode every requested column in parallel when the
4354            // reader is mmap-backed. Each column already parallel-decodes its
4355            // own pages, so a wide table saturates the pool via nested rayon
4356            // without oversubscribing (work-stealing handles it). Falls back to
4357            // the sequential `&mut` path when mmap is unavailable.
4358            if reader.has_mmap() {
4359                use rayon::prelude::*;
4360                // Pre-resolve the requested ids that exist in the schema (don't
4361                // capture `self` inside the rayon closure).
4362                let valid: Vec<u16> = wanted
4363                    .iter()
4364                    .filter(|cid| self.schema.columns.iter().any(|c| c.id == **cid))
4365                    .copied()
4366                    .collect();
4367                // Decode concurrently; `collect` preserves `valid` order.
4368                let decoded: Vec<(u16, columnar::NativeColumn)> = valid
4369                    .par_iter()
4370                    .filter_map(|cid| {
4371                        reader
4372                            .column_native_shared(*cid)
4373                            .ok()
4374                            .map(|col| (*cid, col))
4375                    })
4376                    .collect();
4377                let cols = decoded
4378                    .into_iter()
4379                    .map(|(id, col)| (id, if all_visible { col } else { col.gather(&idxs) }))
4380                    .collect();
4381                return Ok(cols);
4382            }
4383            let mut cols = Vec::with_capacity(wanted.len());
4384            for cid in &wanted {
4385                let cdef = match self.schema.columns.iter().find(|c| c.id == *cid) {
4386                    Some(c) => c,
4387                    None => continue,
4388                };
4389                let col = reader.column_native(cdef.id)?;
4390                cols.push((cdef.id, if all_visible { col } else { col.gather(&idxs) }));
4391            }
4392            return Ok(cols);
4393        }
4394        let vcols = self.visible_columns(snapshot)?;
4395        let want_set: std::collections::HashSet<u16> = wanted.iter().copied().collect();
4396        let out: Vec<(u16, columnar::NativeColumn)> = vcols
4397            .into_iter()
4398            .filter(|(id, _)| want_set.contains(id))
4399            .map(|(id, vals)| {
4400                let ty = self
4401                    .schema
4402                    .columns
4403                    .iter()
4404                    .find(|c| c.id == id)
4405                    .map(|c| c.ty)
4406                    .unwrap_or(TypeId::Bytes);
4407                (id, columnar::values_to_native(ty, &vals))
4408            })
4409            .collect();
4410        Ok(out)
4411    }
4412
4413    pub fn run_count(&self) -> usize {
4414        self.run_refs.len()
4415    }
4416
4417    /// Whether the memtable is empty (no unflushed puts).
4418    pub fn memtable_is_empty(&self) -> bool {
4419        self.memtable.is_empty()
4420    }
4421
4422    /// Cumulative raw-page-cache hit/miss counts (Priority 14: hit visibility).
4423    /// Useful for confirming a repeat scan is served from cache or measuring a
4424    /// query's locality after [`reset_page_cache_stats`](Self::reset_page_cache_stats).
4425    pub fn page_cache_stats(&self) -> crate::cache::CacheStats {
4426        self.page_cache.stats()
4427    }
4428
4429    /// Zero the raw-page-cache hit/miss counters.
4430    pub fn reset_page_cache_stats(&self) {
4431        self.page_cache.reset_stats();
4432    }
4433
4434    /// The run IDs in level order (Phase 15.5: used by the Arrow IPC shadow to
4435    /// key shadow files and detect stale shadows).
4436    pub fn run_ids(&self) -> Vec<u128> {
4437        self.run_refs.iter().map(|r| r.run_id).collect()
4438    }
4439
4440    /// Whether the single run (if exactly one) is clean — i.e. has
4441    /// `RUN_FLAG_CLEAN` set (Phase 15.5: the shadow is zero-copy only for clean
4442    /// runs).
4443    pub fn single_run_is_clean(&self) -> bool {
4444        if self.run_refs.len() != 1 {
4445            return false;
4446        }
4447        self.open_reader(self.run_refs[0].run_id)
4448            .map(|r| r.is_clean())
4449            .unwrap_or(false)
4450    }
4451
4452    /// Best-effort resolve of the survivor RowId set for fine-grained cache
4453    /// invalidation (hardening (c)). On the single-run fast path, opens a reader
4454    /// and calls `resolve_survivor_rids`. On the multi-run/memtable path,
4455    /// returns an empty bitmap — conservative (condition_cols still catches
4456    /// column mutations, and deletes are caught by the epoch-free design falling
4457    /// through to the multi-run path which re-resolves).
4458    fn resolve_footprint(
4459        &self,
4460        conditions: &[crate::query::Condition],
4461        _snapshot: Snapshot,
4462    ) -> roaring::RoaringBitmap {
4463        if !self.memtable.is_empty() || !self.mutable_run.is_empty() {
4464            return roaring::RoaringBitmap::new();
4465        }
4466        if self.run_refs.is_empty() {
4467            return roaring::RoaringBitmap::new();
4468        }
4469        // Try the single-run fast path.
4470        if self.run_refs.len() == 1 {
4471            if let Ok(mut reader) = self.open_reader(self.run_refs[0].run_id) {
4472                if let Ok(rids) = self.resolve_survivor_rids(conditions, &mut reader) {
4473                    return rids.to_roaring_lossy();
4474                }
4475            }
4476        }
4477        roaring::RoaringBitmap::new()
4478    }
4479
4480    /// Phase 19.1 + hardening (c): a cached form of
4481    /// [`Table::query_columns_native`]. The cache key embeds the snapshot epoch
4482    /// so two queries at different pinned snapshots never share an entry;
4483    /// invalidation is fine-grained — a `commit()` drops only entries whose
4484    /// footprint intersects a deleted RowId or whose condition-columns intersect
4485    /// a mutated column. On a miss the underlying `query_columns_native` runs and
4486    /// the result is cached as typed `NativeColumn`s. Returns `None` exactly when
4487    /// the non-cached path would (conditions not pushdown-served). Strictly
4488    /// additive — callers wanting fresh results keep using
4489    /// `query_columns_native`.
4490    pub fn query_columns_native_cached(
4491        &mut self,
4492        conditions: &[crate::query::Condition],
4493        projection: Option<&[u16]>,
4494        snapshot: Snapshot,
4495    ) -> Result<Option<Vec<(u16, columnar::NativeColumn)>>> {
4496        if conditions.is_empty() {
4497            return self.query_columns_native(conditions, projection, snapshot);
4498        }
4499        // The snapshot epoch is part of the key so two queries with identical
4500        // conditions/projection but pinned at different snapshots never share a
4501        // cached result (MVCC isolation for the explicit-snapshot API).
4502        let key = crate::query::canonical_query_key(conditions, projection, snapshot.epoch.0);
4503        if let Some(hit) = self.result_cache.lock().get_columns(key) {
4504            crate::trace::QueryTrace::record(|t| {
4505                t.result_cache_hit = true;
4506                t.scan_mode = crate::trace::ScanMode::NativePushdown;
4507            });
4508            return Ok(Some((*hit).clone()));
4509        }
4510        let res = self.query_columns_native(conditions, projection, snapshot)?;
4511        if let Some(cols) = &res {
4512            let footprint = self.resolve_footprint(conditions, snapshot);
4513            let condition_cols = crate::query::condition_columns(conditions);
4514            self.result_cache.lock().insert(
4515                key,
4516                CachedEntry {
4517                    data: CachedData::Columns(Arc::new(cols.clone())),
4518                    footprint,
4519                    condition_cols,
4520                },
4521            );
4522        }
4523        Ok(res)
4524    }
4525
4526    /// Phase 19.1 + hardening (c): a cached form of [`Table::query`]. The cache key
4527    /// is epoch-independent; invalidation is fine-grained (see
4528    /// [`Table::query_columns_native_cached`]). On a hit returns the cached rows (no
4529    /// re-resolve, no re-decode).
4530    pub fn query_cached(&mut self, q: &crate::query::Query) -> Result<Vec<Row>> {
4531        if q.conditions.is_empty() {
4532            return self.query(q);
4533        }
4534        let key = crate::query::canonical_query_key(&q.conditions, None, 0);
4535        if let Some(hit) = self.result_cache.lock().get_rows(key) {
4536            crate::trace::QueryTrace::record(|t| {
4537                t.result_cache_hit = true;
4538                t.scan_mode = crate::trace::ScanMode::Materialized;
4539            });
4540            return Ok((*hit).clone());
4541        }
4542        let rows = self.query(q)?;
4543        let footprint = rows.iter().map(|r| r.row_id.0 as u32).collect();
4544        let condition_cols = crate::query::condition_columns(&q.conditions);
4545        self.result_cache.lock().insert(
4546            key,
4547            CachedEntry {
4548                data: CachedData::Rows(Arc::new(rows.clone())),
4549                footprint,
4550                condition_cols,
4551            },
4552        );
4553        Ok(rows)
4554    }
4555
4556    // -----------------------------------------------------------------------
4557    // Traced query wrappers (OPTIMIZATIONS.md Priority 0 / 16).
4558    //
4559    // Each `_traced` method runs its underlying query inside a
4560    // [`crate::trace::QueryTrace::capture`] scope and returns the result
4561    // alongside the captured path trace. The trace records which physical path
4562    // served the query (cursor / pushdown / materialized / count-shortcut),
4563    // whether indexes were rebuilt, whether the result cache hit, overlay size,
4564    // survivor count, and the fast row-id map usage. Recording is zero-cost
4565    // when no `_traced` method is on the call stack (the plain methods are
4566    // unchanged).
4567    // -----------------------------------------------------------------------
4568
4569    /// [`Self::query_columns_native`] with a captured [`crate::trace::QueryTrace`].
4570    #[allow(clippy::type_complexity)]
4571    pub fn query_columns_native_traced(
4572        &mut self,
4573        conditions: &[crate::query::Condition],
4574        projection: Option<&[u16]>,
4575        snapshot: Snapshot,
4576    ) -> Result<(
4577        Option<Vec<(u16, columnar::NativeColumn)>>,
4578        crate::trace::QueryTrace,
4579    )> {
4580        let (result, trace) = crate::trace::QueryTrace::capture(|| {
4581            self.query_columns_native(conditions, projection, snapshot)
4582        });
4583        Ok((result?, trace))
4584    }
4585
4586    /// [`Self::query_columns_native_cached`] with a captured
4587    /// [`crate::trace::QueryTrace`] (records result-cache hits too).
4588    #[allow(clippy::type_complexity)]
4589    pub fn query_columns_native_cached_traced(
4590        &mut self,
4591        conditions: &[crate::query::Condition],
4592        projection: Option<&[u16]>,
4593        snapshot: Snapshot,
4594    ) -> Result<(
4595        Option<Vec<(u16, columnar::NativeColumn)>>,
4596        crate::trace::QueryTrace,
4597    )> {
4598        let (result, trace) = crate::trace::QueryTrace::capture(|| {
4599            self.query_columns_native_cached(conditions, projection, snapshot)
4600        });
4601        Ok((result?, trace))
4602    }
4603
4604    /// [`Self::native_page_cursor`] with a captured [`crate::trace::QueryTrace`].
4605    pub fn native_page_cursor_traced(
4606        &self,
4607        snapshot: Snapshot,
4608        projection: Vec<(u16, TypeId)>,
4609        conditions: &[crate::query::Condition],
4610    ) -> Result<(Option<NativePageCursor>, crate::trace::QueryTrace)> {
4611        let (result, trace) = crate::trace::QueryTrace::capture(|| {
4612            self.native_page_cursor(snapshot, projection, conditions)
4613        });
4614        Ok((result?, trace))
4615    }
4616
4617    /// [`Self::native_multi_run_cursor`] with a captured [`crate::trace::QueryTrace`].
4618    pub fn native_multi_run_cursor_traced(
4619        &self,
4620        snapshot: Snapshot,
4621        projection: Vec<(u16, TypeId)>,
4622        conditions: &[crate::query::Condition],
4623    ) -> Result<(
4624        Option<crate::cursor::MultiRunCursor>,
4625        crate::trace::QueryTrace,
4626    )> {
4627        let (result, trace) = crate::trace::QueryTrace::capture(|| {
4628            self.native_multi_run_cursor(snapshot, projection, conditions)
4629        });
4630        Ok((result?, trace))
4631    }
4632
4633    /// [`Self::count_conditions`] with a captured [`crate::trace::QueryTrace`].
4634    pub fn count_conditions_traced(
4635        &mut self,
4636        conditions: &[crate::query::Condition],
4637        snapshot: Snapshot,
4638    ) -> Result<(Option<u64>, crate::trace::QueryTrace)> {
4639        let (result, trace) =
4640            crate::trace::QueryTrace::capture(|| self.count_conditions(conditions, snapshot));
4641        Ok((result?, trace))
4642    }
4643
4644    /// [`Self::query`] with a captured [`crate::trace::QueryTrace`].
4645    pub fn query_traced(
4646        &mut self,
4647        q: &crate::query::Query,
4648    ) -> Result<(Vec<Row>, crate::trace::QueryTrace)> {
4649        let (result, trace) = crate::trace::QueryTrace::capture(|| self.query(q));
4650        Ok((result?, trace))
4651    }
4652
4653    /// Predicate pushdown: resolve `conditions` via indexes to find the matching
4654    /// row-id set, then decode only those rows' columns — not the whole table.
4655    /// Returns `None` if the conditions can't be served by indexes (caller falls
4656    /// back to a full scan). This is the fast path for `WHERE col = 'value'`.
4657    pub fn query_columns_native(
4658        &mut self,
4659        conditions: &[crate::query::Condition],
4660        projection: Option<&[u16]>,
4661        snapshot: Snapshot,
4662    ) -> Result<Option<Vec<(u16, columnar::NativeColumn)>>> {
4663        use crate::query::Condition;
4664        if conditions.is_empty() {
4665            return Ok(None);
4666        }
4667        self.ensure_indexes_complete()?;
4668
4669        // Only these conditions are pushdown-served. Range/RangeF64 need a
4670        // column read on the single-run fast path; off it they fall back to a
4671        // visible-rows scan via `resolve_condition` (still correct for any
4672        // layout, just not page-pruned).
4673        let served = |c: &Condition| {
4674            matches!(
4675                c,
4676                Condition::Pk(_)
4677                    | Condition::BitmapEq { .. }
4678                    | Condition::BitmapIn { .. }
4679                    | Condition::BytesPrefix { .. }
4680                    | Condition::FmContains { .. }
4681                    | Condition::FmContainsAll { .. }
4682                    | Condition::Ann { .. }
4683                    | Condition::Range { .. }
4684                    | Condition::RangeF64 { .. }
4685                    | Condition::SparseMatch { .. }
4686                    | Condition::MinHashSimilar { .. }
4687                    | Condition::IsNull { .. }
4688                    | Condition::IsNotNull { .. }
4689            )
4690        };
4691        if !conditions.iter().all(served) {
4692            return Ok(None);
4693        }
4694        let fast_path =
4695            self.memtable.is_empty() && self.mutable_run.is_empty() && self.run_refs.len() == 1;
4696        crate::trace::QueryTrace::record(|t| {
4697            t.run_count = self.run_refs.len();
4698            t.memtable_rows = self.memtable.len();
4699            t.mutable_run_rows = self.mutable_run.len();
4700            t.conditions_pushed = conditions.len();
4701            t.learned_range_used = conditions.iter().any(|c| match c {
4702                Condition::Range { column_id, .. } | Condition::RangeF64 { column_id, .. } => {
4703                    self.learned_range.contains_key(column_id)
4704                }
4705                _ => false,
4706            });
4707        });
4708        // Build column list (projected or all user columns) + projection pairs.
4709        let col_ids: Vec<u16> = projection
4710            .map(|p| p.to_vec())
4711            .unwrap_or_else(|| self.schema.columns.iter().map(|c| c.id).collect());
4712        let proj_pairs: Vec<(u16, TypeId)> = col_ids
4713            .iter()
4714            .map(|&cid| {
4715                let ty = self
4716                    .schema
4717                    .columns
4718                    .iter()
4719                    .find(|c| c.id == cid)
4720                    .map(|c| c.ty)
4721                    .unwrap_or(TypeId::Bytes);
4722                (cid, ty)
4723            })
4724            .collect();
4725
4726        // -----------------------------------------------------------------------
4727        // Fast path: single run, empty memtable/mutable-run → resolve survivors,
4728        // binary-search positions, gather only the projected columns from one
4729        // reader. This is the fastest pushdown path (no cursor overhead).
4730        // -----------------------------------------------------------------------
4731        if fast_path {
4732            // A Range/RangeF64 needs a column read *unless* its column has a
4733            // learned (PGM) range index, in which case it's served in-memory.
4734            let needs_column = conditions.iter().any(|c| match c {
4735                Condition::Range { column_id, .. } => !self.learned_range.contains_key(column_id),
4736                Condition::RangeF64 { column_id, .. } => {
4737                    !self.learned_range.contains_key(column_id)
4738                }
4739                _ => false,
4740            });
4741            let mut reader_opt: Option<RunReader> = if needs_column {
4742                Some(self.open_reader(self.run_refs[0].run_id)?)
4743            } else {
4744                None
4745            };
4746            let mut sets: Vec<RowIdSet> = Vec::new();
4747            for c in conditions {
4748                let s = match c {
4749                    Condition::Range { column_id, lo, hi }
4750                        if !self.learned_range.contains_key(column_id) =>
4751                    {
4752                        if reader_opt.is_none() {
4753                            reader_opt = Some(self.open_reader(self.run_refs[0].run_id)?);
4754                        }
4755                        reader_opt
4756                            .as_mut()
4757                            .expect("reader opened for range")
4758                            .range_row_id_set_i64(*column_id, *lo, *hi)?
4759                    }
4760                    Condition::RangeF64 {
4761                        column_id,
4762                        lo,
4763                        lo_inclusive,
4764                        hi,
4765                        hi_inclusive,
4766                    } if !self.learned_range.contains_key(column_id) => {
4767                        if reader_opt.is_none() {
4768                            reader_opt = Some(self.open_reader(self.run_refs[0].run_id)?);
4769                        }
4770                        reader_opt
4771                            .as_mut()
4772                            .expect("reader opened for range")
4773                            .range_row_id_set_f64(
4774                                *column_id,
4775                                *lo,
4776                                *lo_inclusive,
4777                                *hi,
4778                                *hi_inclusive,
4779                            )?
4780                    }
4781                    _ => self.resolve_condition(c, snapshot)?,
4782                };
4783                sets.push(s);
4784            }
4785            let candidates = RowIdSet::intersect_many(sets);
4786            crate::trace::QueryTrace::record(|t| {
4787                t.survivor_count = Some(candidates.len());
4788            });
4789            if candidates.is_empty() {
4790                let cols: Vec<(u16, columnar::NativeColumn)> = col_ids
4791                    .iter()
4792                    .map(|&id| {
4793                        (
4794                            id,
4795                            columnar::null_native(
4796                                proj_pairs
4797                                    .iter()
4798                                    .find(|(c, _)| c == &id)
4799                                    .map(|(_, t)| *t)
4800                                    .unwrap_or(TypeId::Bytes),
4801                                0,
4802                            ),
4803                        )
4804                    })
4805                    .collect();
4806                return Ok(Some(cols));
4807            }
4808            let mut reader = match reader_opt.take() {
4809                Some(r) => r,
4810                None => self.open_reader(self.run_refs[0].run_id)?,
4811            };
4812            let candidate_ids = candidates.into_sorted_vec();
4813            let (positions, fast_rid) = if let Some(positions) =
4814                reader.positions_for_row_ids_fast(&candidate_ids)
4815            {
4816                (positions, true)
4817            } else {
4818                let col = reader.column_native(crate::sorted_run::SYS_ROW_ID)?;
4819                match col {
4820                    columnar::NativeColumn::Int64 { data, .. } => {
4821                        let mut p: Vec<usize> = candidate_ids
4822                            .iter()
4823                            .filter_map(|rid| data.binary_search(&(*rid as i64)).ok())
4824                            .collect();
4825                        p.sort_unstable();
4826                        (p, false)
4827                    }
4828                    _ => return Err(MongrelError::InvalidArgument("sys row_id not int64".into())),
4829                }
4830            };
4831            crate::trace::QueryTrace::record(|t| {
4832                t.scan_mode = crate::trace::ScanMode::NativePushdown;
4833                t.fast_row_id_map = fast_rid;
4834            });
4835            let mut cols = Vec::with_capacity(col_ids.len());
4836            for cid in &col_ids {
4837                let col = reader.column_native(*cid)?;
4838                cols.push((*cid, col.gather(&positions)));
4839            }
4840            return Ok(Some(cols));
4841        }
4842
4843        // -----------------------------------------------------------------------
4844        // Non-fast path (multi-run / non-empty overlay). Route through the
4845        // columnar cursor (OPTIMIZATIONS.md Priority 1 + 4): the cursor builder
4846        // resolves MVCC, predicates, and overlay internally in batch, then
4847        // streams projected columns page-by-page. This avoids the per-rid
4848        // `rows_for_rids` `get_version`-across-all-runs cost that made multi-run
4849        // pushdown ~1000× slower than the single-run fast path.
4850        //
4851        // The cursor handles both single-run-with-overlay (`native_page_cursor`)
4852        // and multi-run (`native_multi_run_cursor`) layouts. The empty-table
4853        // (no runs, memtable-only) edge case falls through to `rows_for_rids`.
4854        // -----------------------------------------------------------------------
4855        if !self.run_refs.is_empty() {
4856            use crate::cursor::{drain_cursor_to_columns, Cursor};
4857            let remaining: usize;
4858            let mut cursor: Box<dyn crate::cursor::Cursor> = if self.run_refs.len() == 1 {
4859                let c = self
4860                    .native_page_cursor(snapshot, proj_pairs.clone(), conditions)?
4861                    .expect("single-run cursor should build when run_refs.len() == 1");
4862                remaining = c.remaining_rows();
4863                Box::new(c)
4864            } else {
4865                let c = self
4866                    .native_multi_run_cursor(snapshot, proj_pairs.clone(), conditions)?
4867                    .expect("multi-run cursor should build when run_refs.len() >= 1");
4868                remaining = c.remaining_rows();
4869                Box::new(c)
4870            };
4871            crate::trace::QueryTrace::record(|t| {
4872                if t.survivor_count.is_none() {
4873                    t.survivor_count = Some(remaining);
4874                }
4875            });
4876            let cols = drain_cursor_to_columns(cursor.as_mut(), &proj_pairs)?;
4877            return Ok(Some(cols));
4878        }
4879
4880        // Empty-table fallback (no sorted runs, memtable/mutable-run only): the
4881        // cursor builders return `None` for `run_refs.is_empty()`, so resolve
4882        // from overlay indexes and materialize via `rows_for_rids`. This is the
4883        // rare edge case (fresh table with only `put`s, no `flush`/`bulk_load`).
4884        crate::trace::QueryTrace::record(|t| {
4885            t.scan_mode = crate::trace::ScanMode::Materialized;
4886            t.row_materialized = true;
4887        });
4888        let mut sets: Vec<RowIdSet> = Vec::with_capacity(conditions.len());
4889        for c in conditions {
4890            sets.push(self.resolve_condition(c, snapshot)?);
4891        }
4892        let rids = RowIdSet::intersect_many(sets).into_sorted_vec();
4893        let rows = self.rows_for_rids(&rids, snapshot)?;
4894        let mut cols: Vec<(u16, columnar::NativeColumn)> = Vec::with_capacity(col_ids.len());
4895        for (cid, ty) in &proj_pairs {
4896            let vals: Vec<Value> = rows
4897                .iter()
4898                .map(|r| r.columns.get(cid).cloned().unwrap_or(Value::Null))
4899                .collect();
4900            cols.push((*cid, columnar::values_to_native(*ty, &vals)));
4901        }
4902        Ok(Some(cols))
4903    }
4904
4905    /// Build a lazy, page-aware [`NativePageCursor`] for the single-run fast
4906    /// path. MVCC visibility and predicate survivor resolution are settled up
4907    /// front (so they see the live indexes under the DB lock); the cursor then
4908    /// owns the reader and decodes only the projected columns of pages that
4909    /// contain survivors, lazily. This is the fused-predicate + page-skip +
4910    /// late-materialization scan.
4911    ///
4912    /// Phase 13.1: the memtable / mutable-run overlay is now handled. Rows with
4913    /// a newer version in the overlay are excluded from the run's page plans
4914    /// (their run version is stale); the overlay rows are pre-materialized and
4915    /// appended as a final batch via [`NativePageCursor::new_with_overlay`].
4916    ///
4917    /// Returns `None` only for multiple sorted runs; the caller falls back to
4918    /// the materialize-then-stream scan for that layout.
4919    pub fn native_page_cursor(
4920        &self,
4921        snapshot: Snapshot,
4922        projection: Vec<(u16, TypeId)>,
4923        conditions: &[crate::query::Condition],
4924    ) -> Result<Option<NativePageCursor>> {
4925        use crate::cursor::build_page_plans;
4926        // See `scan_cursor`: incomplete (deferred) indexes cannot resolve
4927        // conditions — signal "can't serve" instead of empty survivor sets.
4928        if !conditions.is_empty() && !self.indexes_complete {
4929            return Ok(None);
4930        }
4931        if self.run_refs.len() != 1 {
4932            return Ok(None);
4933        }
4934        let mut reader = self.open_reader(self.run_refs[0].run_id)?;
4935        let (positions, rids) = reader.visible_positions_with_rids(snapshot.epoch)?;
4936
4937        // Collect overlay rows from memtable + mutable_run (visible, newest
4938        // version per row). These shadow any stale version in the run.
4939        let overlay_rids: HashSet<u64> = {
4940            let mut s = HashSet::new();
4941            for row in self.memtable.visible_versions(snapshot.epoch) {
4942                s.insert(row.row_id.0);
4943            }
4944            for row in self.mutable_run.visible_versions(snapshot.epoch) {
4945                s.insert(row.row_id.0);
4946            }
4947            s
4948        };
4949
4950        // Resolve survivor rids via indexes (covers overlay rows for index-
4951        // served conditions: PK, bitmap, FM, ANN, sparse — all maintained on
4952        // every put).
4953        let survivors = if conditions.is_empty() {
4954            None
4955        } else {
4956            Some(self.resolve_survivor_rids(conditions, &mut reader)?)
4957        };
4958
4959        // Exclude overlay rids from the run portion: their version in the run
4960        // is stale (updated/deleted in the overlay) or they don't exist in the
4961        // run (new inserts). When there are conditions, we remove overlay rids
4962        // from the survivor set. When there are no conditions, we synthesize a
4963        // survivor set = (all visible run rids) − (overlay rids) so the stale
4964        // run rows are pruned.
4965        let run_survivors: Option<RowIdSet> = if overlay_rids.is_empty() {
4966            survivors.clone()
4967        } else if let Some(s) = &survivors {
4968            let mut run_set = s.clone();
4969            run_set.remove_many(overlay_rids.iter().copied());
4970            Some(run_set)
4971        } else {
4972            Some(RowIdSet::from_unsorted(
4973                rids.iter()
4974                    .map(|&r| r as u64)
4975                    .filter(|r| !overlay_rids.contains(r))
4976                    .collect(),
4977            ))
4978        };
4979
4980        let overlay_rows = if overlay_rids.is_empty() {
4981            Vec::new()
4982        } else {
4983            let bound = Self::overlay_materialization_bound(conditions, &survivors);
4984            self.overlay_visible_rows(snapshot, bound)
4985        };
4986
4987        // Build page plans for the run portion.
4988        let plans = if positions.is_empty() {
4989            Vec::new()
4990        } else {
4991            let page_rows = reader.page_row_counts(crate::sorted_run::SYS_ROW_ID)?;
4992            build_page_plans(&positions, &rids, &page_rows, run_survivors.as_ref())
4993        };
4994
4995        // Filter and materialize the overlay.
4996        let overlay = if overlay_rows.is_empty() {
4997            None
4998        } else {
4999            let filtered =
5000                self.filter_overlay_rows(overlay_rows, conditions, survivors.as_ref(), snapshot)?;
5001            if filtered.is_empty() {
5002                None
5003            } else {
5004                Some(self.materialize_overlay(&filtered, &projection))
5005            }
5006        };
5007
5008        let overlay_row_count = overlay
5009            .as_ref()
5010            .map(|c| c.first().map(|c| c.len()).unwrap_or(0))
5011            .unwrap_or(0);
5012        crate::trace::QueryTrace::record(|t| {
5013            t.scan_mode = crate::trace::ScanMode::NativePageCursor;
5014            t.run_count = self.run_refs.len();
5015            t.memtable_rows = self.memtable.len();
5016            t.mutable_run_rows = self.mutable_run.len();
5017            t.overlay_rows = overlay_row_count;
5018            t.conditions_pushed = conditions.len();
5019            t.pages_decoded = plans
5020                .iter()
5021                .map(|p| p.positions.len())
5022                .sum::<usize>()
5023                .min(1);
5024        });
5025
5026        Ok(Some(NativePageCursor::new_with_overlay(
5027            reader, projection, plans, overlay,
5028        )))
5029    }
5030    /// Generalizes [`Self::native_page_cursor`] (single-run) to arbitrary run
5031    /// counts via a k-way merge by `RowId`. Cross-run MVCC resolution (newest
5032    /// visible version per `RowId`) and predicate survivor resolution are settled
5033    /// up front from the cheap system columns + global indexes; the cursor then
5034    /// lazily decodes the projected data columns of just the pages that own
5035    /// survivors, each page at most once. The memtable / mutable-run overlay is
5036    /// materialized and yielded as a final batch (mirroring the single-run path).
5037    ///
5038    /// Returns `None` only when there are no runs at all (caller falls back).
5039    #[allow(clippy::type_complexity)]
5040    pub fn native_multi_run_cursor(
5041        &self,
5042        snapshot: Snapshot,
5043        projection: Vec<(u16, TypeId)>,
5044        conditions: &[crate::query::Condition],
5045    ) -> Result<Option<crate::cursor::MultiRunCursor>> {
5046        use crate::cursor::{MultiRunCursor, RunStream};
5047        use crate::sorted_run::SYS_ROW_ID;
5048        use std::collections::{BinaryHeap, HashMap, HashSet};
5049        // See `scan_cursor`: incomplete (deferred) indexes cannot resolve
5050        // conditions — signal "can't serve" instead of empty survivor sets.
5051        if !conditions.is_empty() && !self.indexes_complete {
5052            return Ok(None);
5053        }
5054        if self.run_refs.is_empty() {
5055            return Ok(None);
5056        }
5057
5058        // Open each run once; read its system columns + page layout.
5059        let mut run_meta: Vec<(RunReader, Vec<i64>, Vec<i64>, Vec<u8>, Vec<usize>)> =
5060            Vec::with_capacity(self.run_refs.len());
5061        for rr in &self.run_refs {
5062            let mut reader = self.open_reader(rr.run_id)?;
5063            let (rids, eps, del) = reader.system_columns_native()?;
5064            let page_rows = reader.page_row_counts(SYS_ROW_ID)?;
5065            run_meta.push((reader, rids, eps, del, page_rows));
5066        }
5067
5068        // Global cross-run newest-version resolution: rid -> (epoch, run_idx,
5069        // position, deleted). Mirrors `visible_rows`, tracking which run owns
5070        // the newest MVCC-visible version.
5071        let mut best: HashMap<u64, (u64, usize, usize, bool)> = HashMap::new();
5072        for (run_idx, (_, rids, eps, del, _)) in run_meta.iter().enumerate() {
5073            for i in 0..rids.len() {
5074                let rid = rids[i] as u64;
5075                let e = eps[i] as u64;
5076                if e > snapshot.epoch.0 {
5077                    continue;
5078                }
5079                let is_del = del[i] != 0;
5080                best.entry(rid)
5081                    .and_modify(|cur| {
5082                        if e > cur.0 {
5083                            *cur = (e, run_idx, i, is_del);
5084                        }
5085                    })
5086                    .or_insert((e, run_idx, i, is_del));
5087            }
5088        }
5089
5090        // Overlay rids (memtable + mutable-run) shadow every run version.
5091        let overlay_rids: HashSet<u64> = {
5092            let mut s = HashSet::new();
5093            for row in self.memtable.visible_versions(snapshot.epoch) {
5094                s.insert(row.row_id.0);
5095            }
5096            for row in self.mutable_run.visible_versions(snapshot.epoch) {
5097                s.insert(row.row_id.0);
5098            }
5099            s
5100        };
5101
5102        // Predicate survivors (global, layout-independent).
5103        let survivors: Option<RowIdSet> = if conditions.is_empty() {
5104            None
5105        } else {
5106            let mut sets: Vec<RowIdSet> = Vec::with_capacity(conditions.len());
5107            for c in conditions {
5108                sets.push(self.resolve_condition(c, snapshot)?);
5109            }
5110            Some(RowIdSet::intersect_many(sets))
5111        };
5112
5113        // Per-run owned survivors: (rid, position), ascending by rid. A row is
5114        // owned by the run holding its newest visible version, is not deleted,
5115        // is not shadowed by the overlay, and satisfies the predicate.
5116        let mut per_run: Vec<Vec<(u64, usize)>> = vec![Vec::new(); run_meta.len()];
5117        for (rid, (_, run_idx, pos, deleted)) in &best {
5118            if *deleted {
5119                continue;
5120            }
5121            if overlay_rids.contains(rid) {
5122                continue;
5123            }
5124            if let Some(s) = &survivors {
5125                if !s.contains(*rid) {
5126                    continue;
5127                }
5128            }
5129            per_run[*run_idx].push((*rid, *pos));
5130        }
5131        for v in per_run.iter_mut() {
5132            v.sort_unstable_by_key(|&(rid, _)| rid);
5133        }
5134
5135        // Build the merge streams: map each owned position to (page_seq, within).
5136        let mut streams = Vec::with_capacity(run_meta.len());
5137        let mut heap: BinaryHeap<std::cmp::Reverse<(u64, usize)>> = BinaryHeap::new();
5138        let mut total = 0usize;
5139        for (run_idx, (reader, _, _, _, page_rows)) in run_meta.into_iter().enumerate() {
5140            let mut starts = Vec::with_capacity(page_rows.len());
5141            let mut acc = 0usize;
5142            for &r in &page_rows {
5143                starts.push(acc);
5144                acc += r;
5145            }
5146            let mut survivors_vec: Vec<(u64, usize, usize)> =
5147                Vec::with_capacity(per_run[run_idx].len());
5148            for &(rid, pos) in &per_run[run_idx] {
5149                let page_seq = match starts.partition_point(|&s| s <= pos) {
5150                    0 => continue,
5151                    p => p - 1,
5152                };
5153                let within = pos - starts[page_seq];
5154                survivors_vec.push((rid, page_seq, within));
5155            }
5156            total += survivors_vec.len();
5157            if let Some(&(rid, _, _)) = survivors_vec.first() {
5158                heap.push(std::cmp::Reverse((rid, run_idx)));
5159            }
5160            streams.push(RunStream::new(reader, survivors_vec, page_rows));
5161        }
5162
5163        // Materialize the overlay (filtered + projected), yielded as the final batch.
5164        let overlay_rows = if overlay_rids.is_empty() {
5165            Vec::new()
5166        } else {
5167            let bound = Self::overlay_materialization_bound(conditions, &survivors);
5168            self.overlay_visible_rows(snapshot, bound)
5169        };
5170        let overlay = if overlay_rows.is_empty() {
5171            None
5172        } else {
5173            let filtered =
5174                self.filter_overlay_rows(overlay_rows, conditions, survivors.as_ref(), snapshot)?;
5175            if filtered.is_empty() {
5176                None
5177            } else {
5178                Some(self.materialize_overlay(&filtered, &projection))
5179            }
5180        };
5181
5182        let overlay_row_count = overlay
5183            .as_ref()
5184            .map(|c| c.first().map(|c| c.len()).unwrap_or(0))
5185            .unwrap_or(0);
5186        crate::trace::QueryTrace::record(|t| {
5187            t.scan_mode = crate::trace::ScanMode::MultiRunCursor;
5188            t.run_count = self.run_refs.len();
5189            t.memtable_rows = self.memtable.len();
5190            t.mutable_run_rows = self.mutable_run.len();
5191            t.overlay_rows = overlay_row_count;
5192            t.conditions_pushed = conditions.len();
5193            t.survivor_count = Some(total);
5194        });
5195
5196        Ok(Some(MultiRunCursor::new(
5197            streams, projection, heap, total, overlay,
5198        )))
5199    }
5200
5201    /// Collect visible, non-deleted overlay rows from the memtable and mutable-
5202    /// run tier at `snapshot`. These are the rows whose data lives only in the
5203    /// in-memory buffers (not yet in a sorted run), or that shadow a stale
5204    /// version in the run.
5205    /// The survivor set that bounds overlay materialization (Priority 2), or
5206    /// `None` when overlay rows must be fully materialized — i.e. there is a
5207    /// `Range`/`RangeF64` residual, for which the index-served survivor set does
5208    /// not cover matching overlay rows (those are evaluated downstream). This
5209    /// mirrors the `all_index_served` branch of
5210    /// [`filter_overlay_rows`](Self::filter_overlay_rows), so bounding here is
5211    /// result-preserving.
5212    fn overlay_materialization_bound<'a>(
5213        conditions: &[crate::query::Condition],
5214        survivors: &'a Option<RowIdSet>,
5215    ) -> Option<&'a RowIdSet> {
5216        use crate::query::Condition;
5217        let has_range = conditions
5218            .iter()
5219            .any(|c| matches!(c, Condition::Range { .. } | Condition::RangeF64 { .. }));
5220        if has_range {
5221            None
5222        } else {
5223            survivors.as_ref()
5224        }
5225    }
5226
5227    /// Materialize the visible overlay rows (memtable + mutable-run, newest
5228    /// version per row, non-deleted).
5229    ///
5230    /// Priority 2 (selective overlay probing): when `bound` is `Some`, only rows
5231    /// whose id is in it are materialized. The caller passes the index-resolved
5232    /// survivor set as `bound` exactly when every condition is index-served — in
5233    /// which case [`filter_overlay_rows`](Self::filter_overlay_rows) would discard
5234    /// any non-survivor overlay row anyway, so this prunes the materialization
5235    /// without changing the result. With a Range/RangeF64 residual the survivor
5236    /// set is incomplete for overlay rows, so the caller passes `None` (full
5237    /// materialization) and the range is re-evaluated downstream.
5238    fn overlay_visible_rows(&self, snapshot: Snapshot, bound: Option<&RowIdSet>) -> Vec<Row> {
5239        let mut best: HashMap<u64, (Epoch, Row)> = HashMap::new();
5240        let mut fold = |row: Row| {
5241            if let Some(b) = bound {
5242                if !b.contains(row.row_id.0) {
5243                    return;
5244                }
5245            }
5246            best.entry(row.row_id.0)
5247                .and_modify(|(be, br)| {
5248                    if row.committed_epoch > *be {
5249                        *be = row.committed_epoch;
5250                        *br = row.clone();
5251                    }
5252                })
5253                .or_insert_with(|| (row.committed_epoch, row));
5254        };
5255        for row in self.memtable.visible_versions(snapshot.epoch) {
5256            fold(row);
5257        }
5258        for row in self.mutable_run.visible_versions(snapshot.epoch) {
5259            fold(row);
5260        }
5261        let mut out: Vec<Row> = best
5262            .into_values()
5263            .filter_map(|(_, r)| if r.deleted { None } else { Some(r) })
5264            .collect();
5265        out.sort_by_key(|r| r.row_id);
5266        out
5267    }
5268
5269    /// Filter overlay rows against the conjunctive predicate. Range / RangeF64
5270    /// are evaluated directly (the reader-served survivor set misses overlay
5271    /// rows). All other conditions are index-served (indexes maintained on
5272    /// every `put`) so the intersected `survivors` set includes overlay rows
5273    /// that match — but ONLY when every condition is index-served. When there
5274    /// is a mix, we compute per-condition index sets for non-range conditions
5275    /// and evaluate range conditions directly, so the intersection is correct.
5276    fn filter_overlay_rows(
5277        &self,
5278        rows: Vec<Row>,
5279        conditions: &[crate::query::Condition],
5280        survivors: Option<&RowIdSet>,
5281        snapshot: Snapshot,
5282    ) -> Result<Vec<Row>> {
5283        if conditions.is_empty() {
5284            return Ok(rows);
5285        }
5286        use crate::query::Condition;
5287        // Determine whether every condition is index-served (survivors set is
5288        // then complete for overlay rows). If so, a simple membership check
5289        // suffices and is cheapest.
5290        let all_index_served = !conditions
5291            .iter()
5292            .any(|c| matches!(c, Condition::Range { .. } | Condition::RangeF64 { .. }));
5293        if all_index_served {
5294            return Ok(rows
5295                .into_iter()
5296                .filter(|r| survivors.map_or(true, |s| s.contains(r.row_id.0)))
5297                .collect());
5298        }
5299        // Mixed: compute per-condition index sets for non-range conditions, and
5300        // evaluate range conditions directly on column values.
5301        let mut per_cond_sets: Vec<RowIdSet> = Vec::with_capacity(conditions.len());
5302        for c in conditions {
5303            let s = match c {
5304                Condition::Range { .. } | Condition::RangeF64 { .. } => RowIdSet::empty(),
5305                _ => self.resolve_condition(c, snapshot)?,
5306            };
5307            per_cond_sets.push(s);
5308        }
5309        Ok(rows
5310            .into_iter()
5311            .filter(|row| {
5312                conditions.iter().enumerate().all(|(i, c)| match c {
5313                    Condition::Range { column_id, lo, hi } => {
5314                        matches!(row.columns.get(column_id), Some(Value::Int64(v)) if *v >= *lo && *v <= *hi)
5315                    }
5316                    Condition::RangeF64 { column_id, lo, lo_inclusive, hi, hi_inclusive } => {
5317                        match row.columns.get(column_id) {
5318                            Some(Value::Float64(v)) => {
5319                                let lo_ok = if *lo_inclusive { *v >= *lo } else { *v > *lo };
5320                                let hi_ok = if *hi_inclusive { *v <= *hi } else { *v < *hi };
5321                                lo_ok && hi_ok
5322                            }
5323                            _ => false,
5324                        }
5325                    }
5326                    _ => per_cond_sets[i].contains(row.row_id.0),
5327                })
5328            })
5329            .collect())
5330    }
5331
5332    /// Materialize overlay rows into typed `NativeColumn`s for the cursor's
5333    /// final batch.
5334    fn materialize_overlay(
5335        &self,
5336        rows: &[Row],
5337        projection: &[(u16, TypeId)],
5338    ) -> Vec<columnar::NativeColumn> {
5339        if projection.is_empty() {
5340            return vec![columnar::null_native(TypeId::Int64, rows.len())];
5341        }
5342        let mut cols = Vec::with_capacity(projection.len());
5343        for (cid, ty) in projection {
5344            let vals: Vec<Value> = rows
5345                .iter()
5346                .map(|r| r.columns.get(cid).cloned().unwrap_or(Value::Null))
5347                .collect();
5348            cols.push(columnar::values_to_native(*ty, &vals));
5349        }
5350        cols
5351    }
5352
5353    /// Resolve a conjunctive predicate to its surviving `RowId` set on the
5354    /// single-run fast path: each condition becomes a `RowId` set via the
5355    /// in-memory indexes or the reader's page-pruned range scan, then they are
5356    /// intersected. Mirrors the resolution inside [`Self::query_columns_native`].
5357    fn resolve_survivor_rids(
5358        &self,
5359        conditions: &[crate::query::Condition],
5360        reader: &mut RunReader,
5361    ) -> Result<RowIdSet> {
5362        use crate::query::Condition;
5363        let mut sets: Vec<RowIdSet> = Vec::new();
5364        for c in conditions {
5365            let s: RowIdSet = match c {
5366                Condition::Pk(key) => {
5367                    let lookup = self
5368                        .schema
5369                        .primary_key()
5370                        .map(|pk| self.index_lookup_key_bytes(pk.id, key))
5371                        .unwrap_or_else(|| key.clone());
5372                    self.hot
5373                        .get(&lookup)
5374                        .map(|r| RowIdSet::one(r.0))
5375                        .unwrap_or_else(RowIdSet::empty)
5376                }
5377                Condition::BitmapEq { column_id, value } => {
5378                    let lookup = self.index_lookup_key_bytes(*column_id, value);
5379                    self.bitmap
5380                        .get(column_id)
5381                        .map(|b| RowIdSet::from_roaring(b.get(&lookup)))
5382                        .unwrap_or_else(RowIdSet::empty)
5383                }
5384                Condition::BitmapIn { column_id, values } => {
5385                    let bm = self.bitmap.get(column_id);
5386                    let mut acc = roaring::RoaringBitmap::new();
5387                    if let Some(b) = bm {
5388                        for v in values {
5389                            let lookup = self.index_lookup_key_bytes(*column_id, v);
5390                            acc |= b.get(&lookup);
5391                        }
5392                    }
5393                    RowIdSet::from_roaring(acc)
5394                }
5395                Condition::BytesPrefix { column_id, prefix } => {
5396                    if let Some(b) = self.bitmap.get(column_id) {
5397                        let lookup_prefix = self.index_lookup_key_bytes(*column_id, prefix);
5398                        let mut acc = roaring::RoaringBitmap::new();
5399                        for key in b.keys() {
5400                            if key.starts_with(&lookup_prefix) {
5401                                acc |= b.get(key);
5402                            }
5403                        }
5404                        RowIdSet::from_roaring(acc)
5405                    } else {
5406                        RowIdSet::empty()
5407                    }
5408                }
5409                Condition::FmContains { column_id, pattern } => self
5410                    .fm
5411                    .get(column_id)
5412                    .map(|f| {
5413                        RowIdSet::from_unsorted(
5414                            f.locate(pattern).into_iter().map(|r| r.0).collect(),
5415                        )
5416                    })
5417                    .unwrap_or_else(RowIdSet::empty),
5418                Condition::FmContainsAll {
5419                    column_id,
5420                    patterns,
5421                } => {
5422                    if let Some(f) = self.fm.get(column_id) {
5423                        let sets: Vec<RowIdSet> = patterns
5424                            .iter()
5425                            .map(|pat| {
5426                                RowIdSet::from_unsorted(
5427                                    f.locate(pat).into_iter().map(|r| r.0).collect(),
5428                                )
5429                            })
5430                            .collect();
5431                        RowIdSet::intersect_many(sets)
5432                    } else {
5433                        RowIdSet::empty()
5434                    }
5435                }
5436                Condition::Ann {
5437                    column_id,
5438                    query,
5439                    k,
5440                } => self
5441                    .ann
5442                    .get(column_id)
5443                    .map(|a| {
5444                        RowIdSet::from_unsorted(
5445                            a.search(query, *k).into_iter().map(|(r, _)| r.0).collect(),
5446                        )
5447                    })
5448                    .unwrap_or_else(RowIdSet::empty),
5449                Condition::SparseMatch {
5450                    column_id,
5451                    query,
5452                    k,
5453                } => self
5454                    .sparse
5455                    .get(column_id)
5456                    .map(|s| {
5457                        RowIdSet::from_unsorted(
5458                            s.search(query, *k).into_iter().map(|(r, _)| r.0).collect(),
5459                        )
5460                    })
5461                    .unwrap_or_else(RowIdSet::empty),
5462                Condition::MinHashSimilar {
5463                    column_id,
5464                    query,
5465                    k,
5466                } => self
5467                    .minhash
5468                    .get(column_id)
5469                    .map(|mh| {
5470                        RowIdSet::from_unsorted(
5471                            mh.search(query, *k).into_iter().map(|(r, _)| r.0).collect(),
5472                        )
5473                    })
5474                    .unwrap_or_else(RowIdSet::empty),
5475                Condition::Range { column_id, lo, hi } => {
5476                    if let Some(li) = self.learned_range.get(column_id) {
5477                        RowIdSet::from_unsorted(li.range(*lo, *hi).into_iter().collect())
5478                    } else {
5479                        reader.range_row_id_set_i64(*column_id, *lo, *hi)?
5480                    }
5481                }
5482                Condition::RangeF64 {
5483                    column_id,
5484                    lo,
5485                    lo_inclusive,
5486                    hi,
5487                    hi_inclusive,
5488                } => {
5489                    if let Some(li) = self.learned_range.get(column_id) {
5490                        RowIdSet::from_unsorted(
5491                            li.range_f64(*lo, *lo_inclusive, *hi, *hi_inclusive)
5492                                .into_iter()
5493                                .collect(),
5494                        )
5495                    } else {
5496                        reader.range_row_id_set_f64(
5497                            *column_id,
5498                            *lo,
5499                            *lo_inclusive,
5500                            *hi,
5501                            *hi_inclusive,
5502                        )?
5503                    }
5504                }
5505                Condition::IsNull { column_id } => reader.null_row_id_set(*column_id, true)?,
5506                Condition::IsNotNull { column_id } => reader.null_row_id_set(*column_id, false)?,
5507            };
5508            sets.push(s);
5509        }
5510        Ok(RowIdSet::intersect_many(sets))
5511    }
5512
5513    /// Native vectorized aggregate over a (possibly filtered) column on the
5514    /// single-run fast path (Phase 7.2). Resolves survivors via the same
5515    /// page-pruned cursor as the scan, then accumulates the aggregate in one
5516    /// pass over the typed buffer — no `Value`, no Arrow `RecordBatch`.
5517    ///
5518    /// `column` is `None` for `COUNT(*)`. Returns `Ok(None)` when the fast path
5519    /// does not apply (multi-run / non-empty memtable); the caller scans.
5520    /// Open the streaming [`Cursor`](crate::cursor::Cursor) matching the current
5521    /// run layout: the single-run page cursor when there is exactly one sorted
5522    /// run, otherwise the multi-run k-way merge cursor. Both fuse the predicate,
5523    /// skip non-surviving pages, and fold the memtable / mutable-run overlay, so
5524    /// callers stay columnar end-to-end and never materialize `Row`s. Returns
5525    /// `None` when no cursor applies (e.g. an overlay-only table with no sorted
5526    /// run), leaving the caller to fall back.
5527    ///
5528    /// This is the single source of truth for layout-aware cursor selection,
5529    /// shared by the column scan ([`Self::query_columns_native`] / the SQL
5530    /// provider) and the aggregate path ([`Self::aggregate_native`]). New
5531    /// streaming consumers should build on this rather than re-deciding the
5532    /// cursor by run count.
5533    pub fn scan_cursor(
5534        &self,
5535        snapshot: Snapshot,
5536        projection: Vec<(u16, TypeId)>,
5537        conditions: &[crate::query::Condition],
5538    ) -> Result<Option<Box<dyn crate::cursor::Cursor>>> {
5539        // A deferred bulk load leaves the live indexes unbuilt; resolving
5540        // conditions against them would return silently-empty survivor sets.
5541        // Signal "can't serve" so the caller falls back to a `&mut` path that
5542        // runs `ensure_indexes_complete`. (Condition-free scans don't touch
5543        // the indexes and stay served.)
5544        if !conditions.is_empty() && !self.indexes_complete {
5545            return Ok(None);
5546        }
5547        if self.run_refs.len() == 1 {
5548            Ok(self
5549                .native_page_cursor(snapshot, projection, conditions)?
5550                .map(|c| Box::new(c) as Box<dyn crate::cursor::Cursor>))
5551        } else {
5552            Ok(self
5553                .native_multi_run_cursor(snapshot, projection, conditions)?
5554                .map(|c| Box::new(c) as Box<dyn crate::cursor::Cursor>))
5555        }
5556    }
5557
5558    /// Native vectorized aggregate over a (possibly filtered) column, in one
5559    /// pass over the typed buffers — no `Value`, no Arrow batch. Layout-agnostic:
5560    /// survivors stream through [`Self::scan_cursor`] (single- or multi-run,
5561    /// overlay-folded), so the same path serves every sorted-run layout.
5562    ///
5563    /// `column` is `None` for `COUNT(*)`. Order of attempts:
5564    /// 1. Single clean run + no `WHERE` ⇒ `MIN`/`MAX`/`COUNT(col)` straight from
5565    ///    page `min`/`max`/`null_count` (no decode).
5566    /// 2. `COUNT(*)` ⇒ survivor cardinality from the cursor's page plans.
5567    /// 3. Otherwise accumulate the projected column over the cursor.
5568    ///
5569    /// Returns `Ok(None)` (caller scans) when no native path applies: an
5570    /// overlay-only table with no sorted run, or a non-numeric column.
5571    pub fn aggregate_native(
5572        &self,
5573        snapshot: Snapshot,
5574        column: Option<u16>,
5575        conditions: &[crate::query::Condition],
5576        agg: NativeAgg,
5577    ) -> Result<Option<NativeAggResult>> {
5578        // 1. Single clean run + no WHERE ⇒ MIN/MAX/COUNT(col) from page stats.
5579        if self.run_refs.len() == 1 && conditions.is_empty() {
5580            if let Some(res) = self.aggregate_from_stats(snapshot, column, agg)? {
5581                return Ok(Some(res));
5582            }
5583        }
5584        // 2. COUNT(*) ⇒ survivor count from the cursor's page plans, no decode.
5585        if matches!(agg, NativeAgg::Count) && column.is_none() {
5586            return Ok(self
5587                .scan_cursor(snapshot, Vec::new(), conditions)?
5588                .map(|c| NativeAggResult::Count(c.remaining_rows() as u64)));
5589        }
5590        // 3. Accumulate the projected column. COUNT(col) excludes nulls — the
5591        //    accumulator's count is the non-null count, which `pack_*` returns.
5592        let cid = match column {
5593            Some(c) => c,
5594            None => return Ok(None),
5595        };
5596        let ty = self.column_type(cid);
5597        let Some(mut cursor) = self.scan_cursor(snapshot, vec![(cid, ty)], conditions)? else {
5598            return Ok(None);
5599        };
5600        match ty {
5601            TypeId::Int64 | TypeId::TimestampNanos | TypeId::Date32 => {
5602                let (count, sum, mn, mx) = accumulate_int(cursor.as_mut())?;
5603                Ok(Some(pack_int(agg, count, sum, mn, mx)))
5604            }
5605            TypeId::Float64 => {
5606                let (count, sum, mn, mx) = accumulate_float(cursor.as_mut())?;
5607                Ok(Some(pack_float(agg, count, sum, mn, mx)))
5608            }
5609            _ => Ok(None),
5610        }
5611    }
5612
5613    /// Phase 7.1 metadata fast path: answer an unfiltered `MIN`/`MAX`/`COUNT(col)`
5614    /// straight from page `min`/`max`/`null_count` — no column decode. Returns
5615    /// `None` (caller decodes) for `COUNT(*)`/`SUM`/`AVG`, when exact stats are
5616    /// unavailable (multi-version run; [`Table::exact_column_stats`] gates this),
5617    /// or for a column whose stats omit `min`/`max` while it still holds values
5618    /// (e.g. an encrypted column) — returning `NULL` there would be a wrong
5619    /// answer, so we fall back to decoding.
5620    fn aggregate_from_stats(
5621        &self,
5622        snapshot: Snapshot,
5623        column: Option<u16>,
5624        agg: NativeAgg,
5625    ) -> Result<Option<NativeAggResult>> {
5626        let cid = match (agg, column) {
5627            (NativeAgg::Count | NativeAgg::Min | NativeAgg::Max, Some(c)) => c,
5628            _ => return Ok(None), // COUNT(*), SUM, AVG: not served from page stats
5629        };
5630        let Some(stats) = self.exact_column_stats(snapshot, &[cid])? else {
5631            return Ok(None);
5632        };
5633        let Some(cs) = stats.get(&cid) else {
5634            return Ok(None);
5635        };
5636        match agg {
5637            // COUNT(col) excludes NULLs: live rows minus the column's null count.
5638            NativeAgg::Count => Ok(Some(NativeAggResult::Count(
5639                self.live_count.saturating_sub(cs.null_count),
5640            ))),
5641            NativeAgg::Min | NativeAgg::Max => {
5642                let bound = if agg == NativeAgg::Min {
5643                    &cs.min
5644                } else {
5645                    &cs.max
5646                };
5647                match bound {
5648                    Some(Value::Int64(x)) => Ok(Some(NativeAggResult::Int(*x))),
5649                    Some(Value::Float64(x)) => Ok(Some(NativeAggResult::Float(*x))),
5650                    Some(_) => Ok(None), // unexpected stat type ⇒ decode
5651                    // No bound: a genuine SQL NULL only when the column is wholly
5652                    // null. Otherwise the stats are simply unavailable (encrypted),
5653                    // so decode for a correct answer.
5654                    None if cs.null_count >= self.live_count => Ok(Some(NativeAggResult::Null)),
5655                    None => Ok(None),
5656                }
5657            }
5658            _ => Ok(None),
5659        }
5660    }
5661
5662    /// Phase 7.1c: exact `COUNT(DISTINCT col)` from the bitmap index's partition
5663    /// cardinality — the number of distinct indexed values — with no scan. Each
5664    /// distinct value is one bitmap key; under the insert-only invariant (empty
5665    /// overlay, single run, `live_count == row_count`) every key has at least one
5666    /// live row, so the key count is exact. `NULL` is excluded from
5667    /// `COUNT(DISTINCT)`, so a null key (from an explicit `Value::Null` put) is
5668    /// discounted. Returns `None` (caller scans) without a bitmap index on the
5669    /// column or when the invariant does not hold.
5670    pub fn count_distinct_from_bitmap(&mut self, column_id: u16) -> Result<Option<u64>> {
5671        if !(self.memtable.is_empty() && self.mutable_run.is_empty() && self.run_refs.len() == 1) {
5672            return Ok(None);
5673        }
5674        // A deferred bulk load leaves the bitmap unbuilt; complete it before
5675        // trusting its key count (same lazy contract as `query`/`flush`).
5676        self.ensure_indexes_complete()?;
5677        let reader = self.open_reader(self.run_refs[0].run_id)?;
5678        if self.live_count != reader.row_count() as u64 {
5679            return Ok(None);
5680        }
5681        let Some(bm) = self.bitmap.get(&column_id) else {
5682            return Ok(None); // no bitmap index ⇒ let the caller scan
5683        };
5684        let mut distinct = bm.value_count() as u64;
5685        // A null key (explicit `Value::Null`) is indexed but excluded from
5686        // COUNT(DISTINCT). (Schema-evolution-absent columns are never indexed.)
5687        if !bm.get(&Value::Null.encode_key()).is_empty() {
5688            distinct = distinct.saturating_sub(1);
5689        }
5690        Ok(Some(distinct))
5691    }
5692
5693    /// Incremental aggregate over the live table (Phase 8.3). For an append-only
5694    /// table, a warm cache entry (same `cache_key`) lets the result be refreshed
5695    /// by aggregating **only the newly inserted rows** (row-id watermark delta)
5696    /// and merging, instead of a full recompute. The caller supplies a stable
5697    /// `cache_key` (e.g. a hash of the SQL + projection); distinct queries must
5698    /// use distinct keys.
5699    ///
5700    /// Returns [`IncrementalAggResult`] with the merged state and whether the
5701    /// delta path was taken. A single `delete` (ever) disables the incremental
5702    /// path for the table, so correctness never relies on append-only behavior
5703    /// that deletes invalidate.
5704    pub fn aggregate_incremental(
5705        &mut self,
5706        cache_key: u64,
5707        conditions: &[crate::query::Condition],
5708        column: Option<u16>,
5709        agg: NativeAgg,
5710    ) -> Result<IncrementalAggResult> {
5711        let snap = self.snapshot();
5712        let cur_wm = self.allocator.current().0;
5713        let cur_epoch = snap.epoch.0;
5714        // The watermark equals the committed row count only when the memtable is
5715        // empty (every allocated row id is durably in a run). With pending
5716        // (uncommitted) writes the allocator is ahead of the visible set, so the
5717        // delta range would silently skip just-committed rows — disable the
5718        // incremental path entirely in that case. The mutable-run tier holding
5719        // un-spilled data also disables it (those rows aren't in a run yet).
5720        let incremental_ok =
5721            !self.had_deletes && self.memtable.is_empty() && self.mutable_run.is_empty();
5722
5723        // Incremental path: append-only, no pending writes, warm cache, advanced
5724        // epoch.
5725        if incremental_ok {
5726            if let Some(cached) = self.agg_cache.get(&cache_key).cloned() {
5727                if cached.epoch == cur_epoch {
5728                    return Ok(IncrementalAggResult {
5729                        state: cached.state,
5730                        incremental: true,
5731                        delta_rows: 0,
5732                    });
5733                }
5734                if cached.epoch < cur_epoch && cached.watermark <= cur_wm {
5735                    let delta_rids: Vec<u64> = (cached.watermark..cur_wm).collect();
5736                    let delta_rows = self.rows_for_rids(&delta_rids, snap)?;
5737                    let index_sets = self.resolve_index_conditions(conditions, snap)?;
5738                    let delta_state = agg_state_from_rows(
5739                        &delta_rows,
5740                        conditions,
5741                        &index_sets,
5742                        column,
5743                        agg,
5744                        &self.schema,
5745                    )?;
5746                    let merged = cached.state.merge(delta_state);
5747                    let delta_n = delta_rids.len() as u64;
5748                    self.agg_cache.insert(
5749                        cache_key,
5750                        CachedAgg {
5751                            state: merged.clone(),
5752                            watermark: cur_wm,
5753                            epoch: cur_epoch,
5754                        },
5755                    );
5756                    return Ok(IncrementalAggResult {
5757                        state: merged,
5758                        incremental: true,
5759                        delta_rows: delta_n,
5760                    });
5761                }
5762            }
5763        }
5764
5765        // Cold path. For Count/Sum/Min/Max the fast vectorized cursor produces a
5766        // directly-seedable state; for Avg it returns only the mean (losing the
5767        // sum+count needed to merge a future delta), so Avg falls back to a
5768        // visible-rows scan that captures both.
5769        let cursor_ok =
5770            self.memtable.is_empty() && self.mutable_run.is_empty() && self.run_refs.len() == 1;
5771        let state = if cursor_ok && agg != NativeAgg::Avg {
5772            match self.aggregate_native(snap, column, conditions, agg)? {
5773                Some(result) => {
5774                    AggState::from_native(result, agg, column.map(|c| self.column_type(c)))
5775                }
5776                None => self.agg_state_full_scan(conditions, column, agg, snap)?,
5777            }
5778        } else {
5779            self.agg_state_full_scan(conditions, column, agg, snap)?
5780        };
5781        // Seed only when the watermark is meaningful (no pending writes).
5782        if incremental_ok {
5783            self.agg_cache.insert(
5784                cache_key,
5785                CachedAgg {
5786                    state: state.clone(),
5787                    watermark: cur_wm,
5788                    epoch: cur_epoch,
5789                },
5790            );
5791        }
5792        Ok(IncrementalAggResult {
5793            state,
5794            incremental: false,
5795            delta_rows: 0,
5796        })
5797    }
5798
5799    /// Full visible-rows scan → [`AggState`] (cold path; captures sum+count for
5800    /// correct Avg seeding).
5801    fn agg_state_full_scan(
5802        &self,
5803        conditions: &[crate::query::Condition],
5804        column: Option<u16>,
5805        agg: NativeAgg,
5806        snap: Snapshot,
5807    ) -> Result<AggState> {
5808        let rows = self.visible_rows(snap)?;
5809        let index_sets = self.resolve_index_conditions(conditions, snap)?;
5810        agg_state_from_rows(&rows, conditions, &index_sets, column, agg, &self.schema)
5811    }
5812
5813    /// Resolve only the index-defined conditions (`Ann`/`SparseMatch`) to row-id
5814    /// sets for membership testing during row-wise aggregation.
5815    fn resolve_index_conditions(
5816        &self,
5817        conditions: &[crate::query::Condition],
5818        snapshot: Snapshot,
5819    ) -> Result<Vec<RowIdSet>> {
5820        use crate::query::Condition;
5821        let mut sets = Vec::new();
5822        for c in conditions {
5823            if matches!(
5824                c,
5825                Condition::Ann { .. }
5826                    | Condition::SparseMatch { .. }
5827                    | Condition::MinHashSimilar { .. }
5828            ) {
5829                sets.push(self.resolve_condition(c, snapshot)?);
5830            }
5831        }
5832        Ok(sets)
5833    }
5834
5835    fn column_type(&self, cid: u16) -> TypeId {
5836        self.schema
5837            .columns
5838            .iter()
5839            .find(|c| c.id == cid)
5840            .map(|c| c.ty)
5841            .unwrap_or(TypeId::Bytes)
5842    }
5843
5844    /// Approximate `COUNT`/`SUM`/`AVG` over a filtered set, computed from the
5845    /// in-memory reservoir sample (Phase 8.2). Returns a point estimate plus a
5846    /// normal-theory confidence interval at the supplied z-score (1.96 ≈ 95 %).
5847    ///
5848    /// The WHERE predicates are evaluated **exactly** on each sampled row (so
5849    /// LIKE/FM and equality/range contribute no index bias); `Ann`/`SparseMatch`
5850    /// are index-defined and resolved once to a row-id set that sampled rows are
5851    /// tested against. `Ok(None)` when there is no usable sample.
5852    pub fn approx_aggregate(
5853        &mut self,
5854        conditions: &[crate::query::Condition],
5855        column: Option<u16>,
5856        agg: ApproxAgg,
5857        z: f64,
5858    ) -> Result<Option<ApproxResult>> {
5859        use crate::query::Condition;
5860        self.ensure_reservoir_complete()?;
5861        let snapshot = self.snapshot();
5862        let n_pop = self.live_count;
5863        let sample_rids: Vec<u64> = self.reservoir.row_ids().to_vec();
5864        if sample_rids.is_empty() {
5865            return Ok(None);
5866        }
5867        // Materialize the live, non-deleted sampled rows.
5868        let live_sample = self.rows_for_rids(&sample_rids, snapshot)?;
5869        let s = live_sample.len();
5870        if s == 0 {
5871            return Ok(None);
5872        }
5873
5874        // Pre-resolve Ann/Sparse conditions (index-defined predicates) to row-id
5875        // sets; the per-row predicates below are evaluated exactly.
5876        let mut index_sets: Vec<RowIdSet> = Vec::new();
5877        for c in conditions {
5878            if matches!(
5879                c,
5880                Condition::Ann { .. }
5881                    | Condition::SparseMatch { .. }
5882                    | Condition::MinHashSimilar { .. }
5883            ) {
5884                index_sets.push(self.resolve_condition(c, snapshot)?);
5885            }
5886        }
5887
5888        // For Sum/Avg, gather the numeric column value of each passing row.
5889        let cid = match (agg, column) {
5890            (ApproxAgg::Count, _) => None,
5891            (_, Some(c)) => Some(c),
5892            _ => return Ok(None),
5893        };
5894        let mut passing_vals: Vec<f64> = Vec::with_capacity(s);
5895        for r in &live_sample {
5896            // Exact per-row predicate evaluation.
5897            if !conditions
5898                .iter()
5899                .all(|c| condition_matches_row(c, r, &self.schema))
5900            {
5901                continue;
5902            }
5903            // Ann/Sparse membership.
5904            if !index_sets.iter().all(|set| set.contains(r.row_id.0)) {
5905                continue;
5906            }
5907            if let Some(cid) = cid {
5908                if let Some(v) = as_f64(r.columns.get(&cid)) {
5909                    passing_vals.push(v);
5910                } // nulls ⇒ excluded (matching SQL AVG/SUM null semantics)
5911            } else {
5912                passing_vals.push(0.0); // placeholder for COUNT
5913            }
5914        }
5915        let m = passing_vals.len();
5916
5917        let (point, half) = match agg {
5918            ApproxAgg::Count => {
5919                // Proportion estimate scaled to the population.
5920                let p = m as f64 / s as f64;
5921                let point = n_pop as f64 * p;
5922                let var = if s > 1 {
5923                    n_pop as f64 * n_pop as f64 * p * (1.0 - p) / s as f64
5924                        * (1.0 - s as f64 / n_pop as f64).max(0.0)
5925                } else {
5926                    0.0
5927                };
5928                (point, z * var.sqrt())
5929            }
5930            ApproxAgg::Sum => {
5931                // Horvitz–Thompson: each sampled row represents n_pop/s rows.
5932                let y: Vec<f64> = live_sample
5933                    .iter()
5934                    .map(|r| {
5935                        let passes_row = conditions
5936                            .iter()
5937                            .all(|c| condition_matches_row(c, r, &self.schema))
5938                            && index_sets.iter().all(|set| set.contains(r.row_id.0));
5939                        if passes_row {
5940                            cid.and_then(|c| as_f64(r.columns.get(&c))).unwrap_or(0.0)
5941                        } else {
5942                            0.0
5943                        }
5944                    })
5945                    .collect();
5946                let mean_y = y.iter().sum::<f64>() / s as f64;
5947                let point = n_pop as f64 * mean_y;
5948                let var = if s > 1 {
5949                    let ss: f64 = y.iter().map(|v| (v - mean_y).powi(2)).sum();
5950                    let var_y = ss / (s - 1) as f64;
5951                    n_pop as f64 * n_pop as f64 * var_y / s as f64
5952                        * (1.0 - s as f64 / n_pop as f64).max(0.0)
5953                } else {
5954                    0.0
5955                };
5956                (point, z * var.sqrt())
5957            }
5958            ApproxAgg::Avg => {
5959                if m == 0 {
5960                    return Ok(Some(ApproxResult {
5961                        point: 0.0,
5962                        ci_low: 0.0,
5963                        ci_high: 0.0,
5964                        n_population: n_pop,
5965                        n_sample_live: s,
5966                        n_passing: 0,
5967                    }));
5968                }
5969                let mean = passing_vals.iter().sum::<f64>() / m as f64;
5970                let half = if m > 1 {
5971                    let ss: f64 = passing_vals.iter().map(|v| (v - mean).powi(2)).sum();
5972                    let sd = (ss / (m - 1) as f64).sqrt();
5973                    let fpc = (1.0 - s as f64 / n_pop as f64).max(0.0);
5974                    z * sd / (m as f64).sqrt() * fpc.sqrt()
5975                } else {
5976                    0.0
5977                };
5978                (mean, half)
5979            }
5980        };
5981
5982        Ok(Some(ApproxResult {
5983            point,
5984            ci_low: point - half,
5985            ci_high: point + half,
5986            n_population: n_pop,
5987            n_sample_live: s,
5988            n_passing: m,
5989        }))
5990    }
5991
5992    /// Exact per-column statistics for the analytical aggregate fast path
5993    /// (Phase 7.1: `MIN`/`MAX`/`COUNT(col)` from page stats). Returns `None`
5994    /// unless the table is effectively insert-only at `snapshot` — empty
5995    /// memtable, a single sorted run, and `live_count == run.row_count()` — so
5996    /// the run's page `min`/`max`/`null_count` are exact (no tombstoned or
5997    /// superseded versions skew them). Under deletes/updates the caller falls
5998    /// back to scanning.
5999    pub fn exact_column_stats(
6000        &self,
6001        _snapshot: Snapshot,
6002        projection: &[u16],
6003    ) -> Result<Option<HashMap<u16, ColumnStat>>> {
6004        if !(self.memtable.is_empty() && self.mutable_run.is_empty() && self.run_refs.len() == 1) {
6005            return Ok(None);
6006        }
6007        let reader = self.open_reader(self.run_refs[0].run_id)?;
6008        if self.live_count != reader.row_count() as u64 {
6009            return Ok(None);
6010        }
6011        let mut out = HashMap::new();
6012        for &cid in projection {
6013            let cdef = match self.schema.columns.iter().find(|c| c.id == cid) {
6014                Some(c) => c,
6015                None => continue,
6016            };
6017            // Absent column (schema evolution) ⇒ all rows null.
6018            let Some(stats) = reader.column_page_stats(cid) else {
6019                out.insert(
6020                    cid,
6021                    ColumnStat {
6022                        min: None,
6023                        max: None,
6024                        null_count: self.live_count,
6025                    },
6026                );
6027                continue;
6028            };
6029            let stat = match cdef.ty {
6030                TypeId::Int64 | TypeId::TimestampNanos | TypeId::Date32 => {
6031                    agg_int(stats, crate::sorted_run::be_i64).map(|(mn, mx, n)| ColumnStat {
6032                        min: mn.map(Value::Int64),
6033                        max: mx.map(Value::Int64),
6034                        null_count: n,
6035                    })
6036                }
6037                TypeId::Float64 => {
6038                    agg_float(stats, crate::sorted_run::be_f64).map(|(mn, mx, n)| ColumnStat {
6039                        min: mn.map(Value::Float64),
6040                        max: mx.map(Value::Float64),
6041                        null_count: n,
6042                    })
6043                }
6044                _ => None,
6045            };
6046            if let Some(s) = stat {
6047                out.insert(cid, s);
6048            }
6049        }
6050        Ok(Some(out))
6051    }
6052
6053    pub fn dir(&self) -> &Path {
6054        &self.dir
6055    }
6056
6057    pub fn schema(&self) -> &Schema {
6058        &self.schema
6059    }
6060
6061    pub(crate) fn prepare_alter_column(
6062        &mut self,
6063        column_name: &str,
6064        change: &AlterColumn,
6065    ) -> Result<ColumnDef> {
6066        if !self.pending_rows.is_empty() || !self.pending_dels.is_empty() {
6067            return Err(MongrelError::InvalidArgument(
6068                "ALTER COLUMN requires committing staged writes first".into(),
6069            ));
6070        }
6071        let old = self
6072            .schema
6073            .columns
6074            .iter()
6075            .find(|c| c.name == column_name)
6076            .cloned()
6077            .ok_or_else(|| MongrelError::Schema(format!("unknown column {column_name}")))?;
6078        let mut next = old.clone();
6079
6080        if let Some(name) = &change.name {
6081            let trimmed = name.trim();
6082            if trimmed.is_empty() {
6083                return Err(MongrelError::InvalidArgument(
6084                    "ALTER COLUMN name must not be empty".into(),
6085                ));
6086            }
6087            if trimmed != old.name && self.schema.columns.iter().any(|c| c.name == trimmed) {
6088                return Err(MongrelError::Schema(format!(
6089                    "column {trimmed} already exists"
6090                )));
6091            }
6092            next.name = trimmed.to_string();
6093        }
6094
6095        if let Some(ty) = change.ty {
6096            next.ty = ty;
6097        }
6098        if let Some(flags) = change.flags {
6099            validate_alter_column_flags(old.flags, flags)?;
6100            next.flags = flags;
6101        }
6102
6103        validate_alter_column_type(&self.schema, &old, &next, self.has_stored_versions())?;
6104        if old.flags.contains(ColumnFlags::NULLABLE)
6105            && !next.flags.contains(ColumnFlags::NULLABLE)
6106            && self.column_has_nulls(old.id)?
6107        {
6108            return Err(MongrelError::InvalidArgument(format!(
6109                "column '{}' contains NULL values",
6110                old.name
6111            )));
6112        }
6113        Ok(next)
6114    }
6115
6116    pub(crate) fn apply_altered_column(&mut self, column: ColumnDef) -> Result<()> {
6117        let idx = self
6118            .schema
6119            .columns
6120            .iter()
6121            .position(|c| c.id == column.id)
6122            .ok_or_else(|| MongrelError::Schema(format!("unknown column {}", column.id)))?;
6123        if self.schema.columns[idx] == column {
6124            return Ok(());
6125        }
6126        self.schema.columns[idx] = column;
6127        self.schema.schema_id = self.schema.schema_id.saturating_add(1);
6128        self.schema.validate_auto_increment()?;
6129        self.auto_inc = resolve_auto_inc(&self.schema);
6130        self.column_keys = build_column_keys(self.kek.as_deref(), &self.schema);
6131        write_schema(&self.dir, &self.schema)?;
6132        self.clear_result_cache();
6133        let _ = std::fs::remove_dir_all(self.dir.join("_shadow"));
6134        self.persist_manifest(self.current_epoch())?;
6135        Ok(())
6136    }
6137
6138    pub fn alter_column(&mut self, column_name: &str, change: AlterColumn) -> Result<ColumnDef> {
6139        let column = self.prepare_alter_column(column_name, &change)?;
6140        self.apply_altered_column(column.clone())?;
6141        Ok(column)
6142    }
6143
6144    fn column_has_nulls(&mut self, column_id: u16) -> Result<bool> {
6145        if self.live_count == 0 {
6146            return Ok(false);
6147        }
6148        let snap = self.snapshot();
6149        let columns = self.visible_columns_native(snap, Some(&[column_id]))?;
6150        Ok(columns
6151            .first()
6152            .map(|(_, col)| col.null_count(col.len()) != 0)
6153            .unwrap_or(true))
6154    }
6155
6156    fn has_stored_versions(&self) -> bool {
6157        !self.memtable.is_empty()
6158            || !self.mutable_run.is_empty()
6159            || self.run_refs.iter().any(|r| r.row_count > 0)
6160            || !self.retiring.is_empty()
6161    }
6162
6163    /// Add a column to the schema (schema evolution). Existing runs simply read
6164    /// back as null for the new column until re-written. Persists the new schema
6165    /// and manifest. The caller supplies the full [`ColumnFlags`] so migrations
6166    /// can add `PRIMARY KEY` / `AUTO_INCREMENT` columns correctly.
6167    pub fn add_column(&mut self, name: &str, ty: TypeId, flags: ColumnFlags) -> Result<u16> {
6168        if self.schema.columns.iter().any(|c| c.name == name) {
6169            return Err(MongrelError::Schema(format!(
6170                "column {name} already exists"
6171            )));
6172        }
6173        let id = self.schema.columns.iter().map(|c| c.id).max().unwrap_or(0) + 1;
6174        self.schema.columns.push(ColumnDef {
6175            id,
6176            name: name.to_string(),
6177            ty,
6178            flags,
6179        });
6180        self.schema.schema_id = self.schema.schema_id.saturating_add(1);
6181        self.schema.validate_auto_increment()?;
6182        if flags.contains(ColumnFlags::AUTO_INCREMENT) {
6183            self.auto_inc = resolve_auto_inc(&self.schema);
6184        }
6185        write_schema(&self.dir, &self.schema)?;
6186        self.clear_result_cache();
6187        // Phase 15.5: invalidate Arrow IPC shadows (schema changed).
6188        let _ = std::fs::remove_dir_all(self.dir.join("_shadow"));
6189        self.persist_manifest(self.current_epoch())?;
6190        Ok(id)
6191    }
6192
6193    /// Declare a `LearnedRange` (PGM) index on an existing numeric column and
6194    /// build it immediately from the current sorted run (Phase 13.3). After
6195    /// this, `Condition::Range` / `Condition::RangeF64` on that column resolve
6196    /// survivors sub-linearly (O(log segments + log ε)) instead of scanning the
6197    /// full column.
6198    ///
6199    /// Requires exactly one sorted run (call after `flush`). The index is
6200    /// rebuilt automatically on subsequent flushes.
6201    pub fn add_learned_range_index(&mut self, column_name: &str) -> Result<()> {
6202        let cid = self
6203            .schema
6204            .columns
6205            .iter()
6206            .find(|c| c.name == column_name)
6207            .map(|c| c.id)
6208            .ok_or_else(|| MongrelError::Schema(format!("unknown column {column_name}")))?;
6209        let ty = self
6210            .schema
6211            .columns
6212            .iter()
6213            .find(|c| c.id == cid)
6214            .map(|c| c.ty)
6215            .unwrap_or(TypeId::Int64);
6216        if !matches!(
6217            ty,
6218            TypeId::Int64 | TypeId::Float64 | TypeId::TimestampNanos | TypeId::Date32
6219        ) {
6220            return Err(MongrelError::Schema(format!(
6221                "LearnedRange requires a numeric column; {column_name} is {ty:?}"
6222            )));
6223        }
6224        if self
6225            .schema
6226            .indexes
6227            .iter()
6228            .any(|i| i.column_id == cid && i.kind == IndexKind::LearnedRange)
6229        {
6230            return Ok(()); // already declared
6231        }
6232        self.schema.indexes.push(IndexDef {
6233            name: format!("{}_learned_range", column_name),
6234            column_id: cid,
6235            kind: IndexKind::LearnedRange,
6236            predicate: None,
6237        });
6238        self.schema.schema_id = self.schema.schema_id.saturating_add(1);
6239        write_schema(&self.dir, &self.schema)?;
6240        self.build_learned_ranges()?;
6241        Ok(())
6242    }
6243
6244    /// Tuning knob for the WAL auto-sync threshold. A no-op on a mounted table
6245    /// (the shared WAL's durability is governed by the group-commit coordinator).
6246    pub fn set_sync_byte_threshold(&mut self, threshold: u64) {
6247        self.sync_byte_threshold = threshold;
6248        if let WalSink::Private(w) = &mut self.wal {
6249            w.set_sync_byte_threshold(threshold);
6250        }
6251    }
6252
6253    /// Flush all live page-cache entries to the persistent `_cache/` backing
6254    /// directory (best-effort). Useful before a clean shutdown so hot pages
6255    /// survive restart.
6256    pub fn page_cache_flush(&self) {
6257        self.page_cache.flush_to_disk();
6258    }
6259
6260    /// Number of entries currently in the shared page cache (diagnostic).
6261    pub fn page_cache_len(&self) -> usize {
6262        self.page_cache.len()
6263    }
6264
6265    /// Number of entries currently in the shared decoded-page cache (Phase
6266    /// 15.4 diagnostic).
6267    pub fn decoded_cache_len(&self) -> usize {
6268        self.decoded_cache.len()
6269    }
6270
6271    /// Drain the live memtable (prototype/testing helper used by the flush path
6272    /// demos). Prefer [`Table::flush`] for the durable path.
6273    pub fn drain_memtable_sorted(&mut self) -> Vec<Row> {
6274        self.memtable.drain_sorted()
6275    }
6276
6277    pub(crate) fn run_path(&self, run_id: u64) -> PathBuf {
6278        self.dir.join(RUNS_DIR).join(format!("r-{run_id}.sr"))
6279    }
6280
6281    pub(crate) fn table_dir(&self) -> &Path {
6282        &self.dir
6283    }
6284
6285    pub(crate) fn schema_ref(&self) -> &crate::schema::Schema {
6286        &self.schema
6287    }
6288
6289    pub(crate) fn alloc_run_id(&mut self) -> u64 {
6290        let id = self.next_run_id;
6291        self.next_run_id += 1;
6292        id
6293    }
6294
6295    pub(crate) fn link_run(&mut self, run_ref: crate::manifest::RunRef) {
6296        self.run_refs.push(run_ref);
6297    }
6298
6299    /// Link a spilled run found during shared-WAL recovery (spec §8.5).
6300    /// **Idempotent**: if the run is already in the manifest (the publish phase
6301    /// persisted it before the crash, or this is a clean reopen with the
6302    /// `TxnCommit` still in the WAL) this is a no-op returning `false`, so the
6303    /// caller never double-links or double-counts. Otherwise — a crash *after*
6304    /// the commit fsync but *before* publish persisted the manifest — the run is
6305    /// Enqueue a compaction-superseded run for retention-gated deletion (spec
6306    /// §6.4). The file stays on disk until [`Self::reap_retiring`] removes it
6307    /// once `min_active_snapshot` has advanced past `retire_epoch`.
6308    pub(crate) fn retire_run(&mut self, run_id: u128, retire_epoch: u64) {
6309        self.retiring.push(crate::manifest::RetiredRun {
6310            run_id,
6311            retire_epoch,
6312        });
6313    }
6314
6315    /// Physically delete retired run files whose `retire_epoch` no pinned reader
6316    /// can still need (`min_active >= retire_epoch`), drop them from the queue,
6317    /// and persist the manifest if anything changed. Returns the count reaped.
6318    pub(crate) fn reap_retiring(&mut self, min_active: Epoch) -> Result<usize> {
6319        if self.retiring.is_empty() {
6320            return Ok(0);
6321        }
6322        let mut reaped = 0;
6323        let mut kept: Vec<crate::manifest::RetiredRun> = Vec::new();
6324        // Delete-then-persist is crash-idempotent: if we crash after unlinking
6325        // some files but before persisting, the manifest still lists them in
6326        // `retiring`; the next `reap_retiring` re-issues `remove_file` (the
6327        // error is ignored) and `check()` excludes `retiring` ids from orphan
6328        // detection, so the lingering entries are harmless until then.
6329        for r in std::mem::take(&mut self.retiring) {
6330            if min_active.0 >= r.retire_epoch {
6331                let _ = std::fs::remove_file(self.run_path(r.run_id as u64));
6332                reaped += 1;
6333            } else {
6334                kept.push(r);
6335            }
6336        }
6337        self.retiring = kept;
6338        if reaped > 0 {
6339            self.persist_manifest(self.current_epoch())?;
6340        }
6341        Ok(reaped)
6342    }
6343
6344    pub(crate) fn recover_spilled_run(&mut self, run_ref: crate::manifest::RunRef) -> bool {
6345        if self.run_refs.iter().any(|r| r.run_id == run_ref.run_id) {
6346            return false;
6347        }
6348        self.live_count = self.live_count.saturating_add(run_ref.row_count);
6349        self.run_refs.push(run_ref);
6350        self.indexes_complete = false;
6351        true
6352    }
6353
6354    pub(crate) fn kek_ref(&self) -> Option<&Arc<Kek>> {
6355        self.kek.as_ref()
6356    }
6357
6358    pub(crate) fn open_reader(&self, run_id: u128) -> Result<RunReader> {
6359        let mut reader = RunReader::open_with_cache(
6360            self.dir.join(RUNS_DIR).join(format!("r-{run_id}.sr")),
6361            self.schema.clone(),
6362            self.kek.clone(),
6363            Some(self.page_cache.clone()),
6364            Some(self.decoded_cache.clone()),
6365            self.table_id,
6366            Some(&self.verified_runs),
6367        )?;
6368        // Overlay the real commit epoch for uniform-epoch (large-txn spill) runs:
6369        // their stored `_epoch` is a placeholder; the manifest RunRef carries the
6370        // assigned epoch. A no-op for ordinary runs.
6371        if let Some(rr) = self.run_refs.iter().find(|r| r.run_id == run_id) {
6372            reader.set_uniform_epoch(Epoch(rr.epoch_created));
6373        }
6374        Ok(reader)
6375    }
6376
6377    pub(crate) fn run_refs(&self) -> &[RunRef] {
6378        &self.run_refs
6379    }
6380
6381    pub(crate) fn runs_dir(&self) -> PathBuf {
6382        self.dir.join(RUNS_DIR)
6383    }
6384
6385    pub(crate) fn wal_dir(&self) -> PathBuf {
6386        self.dir.join(WAL_DIR)
6387    }
6388
6389    pub(crate) fn set_run_refs(&mut self, refs: Vec<RunRef>) {
6390        self.run_refs = refs;
6391    }
6392
6393    pub(crate) fn next_run_id(&self) -> u64 {
6394        self.next_run_id
6395    }
6396
6397    pub(crate) fn compaction_zstd_level(&self) -> i32 {
6398        self.compaction_zstd_level
6399    }
6400
6401    pub(crate) fn bump_next_run_id(&mut self) {
6402        self.next_run_id += 1;
6403    }
6404
6405    pub(crate) fn kek(&self) -> Option<Arc<Kek>> {
6406        self.kek.clone()
6407    }
6408
6409    /// The index-checkpoint DEK (KEK-derived) for encrypted tables; `None` for
6410    /// plaintext tables. The checkpoint embeds index keys / PGM segment values
6411    /// derived from user data, so an encrypted table must encrypt it at rest.
6412    #[cfg(feature = "encryption")]
6413    fn idx_dek(&self) -> Option<Zeroizing<[u8; DEK_LEN]>> {
6414        self.kek.as_ref().map(|k| k.derive_idx_key())
6415    }
6416
6417    #[cfg(not(feature = "encryption"))]
6418    fn idx_dek(&self) -> Option<Zeroizing<[u8; DEK_LEN]>> {
6419        None
6420    }
6421
6422    /// Manifest (and other DB-wide metadata) meta DEK, derived from the KEK so
6423    /// the on-disk manifest is encrypted + authenticated at rest for encrypted
6424    /// tables. `None` for plaintext.
6425    #[cfg(feature = "encryption")]
6426    fn manifest_meta_dek(&self) -> Option<[u8; DEK_LEN]> {
6427        self.kek.as_ref().map(|k| *k.derive_meta_key())
6428    }
6429
6430    #[cfg(not(feature = "encryption"))]
6431    fn manifest_meta_dek(&self) -> Option<[u8; DEK_LEN]> {
6432        None
6433    }
6434
6435    /// `(column_id, scheme)` for every ENCRYPTED_INDEXABLE column — passed to
6436    /// the run writer so each run's descriptor records the column keys.
6437    pub(crate) fn indexable_column_specs(&self) -> Vec<(u16, u8)> {
6438        self.column_keys
6439            .iter()
6440            .map(|(&id, &(_, scheme))| (id, scheme))
6441            .collect()
6442    }
6443
6444    /// Tokenize a value for an ENCRYPTED_INDEXABLE column (HMAC-eq or OPE-range,
6445    /// per the column's scheme). Returns `None` for plaintext columns. Indexes
6446    /// over such columns store tokens, and queries tokenize literals the same
6447    /// way — so lookups never decrypt the stored (encrypted) page payloads.
6448    #[cfg(feature = "encryption")]
6449    fn tokenize_value(&self, column_id: u16, v: &Value) -> Option<Value> {
6450        self.tokenize_value_enc(column_id, v)
6451    }
6452
6453    #[cfg(feature = "encryption")]
6454    fn tokenize_value_enc(&self, column_id: u16, v: &Value) -> Option<Value> {
6455        use crate::encryption::{hmac_token, ope_token_f64, ope_token_i64, SCHEME_HMAC_EQ};
6456        let (key, scheme) = self.column_keys.get(&column_id)?;
6457        let token: Vec<u8> = match (*scheme, v) {
6458            (SCHEME_HMAC_EQ, _) => hmac_token(key, &v.encode_key()).to_vec(),
6459            (_, Value::Int64(x)) => ope_token_i64(key, *x).to_vec(),
6460            (_, Value::Float64(x)) => ope_token_f64(key, *x).to_vec(),
6461            _ => hmac_token(key, &v.encode_key()).to_vec(),
6462        };
6463        Some(Value::Bytes(token))
6464    }
6465
6466    /// Encoded index key for a `Value`, tokenized for HMAC-eq columns.
6467    fn index_lookup_key(&self, column_id: u16, v: &Value) -> Vec<u8> {
6468        self.index_lookup_key_bytes(column_id, &v.encode_key())
6469    }
6470
6471    /// Tokenize an already-encoded lookup key (equality queries pass the
6472    /// encoded search value; HMAC-eq columns wrap it under the column key).
6473    fn index_lookup_key_bytes(&self, column_id: u16, encoded: &[u8]) -> Vec<u8> {
6474        #[cfg(feature = "encryption")]
6475        {
6476            use crate::encryption::{hmac_token, SCHEME_HMAC_EQ};
6477            if let Some((key, scheme)) = self.column_keys.get(&column_id) {
6478                if *scheme == SCHEME_HMAC_EQ {
6479                    return hmac_token(key, encoded).to_vec();
6480                }
6481            }
6482        }
6483        let _ = column_id;
6484        encoded.to_vec()
6485    }
6486}
6487
6488fn native_int64_strictly_increasing(col: &columnar::NativeColumn, n: usize) -> bool {
6489    let columnar::NativeColumn::Int64 { data, validity } = col else {
6490        return false;
6491    };
6492    if data.len() < n || !columnar::all_non_null(validity, n) {
6493        return false;
6494    }
6495    data.iter()
6496        .take(n)
6497        .zip(data.iter().skip(1))
6498        .all(|(a, b)| a < b)
6499}
6500
6501/// Exact aggregate of a column's page stats into a min/max/null_count triple
6502/// (Phase 7.1). Only meaningful when the owning table is insert-only, which
6503/// [`Table::exact_column_stats`] gates on.
6504#[derive(Debug, Clone)]
6505pub struct ColumnStat {
6506    pub min: Option<Value>,
6507    pub max: Option<Value>,
6508    pub null_count: u64,
6509}
6510
6511/// A supported native aggregate (Phase 7.2).
6512#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6513pub enum NativeAgg {
6514    Count,
6515    Sum,
6516    Min,
6517    Max,
6518    Avg,
6519}
6520
6521/// The typed result of a [`NativeAgg`] over a column.
6522#[derive(Debug, Clone, PartialEq)]
6523pub enum NativeAggResult {
6524    Count(u64),
6525    Int(i64),
6526    Float(f64),
6527    /// No non-null inputs (SUM/MIN/MAX/AVG over zero rows ⇒ SQL NULL).
6528    Null,
6529}
6530
6531/// A supported approximate aggregate over the reservoir sample (Phase 8.2).
6532#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6533pub enum ApproxAgg {
6534    Count,
6535    Sum,
6536    Avg,
6537}
6538
6539/// Point estimate with a normal-theory confidence interval from the reservoir
6540/// sample (Phase 8.2). `ci_low`/`ci_high` bracket `point` at the requested
6541/// z-score; the interval has zero width when the sample equals the whole table.
6542#[derive(Debug, Clone)]
6543pub struct ApproxResult {
6544    /// Point estimate of the aggregate.
6545    pub point: f64,
6546    /// Lower bound (`point − z·SE`).
6547    pub ci_low: f64,
6548    /// Upper bound (`point + z·SE`).
6549    pub ci_high: f64,
6550    /// Live population size (the table's `count()`).
6551    pub n_population: u64,
6552    /// Live rows in the sample (`≤` reservoir capacity).
6553    pub n_sample_live: usize,
6554    /// Sampled rows passing the WHERE predicate.
6555    pub n_passing: usize,
6556}
6557
6558/// A mergeable running aggregate state (Phase 8.3). Two states over disjoint
6559/// row sets `merge` into the state over their union, so a cached analytical
6560/// aggregate can be updated by merging in only the delta (newly inserted rows)
6561/// instead of a full recompute.
6562#[derive(Debug, Clone, PartialEq)]
6563pub enum AggState {
6564    /// `COUNT(*)` or `COUNT(col)` over `n` matching rows.
6565    Count(u64),
6566    /// Int64 `SUM`: running `i128` sum + non-null count.
6567    SumI {
6568        sum: i128,
6569        count: u64,
6570    },
6571    /// Float64 `SUM`: running `f64` sum + non-null count.
6572    SumF {
6573        sum: f64,
6574        count: u64,
6575    },
6576    /// Int64 `AVG`: running `i128` sum + non-null count (avg = sum/count).
6577    AvgI {
6578        sum: i128,
6579        count: u64,
6580    },
6581    /// Float64 `AVG`: running `f64` sum + non-null count.
6582    AvgF {
6583        sum: f64,
6584        count: u64,
6585    },
6586    /// Int64 `MIN`/`MAX`.
6587    MinI(i64),
6588    MaxI(i64),
6589    /// Float64 `MIN`/`MAX`.
6590    MinF(f64),
6591    MaxF(f64),
6592    /// No matching rows observed yet.
6593    Empty,
6594}
6595
6596impl AggState {
6597    /// Combine two states over disjoint row sets into the state over the union.
6598    pub fn merge(self, other: AggState) -> AggState {
6599        use AggState::*;
6600        match (self, other) {
6601            (Empty, x) | (x, Empty) => x,
6602            (Count(a), Count(b)) => Count(a + b),
6603            (SumI { sum: sa, count: ca }, SumI { sum: sb, count: cb }) => SumI {
6604                sum: sa + sb,
6605                count: ca + cb,
6606            },
6607            (SumF { sum: sa, count: ca }, SumF { sum: sb, count: cb }) => SumF {
6608                sum: sa + sb,
6609                count: ca + cb,
6610            },
6611            (AvgI { sum: sa, count: ca }, AvgI { sum: sb, count: cb }) => AvgI {
6612                sum: sa + sb,
6613                count: ca + cb,
6614            },
6615            (AvgF { sum: sa, count: ca }, AvgF { sum: sb, count: cb }) => AvgF {
6616                sum: sa + sb,
6617                count: ca + cb,
6618            },
6619            (MinI(a), MinI(b)) => MinI(a.min(b)),
6620            (MaxI(a), MaxI(b)) => MaxI(a.max(b)),
6621            (MinF(a), MinF(b)) => MinF(a.min(b)),
6622            (MaxF(a), MaxF(b)) => MaxF(a.max(b)),
6623            _ => Empty, // mismatched kinds — shouldn't happen (same query)
6624        }
6625    }
6626
6627    /// The scalar point value (`f64`), or `None` when there were no inputs.
6628    pub fn point(&self) -> Option<f64> {
6629        match self {
6630            AggState::Count(n) => Some(*n as f64),
6631            AggState::SumI { sum, .. } => Some(*sum as f64),
6632            AggState::SumF { sum, .. } => Some(*sum),
6633            AggState::AvgI { sum, count } if *count > 0 => Some(*sum as f64 / *count as f64),
6634            AggState::AvgF { sum, count } if *count > 0 => Some(*sum / *count as f64),
6635            AggState::MinI(n) => Some(*n as f64),
6636            AggState::MaxI(n) => Some(*n as f64),
6637            AggState::MinF(n) => Some(*n),
6638            AggState::MaxF(n) => Some(*n),
6639            AggState::AvgI { .. } | AggState::AvgF { .. } | AggState::Empty => None,
6640        }
6641    }
6642
6643    /// Convert a vectorized [`NativeAggResult`] (from the cursor path) into a
6644    /// mergeable [`AggState`], so the incremental cache can be seeded from the
6645    /// fast cold path. `ty` is the column's type (`None` for COUNT(*)).
6646    pub fn from_native(result: NativeAggResult, agg: NativeAgg, ty: Option<TypeId>) -> Self {
6647        let is_float = matches!(ty, Some(TypeId::Float64));
6648        match (agg, result) {
6649            (NativeAgg::Count, NativeAggResult::Count(n)) => AggState::Count(n),
6650            (NativeAgg::Sum, NativeAggResult::Int(x)) => AggState::SumI {
6651                sum: x as i128,
6652                count: 1, // count unknown from NativeAggResult; use sentinel
6653            },
6654            (NativeAgg::Sum, NativeAggResult::Float(x)) => AggState::SumF { sum: x, count: 1 },
6655            (NativeAgg::Avg, NativeAggResult::Float(x)) => AggState::AvgF { sum: x, count: 1 },
6656            (NativeAgg::Min, NativeAggResult::Int(x)) => AggState::MinI(x),
6657            (NativeAgg::Max, NativeAggResult::Int(x)) => AggState::MaxI(x),
6658            (NativeAgg::Min, NativeAggResult::Float(x)) => AggState::MinF(x),
6659            (NativeAgg::Max, NativeAggResult::Float(x)) => AggState::MaxF(x),
6660            (NativeAgg::Count, _) => AggState::Empty,
6661            (_, NativeAggResult::Null) => AggState::Empty,
6662            _ => {
6663                let _ = is_float;
6664                AggState::Empty
6665            }
6666        }
6667    }
6668}
6669
6670/// A cached incremental aggregate (Phase 8.3): the mergeable state, the row-id
6671/// watermark it covers (rows `[0, watermark)`), and the snapshot epoch.
6672#[derive(Debug, Clone)]
6673pub struct CachedAgg {
6674    pub state: AggState,
6675    pub watermark: u64,
6676    pub epoch: u64,
6677}
6678
6679/// Outcome of [`Table::aggregate_incremental`].
6680#[derive(Debug, Clone)]
6681pub struct IncrementalAggResult {
6682    /// The aggregate state covering all rows at the current epoch.
6683    pub state: AggState,
6684    /// `true` when produced by merging only the delta (new rows); `false` when
6685    /// a full recompute was required (cold cache, deletes, or same epoch).
6686    pub incremental: bool,
6687    /// Rows processed in the delta pass (`0` for a full recompute).
6688    pub delta_rows: u64,
6689}
6690
6691/// Compute a mergeable [`AggState`] over `rows` that pass every per-row
6692/// `conditions` conjunct (and whose row id is in every pre-resolved
6693/// `index_sets`). Shared by the cold (full) and warm (delta) incremental paths.
6694fn agg_state_from_rows(
6695    rows: &[Row],
6696    conditions: &[crate::query::Condition],
6697    index_sets: &[RowIdSet],
6698    column: Option<u16>,
6699    agg: NativeAgg,
6700    schema: &Schema,
6701) -> Result<AggState> {
6702    let mut count: u64 = 0;
6703    let mut sum_i: i128 = 0;
6704    let mut sum_f: f64 = 0.0;
6705    let mut mn_i: i64 = i64::MAX;
6706    let mut mx_i: i64 = i64::MIN;
6707    let mut mn_f: f64 = f64::INFINITY;
6708    let mut mx_f: f64 = f64::NEG_INFINITY;
6709    let mut saw_int = false;
6710    let mut saw_float = false;
6711    for r in rows {
6712        if !conditions
6713            .iter()
6714            .all(|c| condition_matches_row(c, r, schema))
6715        {
6716            continue;
6717        }
6718        if !index_sets.iter().all(|s| s.contains(r.row_id.0)) {
6719            continue;
6720        }
6721        match agg {
6722            NativeAgg::Count => match column {
6723                // COUNT(*) counts every passing row.
6724                None => count += 1,
6725                // COUNT(col) excludes NULLs — explicit `Value::Null` and a column
6726                // absent from the row (schema evolution) are both NULL.
6727                Some(cid) => match r.columns.get(&cid) {
6728                    None | Some(Value::Null) => {}
6729                    Some(_) => count += 1,
6730                },
6731            },
6732            _ => match column.and_then(|cid| r.columns.get(&cid)) {
6733                Some(Value::Int64(n)) => {
6734                    count += 1;
6735                    sum_i += *n as i128;
6736                    mn_i = mn_i.min(*n);
6737                    mx_i = mx_i.max(*n);
6738                    saw_int = true;
6739                }
6740                Some(Value::Float64(f)) => {
6741                    count += 1;
6742                    sum_f += f;
6743                    mn_f = mn_f.min(*f);
6744                    mx_f = mx_f.max(*f);
6745                    saw_float = true;
6746                }
6747                _ => {}
6748            },
6749        }
6750    }
6751    Ok(match agg {
6752        NativeAgg::Count => {
6753            if count == 0 {
6754                AggState::Empty
6755            } else {
6756                AggState::Count(count)
6757            }
6758        }
6759        NativeAgg::Sum => {
6760            if count == 0 {
6761                AggState::Empty
6762            } else if saw_int {
6763                AggState::SumI { sum: sum_i, count }
6764            } else {
6765                AggState::SumF { sum: sum_f, count }
6766            }
6767        }
6768        NativeAgg::Avg => {
6769            if count == 0 {
6770                AggState::Empty
6771            } else if saw_int {
6772                AggState::AvgI { sum: sum_i, count }
6773            } else {
6774                AggState::AvgF { sum: sum_f, count }
6775            }
6776        }
6777        NativeAgg::Min => {
6778            if !saw_int && !saw_float {
6779                AggState::Empty
6780            } else if saw_int {
6781                AggState::MinI(mn_i)
6782            } else {
6783                AggState::MinF(mn_f)
6784            }
6785        }
6786        NativeAgg::Max => {
6787            if !saw_int && !saw_float {
6788                AggState::Empty
6789            } else if saw_int {
6790                AggState::MaxI(mx_i)
6791            } else {
6792                AggState::MaxF(mx_f)
6793            }
6794        }
6795    })
6796}
6797
6798/// Evaluate an index-served [`Condition`] exactly against a materialized row.
6799/// `Ann`/`SparseMatch` (index-defined) always pass here; callers test those via a
6800/// pre-resolved row-id set.
6801fn condition_matches_row(c: &crate::query::Condition, row: &Row, schema: &Schema) -> bool {
6802    use crate::query::Condition;
6803    match c {
6804        Condition::Pk(key) => match schema.primary_key() {
6805            Some(pk) => row
6806                .columns
6807                .get(&pk.id)
6808                .map(|v| v.encode_key() == *key)
6809                .unwrap_or(false),
6810            None => false,
6811        },
6812        Condition::BitmapEq { column_id, value } => row
6813            .columns
6814            .get(column_id)
6815            .map(|v| v.encode_key() == *value)
6816            .unwrap_or(false),
6817        Condition::BitmapIn { column_id, values } => {
6818            let key = row.columns.get(column_id).map(|v| v.encode_key());
6819            match key {
6820                Some(k) => values.contains(&k),
6821                None => false,
6822            }
6823        }
6824        Condition::BytesPrefix { column_id, prefix } => row
6825            .columns
6826            .get(column_id)
6827            .map(|v| v.encode_key().starts_with(prefix))
6828            .unwrap_or(false),
6829        Condition::Range { column_id, lo, hi } => match row.columns.get(column_id) {
6830            Some(Value::Int64(n)) => *n >= *lo && *n <= *hi,
6831            _ => false,
6832        },
6833        Condition::RangeF64 {
6834            column_id,
6835            lo,
6836            lo_inclusive,
6837            hi,
6838            hi_inclusive,
6839        } => match row.columns.get(column_id) {
6840            Some(Value::Float64(n)) => {
6841                let lo_ok = if *lo_inclusive { *n >= *lo } else { *n > *lo };
6842                let hi_ok = if *hi_inclusive { *n <= *hi } else { *n < *hi };
6843                lo_ok && hi_ok
6844            }
6845            _ => false,
6846        },
6847        Condition::FmContains { column_id, pattern } => match row.columns.get(column_id) {
6848            Some(Value::Bytes(b)) => {
6849                !pattern.is_empty() && b.windows(pattern.len()).any(|w| w == &pattern[..])
6850            }
6851            _ => false,
6852        },
6853        Condition::FmContainsAll {
6854            column_id,
6855            patterns,
6856        } => match row.columns.get(column_id) {
6857            Some(Value::Bytes(b)) => patterns
6858                .iter()
6859                .all(|pat| !pat.is_empty() && b.windows(pat.len()).any(|w| w == &pat[..])),
6860            _ => false,
6861        },
6862        Condition::Ann { .. }
6863        | Condition::SparseMatch { .. }
6864        | Condition::MinHashSimilar { .. } => true,
6865        Condition::IsNull { column_id } => {
6866            matches!(row.columns.get(column_id), Some(Value::Null) | None)
6867        }
6868        Condition::IsNotNull { column_id } => {
6869            !matches!(row.columns.get(column_id), Some(Value::Null) | None)
6870        }
6871    }
6872}
6873
6874/// Coerce a cell to `f64` for Sum/Avg (Int64/Float64 only).
6875fn as_f64(v: Option<&Value>) -> Option<f64> {
6876    match v {
6877        Some(Value::Int64(n)) => Some(*n as f64),
6878        Some(Value::Float64(f)) => Some(*f),
6879        _ => None,
6880    }
6881}
6882
6883/// One-pass vectorized accumulation of `(non-null count, sum, min, max)` over an
6884/// Int64 column streamed through `cursor`. The inner loop over a contiguous
6885/// `&[i64]` autovectorizes (SIMD) for the all-non-null prefix.
6886fn accumulate_int(cursor: &mut dyn crate::cursor::Cursor) -> Result<(u64, i128, i64, i64)> {
6887    let mut count: u64 = 0;
6888    let mut sum: i128 = 0;
6889    let mut mn: i64 = i64::MAX;
6890    let mut mx: i64 = i64::MIN;
6891    while let Some(cols) = cursor.next_batch()? {
6892        if let Some(crate::columnar::NativeColumn::Int64 { data, validity }) = cols.first() {
6893            if crate::columnar::all_non_null(validity, data.len()) {
6894                // All-non-null: vectorized sum/min/max with no per-element branch.
6895                count += data.len() as u64;
6896                sum += data.iter().map(|&v| v as i128).sum::<i128>();
6897                mn = mn.min(*data.iter().min().unwrap_or(&mn));
6898                mx = mx.max(*data.iter().max().unwrap_or(&mx));
6899            } else {
6900                for (i, &v) in data.iter().enumerate() {
6901                    if crate::columnar::validity_bit(validity, i) {
6902                        count += 1;
6903                        sum += v as i128;
6904                        mn = mn.min(v);
6905                        mx = mx.max(v);
6906                    }
6907                }
6908            }
6909        }
6910    }
6911    Ok((count, sum, mn, mx))
6912}
6913
6914/// f64 analogue of [`accumulate_int`].
6915fn accumulate_float(cursor: &mut dyn crate::cursor::Cursor) -> Result<(u64, f64, f64, f64)> {
6916    let mut count: u64 = 0;
6917    let mut sum: f64 = 0.0;
6918    let mut mn: f64 = f64::INFINITY;
6919    let mut mx: f64 = f64::NEG_INFINITY;
6920    while let Some(cols) = cursor.next_batch()? {
6921        if let Some(crate::columnar::NativeColumn::Float64 { data, validity }) = cols.first() {
6922            if crate::columnar::all_non_null(validity, data.len()) {
6923                count += data.len() as u64;
6924                sum += data.iter().sum::<f64>();
6925                mn = mn.min(data.iter().copied().fold(f64::INFINITY, f64::min));
6926                mx = mx.max(data.iter().copied().fold(f64::NEG_INFINITY, f64::max));
6927            } else {
6928                for (i, &v) in data.iter().enumerate() {
6929                    if crate::columnar::validity_bit(validity, i) {
6930                        count += 1;
6931                        sum += v;
6932                        mn = mn.min(v);
6933                        mx = mx.max(v);
6934                    }
6935                }
6936            }
6937        }
6938    }
6939    Ok((count, sum, mn, mx))
6940}
6941
6942fn pack_int(agg: NativeAgg, count: u64, sum: i128, mn: i64, mx: i64) -> NativeAggResult {
6943    if count == 0 && !matches!(agg, NativeAgg::Count) {
6944        return NativeAggResult::Null;
6945    }
6946    match agg {
6947        NativeAgg::Count => NativeAggResult::Count(count),
6948        // i64 overflow on Sum ⇒ SQL NULL (DataFusion errors on overflow; null is
6949        // a safe, non-misleading fallback rather than a saturated wrong value).
6950        NativeAgg::Sum => match sum.try_into() {
6951            Ok(v) => NativeAggResult::Int(v),
6952            Err(_) => NativeAggResult::Null,
6953        },
6954        NativeAgg::Min => NativeAggResult::Int(mn),
6955        NativeAgg::Max => NativeAggResult::Int(mx),
6956        NativeAgg::Avg => NativeAggResult::Float((sum as f64) / (count as f64)),
6957    }
6958}
6959
6960fn pack_float(agg: NativeAgg, count: u64, sum: f64, mn: f64, mx: f64) -> NativeAggResult {
6961    if count == 0 && !matches!(agg, NativeAgg::Count) {
6962        return NativeAggResult::Null;
6963    }
6964    match agg {
6965        NativeAgg::Count => NativeAggResult::Count(count),
6966        NativeAgg::Sum => NativeAggResult::Float(sum),
6967        NativeAgg::Min => NativeAggResult::Float(mn),
6968        NativeAgg::Max => NativeAggResult::Float(mx),
6969        NativeAgg::Avg => NativeAggResult::Float(sum / (count as f64)),
6970    }
6971}
6972
6973/// Aggregate per-page `min`/`max`/`null_count` into a column-wide i64 triple.
6974/// Returns `None` if no page contributes a non-null min/max (all-null column).
6975fn agg_int(
6976    stats: &[crate::page::PageStat],
6977    decode: fn(Option<&[u8]>) -> Option<i64>,
6978) -> Option<(Option<i64>, Option<i64>, u64)> {
6979    let (mut mn, mut mx, mut nulls) = (i64::MAX, i64::MIN, 0u64);
6980    let mut any = false;
6981    for s in stats {
6982        if let Some(v) = decode(s.min.as_deref()) {
6983            mn = mn.min(v);
6984            any = true;
6985        }
6986        if let Some(v) = decode(s.max.as_deref()) {
6987            mx = mx.max(v);
6988            any = true;
6989        }
6990        nulls += s.null_count;
6991    }
6992    any.then_some((Some(mn), Some(mx), nulls))
6993}
6994
6995/// f64 analogue of [`agg_int`] (compares as f64, not as bit patterns).
6996fn agg_float(
6997    stats: &[crate::page::PageStat],
6998    decode: fn(Option<&[u8]>) -> Option<f64>,
6999) -> Option<(Option<f64>, Option<f64>, u64)> {
7000    let (mut mn, mut mx, mut nulls) = (f64::INFINITY, f64::NEG_INFINITY, 0u64);
7001    let mut any = false;
7002    for s in stats {
7003        if let Some(v) = decode(s.min.as_deref()) {
7004            mn = mn.min(v);
7005            any = true;
7006        }
7007        if let Some(v) = decode(s.max.as_deref()) {
7008            mx = mx.max(v);
7009            any = true;
7010        }
7011        nulls += s.null_count;
7012    }
7013    any.then_some((Some(mn), Some(mx), nulls))
7014}
7015
7016/// The four maintained secondary-index maps, keyed by column id.
7017type SecondaryIndexes = (
7018    HashMap<u16, BitmapIndex>,
7019    HashMap<u16, AnnIndex>,
7020    HashMap<u16, FmIndex>,
7021    HashMap<u16, SparseIndex>,
7022    HashMap<u16, MinHashIndex>,
7023);
7024
7025fn empty_indexes(schema: &Schema) -> SecondaryIndexes {
7026    let mut bitmap = HashMap::new();
7027    let mut ann = HashMap::new();
7028    let mut fm = HashMap::new();
7029    let mut sparse = HashMap::new();
7030    let mut minhash = HashMap::new();
7031    for idef in &schema.indexes {
7032        match idef.kind {
7033            IndexKind::Bitmap => {
7034                bitmap.insert(idef.column_id, BitmapIndex::new());
7035            }
7036            IndexKind::Ann => {
7037                let dim = schema
7038                    .columns
7039                    .iter()
7040                    .find(|c| c.id == idef.column_id)
7041                    .and_then(|c| match c.ty {
7042                        TypeId::Embedding { dim } => Some(dim as usize),
7043                        _ => None,
7044                    })
7045                    .unwrap_or(0);
7046                ann.insert(idef.column_id, AnnIndex::new(dim));
7047            }
7048            IndexKind::FmIndex => {
7049                fm.insert(idef.column_id, FmIndex::new());
7050            }
7051            IndexKind::Sparse => {
7052                sparse.insert(idef.column_id, SparseIndex::new());
7053            }
7054            IndexKind::MinHash => {
7055                minhash.insert(idef.column_id, MinHashIndex::new());
7056            }
7057            _ => {}
7058        }
7059    }
7060    (bitmap, ann, fm, sparse, minhash)
7061}
7062
7063const ALTER_COLUMN_PROTECTED_FLAGS: u32 = ColumnFlags::PRIMARY_KEY
7064    | ColumnFlags::AUTO_INCREMENT
7065    | ColumnFlags::ENCRYPTED
7066    | ColumnFlags::ENCRYPTED_INDEXABLE
7067    | ColumnFlags::EMBEDDING_BINARY_QUANTIZED;
7068
7069fn validate_alter_column_flags(old: ColumnFlags, new: ColumnFlags) -> Result<()> {
7070    if (old.bits() ^ new.bits()) & ALTER_COLUMN_PROTECTED_FLAGS != 0 {
7071        return Err(MongrelError::Schema(
7072            "ALTER COLUMN may only change NULLABLE; primary key, auto-increment, encryption, and embedding flags are immutable".into(),
7073        ));
7074    }
7075    Ok(())
7076}
7077
7078fn validate_alter_column_type(
7079    schema: &Schema,
7080    old: &ColumnDef,
7081    next: &ColumnDef,
7082    has_stored_versions: bool,
7083) -> Result<()> {
7084    if old.ty == next.ty {
7085        return Ok(());
7086    }
7087    if schema.indexes.iter().any(|i| i.column_id == old.id) {
7088        return Err(MongrelError::Schema(format!(
7089            "ALTER COLUMN TYPE is not supported for indexed column '{}'",
7090            old.name
7091        )));
7092    }
7093    if !has_stored_versions || storage_compatible_type_change(old.ty, next.ty) {
7094        return Ok(());
7095    }
7096    Err(MongrelError::Schema(format!(
7097        "ALTER COLUMN TYPE from {:?} to {:?} requires an empty column or a representation-compatible type",
7098        old.ty, next.ty
7099    )))
7100}
7101
7102fn storage_compatible_type_change(old: TypeId, new: TypeId) -> bool {
7103    matches!(
7104        (old, new),
7105        (TypeId::Int64, TypeId::TimestampNanos) | (TypeId::TimestampNanos, TypeId::Int64)
7106    )
7107}
7108
7109/// True when every row carries an `Int64` PK value and the sequence is
7110/// strictly increasing — no intra-batch duplicate is possible. The row-major
7111/// mirror of `native_int64_strictly_increasing` (the `bulk_pk_winner_indices`
7112/// fast path), used by `apply_put_rows_inner` to skip upsert probing for
7113/// append-style batches.
7114fn rows_pk_strictly_increasing(rows: &[Row], pk_id: u16) -> bool {
7115    let mut prev: Option<i64> = None;
7116    for r in rows {
7117        match r.columns.get(&pk_id) {
7118            Some(Value::Int64(v)) => {
7119                if prev.is_some_and(|p| p >= *v) {
7120                    return false;
7121                }
7122                prev = Some(*v);
7123            }
7124            _ => return false,
7125        }
7126    }
7127    true
7128}
7129
7130#[allow(clippy::too_many_arguments)]
7131fn index_into(
7132    schema: &Schema,
7133    row: &Row,
7134    hot: &mut HotIndex,
7135    bitmap: &mut HashMap<u16, BitmapIndex>,
7136    ann: &mut HashMap<u16, AnnIndex>,
7137    fm: &mut HashMap<u16, FmIndex>,
7138    sparse: &mut HashMap<u16, SparseIndex>,
7139    minhash: &mut HashMap<u16, MinHashIndex>,
7140) {
7141    for idef in &schema.indexes {
7142        let Some(val) = row.columns.get(&idef.column_id) else {
7143            continue;
7144        };
7145        match idef.kind {
7146            IndexKind::Bitmap => {
7147                if let Some(b) = bitmap.get_mut(&idef.column_id) {
7148                    b.insert(val.encode_key(), row.row_id);
7149                }
7150            }
7151            IndexKind::Ann => {
7152                if let (Some(a), Value::Embedding(v)) = (ann.get_mut(&idef.column_id), val) {
7153                    a.insert(v, row.row_id);
7154                }
7155            }
7156            IndexKind::FmIndex => {
7157                if let (Some(f), Value::Bytes(b)) = (fm.get_mut(&idef.column_id), val) {
7158                    f.insert(b.clone(), row.row_id);
7159                }
7160            }
7161            IndexKind::Sparse => {
7162                if let (Some(s), Value::Bytes(b)) = (sparse.get_mut(&idef.column_id), val) {
7163                    // A sparse vector is stored as a bincode'd `Vec<(u32, f32)>`
7164                    // in a Bytes column (SPLADE weights in, retrieval out).
7165                    if let Ok(terms) = bincode::deserialize::<Vec<(u32, f32)>>(b) {
7166                        s.insert(&terms, row.row_id);
7167                    }
7168                }
7169            }
7170            IndexKind::MinHash => {
7171                if let (Some(mh), Value::Bytes(b)) = (minhash.get_mut(&idef.column_id), val) {
7172                    // The set is a JSON array (the Kit's `set_similarity` shape);
7173                    // tokenize + hash its members into the MinHash signature.
7174                    let tokens = crate::index::token_hashes_from_bytes(b);
7175                    mh.insert(&tokens, row.row_id);
7176                }
7177            }
7178            _ => {}
7179        }
7180    }
7181    if let Some(pk_col) = schema.primary_key() {
7182        if let Some(pk_val) = row.columns.get(&pk_col.id) {
7183            hot.insert(pk_val.encode_key(), row.row_id);
7184        }
7185    }
7186}
7187
7188/// Index a row into a single specific index (used for partial indexes where
7189/// only matching indexes should receive the row).
7190#[allow(clippy::too_many_arguments)]
7191fn index_into_single(
7192    idef: &IndexDef,
7193    _schema: &Schema,
7194    row: &Row,
7195    _hot: &mut HotIndex,
7196    bitmap: &mut HashMap<u16, BitmapIndex>,
7197    ann: &mut HashMap<u16, AnnIndex>,
7198    fm: &mut HashMap<u16, FmIndex>,
7199    sparse: &mut HashMap<u16, SparseIndex>,
7200    minhash: &mut HashMap<u16, MinHashIndex>,
7201) {
7202    let Some(val) = row.columns.get(&idef.column_id) else {
7203        return;
7204    };
7205    match idef.kind {
7206        IndexKind::Bitmap => {
7207            if let Some(b) = bitmap.get_mut(&idef.column_id) {
7208                b.insert(val.encode_key(), row.row_id);
7209            }
7210        }
7211        IndexKind::Ann => {
7212            if let (Some(a), Value::Embedding(v)) = (ann.get_mut(&idef.column_id), val) {
7213                a.insert(v, row.row_id);
7214            }
7215        }
7216        IndexKind::FmIndex => {
7217            if let (Some(f), Value::Bytes(b)) = (fm.get_mut(&idef.column_id), val) {
7218                f.insert(b.clone(), row.row_id);
7219            }
7220        }
7221        IndexKind::Sparse => {
7222            if let (Some(s), Value::Bytes(b)) = (sparse.get_mut(&idef.column_id), val) {
7223                if let Ok(terms) = bincode::deserialize::<Vec<(u32, f32)>>(b) {
7224                    s.insert(&terms, row.row_id);
7225                }
7226            }
7227        }
7228        IndexKind::MinHash => {
7229            if let (Some(mh), Value::Bytes(b)) = (minhash.get_mut(&idef.column_id), val) {
7230                let tokens = crate::index::token_hashes_from_bytes(b);
7231                mh.insert(&tokens, row.row_id);
7232            }
7233        }
7234        _ => {}
7235    }
7236}
7237
7238/// Evaluate a partial-index predicate against a row. Supports the most common
7239/// patterns: `"column IS NOT NULL"` and `"column IS NULL"`. More complex
7240/// expressions require a full SQL evaluator in core (future work); the
7241/// predicate string is stored verbatim and this function provides a pragmatic
7242/// subset. Returns `true` if the row should be indexed.
7243fn eval_partial_predicate(
7244    pred: &str,
7245    columns_map: &HashMap<u16, &Value>,
7246    name_to_id: &HashMap<&str, u16>,
7247) -> bool {
7248    let lower = pred.trim().to_ascii_lowercase();
7249    // Pattern: "column_name IS NOT NULL"
7250    if let Some(rest) = lower.strip_suffix(" is not null") {
7251        let col_name = rest.trim();
7252        if let Some(col_id) = name_to_id.get(col_name) {
7253            return columns_map
7254                .get(col_id)
7255                .is_some_and(|v| !matches!(v, Value::Null));
7256        }
7257    }
7258    // Pattern: "column_name IS NULL"
7259    if let Some(rest) = lower.strip_suffix(" is null") {
7260        let col_name = rest.trim();
7261        if let Some(col_id) = name_to_id.get(col_name) {
7262            return columns_map
7263                .get(col_id)
7264                .map_or(true, |v| matches!(v, Value::Null));
7265        }
7266    }
7267    // Unknown predicate syntax: index the row (conservative — better to
7268    // over-index than to miss rows).
7269    true
7270}
7271
7272/// Per-element index key for the typed bulk-index path (Phase 14.2): mirrors
7273/// `index_into` on a `tokenized_for_indexes(row)` — encodes the element the way
7274/// [`Value::encode_key`] would, then applies the column's
7275/// `ENCRYPTED_INDEXABLE` tokenization (HMAC-eq / OPE) so bitmap/HOT keys match
7276/// what the incremental path stores. Returns `None` for null slots.
7277#[allow(dead_code)]
7278fn bulk_index_key(
7279    column_keys: &HashMap<u16, ([u8; 32], u8)>,
7280    column_id: u16,
7281    ty: TypeId,
7282    col: &columnar::NativeColumn,
7283    i: usize,
7284) -> Option<Vec<u8>> {
7285    let encoded = columnar::encode_key_native(ty, col, i)?;
7286    #[cfg(feature = "encryption")]
7287    {
7288        use crate::encryption::{hmac_token, ope_token_f64, ope_token_i64, SCHEME_HMAC_EQ};
7289        if let Some((key, scheme)) = column_keys.get(&column_id) {
7290            return Some(match (*scheme, col) {
7291                (SCHEME_HMAC_EQ, _) => hmac_token(key, &encoded).to_vec(),
7292                (_, columnar::NativeColumn::Int64 { data, .. }) => {
7293                    ope_token_i64(key, data[i]).to_vec()
7294                }
7295                (_, columnar::NativeColumn::Float64 { data, .. }) => {
7296                    ope_token_f64(key, data[i]).to_vec()
7297                }
7298                _ => hmac_token(key, &encoded).to_vec(),
7299            });
7300        }
7301    }
7302    #[cfg(not(feature = "encryption"))]
7303    {
7304        let _ = (column_id, column_keys, col);
7305    }
7306    Some(encoded)
7307}
7308
7309pub(crate) fn write_schema(dir: &Path, schema: &Schema) -> Result<()> {
7310    let json = serde_json::to_string_pretty(schema)
7311        .map_err(|e| MongrelError::Schema(format!("encode schema: {e}")))?;
7312    std::fs::write(dir.join(SCHEMA_FILENAME), json)?;
7313    Ok(())
7314}
7315
7316fn read_schema(dir: &Path) -> Result<Schema> {
7317    serde_json::from_str(&std::fs::read_to_string(dir.join(SCHEMA_FILENAME))?)
7318        .map_err(|e| MongrelError::Schema(format!("decode schema: {e}")))
7319}
7320
7321fn next_wal_segment(wal_dir: &Path) -> Result<PathBuf> {
7322    Ok(wal_dir.join(format!("seg-{:06}.wal", next_wal_number(wal_dir)?)))
7323}
7324
7325fn latest_wal_segment(wal_dir: &Path) -> Result<Option<PathBuf>> {
7326    let n = list_wal_numbers(wal_dir)?;
7327    Ok(n.map(|max| wal_dir.join(format!("seg-{max:06}.wal"))))
7328}
7329
7330fn next_wal_number(wal_dir: &Path) -> Result<u32> {
7331    Ok(list_wal_numbers(wal_dir)?.map(|m| m + 1).unwrap_or(0))
7332}
7333
7334fn list_wal_numbers(wal_dir: &Path) -> Result<Option<u32>> {
7335    let _ = std::fs::create_dir_all(wal_dir);
7336    let mut max_n = None;
7337    for entry in std::fs::read_dir(wal_dir)? {
7338        let entry = entry?;
7339        let fname = entry.file_name();
7340        let Some(s) = fname.to_str() else {
7341            continue;
7342        };
7343        let Some(stripped) = s.strip_prefix("seg-") else {
7344            continue;
7345        };
7346        let Some(stripped) = stripped.strip_suffix(".wal") else {
7347            continue;
7348        };
7349        if let Ok(n) = stripped.parse::<u32>() {
7350            max_n = Some(max_n.map(|m: u32| m.max(n)).unwrap_or(n));
7351        }
7352    }
7353    Ok(max_n)
7354}