Skip to main content

mongreldb_core/
run_lookup.rs

1//! RowId → run-set lookup directory (TODO §1.2).
2//!
3//! Derived, rebuildable, **never** authoritative. Missing, stale, corrupt, or
4//! incomplete directory state falls back to the existing range-scan path in
5//! `Table::get`. The directory is rebuilt from sorted-run system columns and
6//! published atomically alongside the manifest, so a torn publication never
7//! leaves an inconsistent directory visible.
8//!
9//! On-disk shape:
10//! - delta-encoded `RowId` keys (varint);
11//! - for each key, a newest-first list of `RunLocator`s (run_id u128, epoch
12//!   min/max, optional HLC min/max, unstamped flag);
13//! - a fingerprint of the exact active run-set + schema/index generation that
14//!   produced the directory; rejected on reopen if the active manifest
15//!   diverges;
16//! - a CRC32C footer for corruption detection.
17//!
18//! Large tables use a sharded checkpoint layout (one file per `RowId` range).
19//! The single-file API is retained for tests and small tables; the
20//! production open path uses [`RunLookupDirectory::read_checkpoint_sharded`].
21
22use std::collections::BTreeMap;
23use std::fs::{File, OpenOptions};
24use std::io::{self, Read, Write};
25use std::path::Path;
26
27use crate::epoch::{Epoch, Snapshot};
28use crate::manifest::RunRef;
29use crate::rowid::RowId;
30use crate::{Result, Table};
31use crc::{Crc, CRC_32_ISCSI};
32use mongreldb_types::hlc::HlcTimestamp;
33
34/// On-disk filename of the single-file directory checkpoint. Production tables
35/// use the sharded layout (see `directory_shard_path`).
36pub const DIRECTORY_FILENAME: &str = "directory.bin";
37/// Prefix for sharded directory files (`directory.shard-<start>.bin`).
38pub const DIRECTORY_SHARD_PREFIX: &str = "directory.shard-";
39pub const DIRECTORY_SHARD_SUFFIX: &str = ".bin";
40/// Default max bytes per shard when publishing a large directory. Keeps the
41/// in-memory buffer under 16 MiB so rebuilds/checkpoint publishes never need
42/// the entire directory resident at once on a 256-run workload.
43pub const DEFAULT_SHARD_BYTES: u64 = 16 * 1024 * 1024;
44
45/// Magic bytes that prefix the on-disk checkpoint (`MLKP` = Mongrel Lookup).
46const LOOKUP_MAGIC: [u8; 4] = *b"MLKP";
47/// CRC32C (Castagnoli) over the body — same algorithm as the WAL.
48const CRC32C: Crc<u32> = Crc::<u32>::new(&CRC_32_ISCSI);
49const FOOTER_LEN: usize = 4;
50const HLC_BYTES: usize = 16;
51
52/// What one run-level posting says about a `RowId`: enough metadata to
53/// conservatively decide whether the run can contain a version visible to a
54/// supplied snapshot, without opening the run.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub struct RunLocator {
57    /// Stable identifier of the run, matched against the manifest's `RunRef`.
58    pub run_id: u128,
59    pub min_epoch: Epoch,
60    pub max_epoch: Epoch,
61    /// `Some` when the run carries HLC-stamped rows.
62    pub min_hlc: Option<HlcTimestamp>,
63    pub max_hlc: Option<HlcTimestamp>,
64    /// `true` when the run also contains legacy unstamped (epoch-only) rows.
65    pub contains_unstamped_versions: bool,
66}
67
68/// A complete, directly-consultable view of the directory for a single
69/// `RowId`. Returned by [`RunLookupDirectory::locate`].
70#[derive(Debug, Clone, Default)]
71pub struct RunLocatorList {
72    pub locators: Vec<RunLocator>,
73}
74
75impl RunLocatorList {
76    pub fn is_empty(&self) -> bool {
77        self.locators.is_empty()
78    }
79
80    pub fn len(&self) -> usize {
81        self.locators.len()
82    }
83}
84
85/// What a `Table::get` directory consultation concluded with.
86///
87/// `CompleteMiss` is the authoritative answer "this row has no postings in
88/// the directory" — the memtable and mutable-run tiers have already been
89/// searched, so an empty complete lookup means no immutable run can host a
90/// visible version. The caller opens zero readers.
91///
92/// `Candidates` means the directory returned at least one locator; the
93/// caller walks them with the conservative snapshot filter and the
94/// [`RunLocator::can_contain_version_newer_than`] early-stop proof.
95///
96/// `UnavailableOrStale` means the directory was missing, corrupt, or had a
97/// stale fingerprint; the caller falls back to the existing range-scan
98/// path. Distinct from `CompleteMiss` because the directory gave no
99/// definitive answer and the row *could* still exist in an immutable run.
100#[derive(Debug, Clone)]
101pub(crate) enum DirectoryLookupDecision {
102    CompleteMiss,
103    Candidates(Vec<RunLocator>),
104    UnavailableOrStale,
105}
106
107/// A compact snapshot of the (epoch, hlc) coordinates of a row version. Used
108/// by the safe early-stop proof so the caller can ask "can this locator
109/// still beat the current winner?" without exposing the full [`crate::memtable::Row`]
110/// in the public surface.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub(crate) struct VersionStamp {
113    pub epoch: Epoch,
114    pub hlc: Option<HlcTimestamp>,
115}
116
117/// Per-`RowId` posting list. Backed by a `BTreeMap` for the in-memory
118/// representation; the on-disk checkpoint uses delta-encoded `RowId` keys.
119#[derive(Debug, Default, Clone)]
120pub struct RunLookupDirectory {
121    /// `(RowId -> newest-first RunLocator list)`.
122    postings: BTreeMap<RowId, RunLocatorList>,
123    /// Fingerprint of the active run-set + schema/index generation that
124    /// produced this directory. `None` for an in-memory rebuild not yet
125    /// checkpointed.
126    fingerprint: Option<u64>,
127}
128
129impl RunLookupDirectory {
130    /// Construct an empty directory with no fingerprint.
131    pub fn empty() -> Self {
132        Self::default()
133    }
134
135    /// Look up the candidate run locators for `row_id`. Returns an empty list
136    /// when the row has no postings (caller may treat this as a miss).
137    pub fn locate(&self, row_id: RowId) -> &RunLocatorList {
138        static EMPTY: RunLocatorList = RunLocatorList {
139            locators: Vec::new(),
140        };
141        self.postings.get(&row_id).unwrap_or(&EMPTY)
142    }
143
144    /// Decide how the read path should consult this directory for `row_id`.
145    /// Distinguishes a complete directory miss (no postings → open zero
146    /// immutable readers) from the absence of any usable directory
147    /// (`UnavailableOrStale` → range-scan fallback).
148    pub(crate) fn decide(&self, row_id: RowId) -> DirectoryLookupDecision {
149        match self.postings.get(&row_id) {
150            None => DirectoryLookupDecision::CompleteMiss,
151            Some(list) if list.is_empty() => DirectoryLookupDecision::CompleteMiss,
152            Some(list) => DirectoryLookupDecision::Candidates(list.locators.clone()),
153        }
154    }
155
156    /// Insert or replace a `RunLocator` for `row_id`. Locators are kept
157    /// newest-first; the most recent write wins for ties.
158    pub fn insert(&mut self, row_id: RowId, locator: RunLocator) {
159        let entry = self.postings.entry(row_id).or_default();
160        // Newest-first: locate the first locator whose `max_epoch`/`max_hlc`
161        // is older than the new one and insert before it. Falls back to
162        // append when the new locator is the oldest.
163        let pos = entry
164            .locators
165            .iter()
166            .position(|existing| locator_is_newer(locator, *existing));
167        match pos {
168            Some(idx) => entry.locators.insert(idx, locator),
169            None => entry.locators.push(locator),
170        }
171    }
172
173    /// Drop every locator whose `run_id` no longer exists in the active
174    /// manifest. Returns the number of removed postings.
175    pub fn retain_active_runs(&mut self, active_runs: &[u128]) -> usize {
176        let mut removed = 0;
177        for list in self.postings.values_mut() {
178            let before = list.locators.len();
179            list.locators.retain(|l| active_runs.contains(&l.run_id));
180            removed += before - list.locators.len();
181        }
182        removed
183    }
184
185    /// Set the fingerprint of the active run-set + schema/index generation.
186    pub fn set_fingerprint(&mut self, fp: u64) {
187        self.fingerprint = Some(fp);
188    }
189
190    pub fn fingerprint(&self) -> Option<u64> {
191        self.fingerprint
192    }
193
194    /// Number of `(RowId, posting)` entries. Used for memory/footprint metrics.
195    pub fn total_locators(&self) -> usize {
196        self.postings.values().map(|l| l.locators.len()).sum()
197    }
198
199    /// Number of distinct `RowId` keys.
200    pub fn key_count(&self) -> usize {
201        self.postings.len()
202    }
203
204    /// Maximum number of locators for any single `RowId`.
205    pub fn max_locators_per_row(&self) -> usize {
206        self.postings
207            .values()
208            .map(|l| l.locators.len())
209            .max()
210            .unwrap_or(0)
211    }
212
213    /// Average number of locators per `RowId` (0 when the directory is empty).
214    pub fn avg_locators_per_row(&self) -> f64 {
215        if self.postings.is_empty() {
216            return 0.0;
217        }
218        self.total_locators() as f64 / self.postings.len() as f64
219    }
220
221    /// Persist the directory to `path` as a single atomic file (write to temp
222    /// sibling, fsync, rename). A `fingerprint` must have been set; without
223    /// one the on-disk file is meaningless on reopen. Layout: magic, fingerprint,
224    /// key count, delta-encoded `RowId` keys with newest-first per-key locator
225    /// lists, then a CRC32C footer.
226    pub fn write_checkpoint(&self, path: &Path) -> io::Result<()> {
227        let fp = self.fingerprint.ok_or_else(|| {
228            io::Error::new(
229                io::ErrorKind::InvalidData,
230                "run-lookup directory has no fingerprint; refusing to checkpoint",
231            )
232        })?;
233        let mut body: Vec<u8> = Vec::new();
234        body.extend_from_slice(&LOOKUP_MAGIC);
235        body.extend_from_slice(&fp.to_le_bytes());
236        let key_count = self.postings.len() as u32;
237        body.extend_from_slice(&key_count.to_le_bytes());
238        let mut prev: u64 = 0;
239        for (row_id, list) in &self.postings {
240            let cur = row_id.0;
241            let delta = cur.checked_sub(prev).ok_or_else(|| {
242                io::Error::new(
243                    io::ErrorKind::InvalidData,
244                    "run-lookup row_id sequence is not non-decreasing",
245                )
246            })?;
247            encode_varint(delta, &mut body);
248            let locator_count = list.locators.len() as u32;
249            body.extend_from_slice(&locator_count.to_le_bytes());
250            for loc in &list.locators {
251                body.extend_from_slice(&loc.run_id.to_le_bytes());
252                body.extend_from_slice(&loc.min_epoch.0.to_le_bytes());
253                body.extend_from_slice(&loc.max_epoch.0.to_le_bytes());
254                write_optional_hlc(loc.min_hlc, &mut body);
255                write_optional_hlc(loc.max_hlc, &mut body);
256                body.push(if loc.contains_unstamped_versions {
257                    1
258                } else {
259                    0
260                });
261            }
262            prev = cur;
263        }
264        let crc = CRC32C.checksum(&body);
265        body.extend_from_slice(&crc.to_le_bytes());
266
267        let parent = path.parent().unwrap_or_else(|| Path::new("."));
268        let staging = parent.join(format!(
269            ".{}.{}.{}.staging",
270            path.file_name()
271                .and_then(|name| name.to_str())
272                .unwrap_or("checkpoint"),
273            std::process::id(),
274            std::time::SystemTime::now()
275                .duration_since(std::time::UNIX_EPOCH)
276                .map(|d| d.as_nanos())
277                .unwrap_or(0),
278        ));
279        {
280            // Best-effort cleanup of any stale staging file left behind by a
281            // crashed previous attempt at the same nanos tick. The window is
282            // vanishingly small but the truncate avoids an `AlreadyExists`
283            // failure that would mask a real I/O error.
284            let _ = std::fs::remove_file(&staging);
285            let mut file = OpenOptions::new()
286                .create(true)
287                .write(true)
288                .truncate(true)
289                .open(&staging)?;
290            file.write_all(&body)?;
291            file.sync_all()?;
292        }
293        match std::fs::rename(&staging, path) {
294            Ok(()) => {}
295            Err(error) => {
296                let _ = std::fs::remove_file(&staging);
297                return Err(error);
298            }
299        }
300        if let Ok(dir) = OpenOptions::new().read(true).open(parent) {
301            let _ = dir.sync_all();
302        }
303        Ok(())
304    }
305
306    /// Read a checkpoint file, validating magic, CRC32C, and that the stored
307    /// fingerprint equals the supplied `fingerprint`. Any mismatch is returned
308    /// as an `io::Error`; the caller may then fall back to `rebuild_from_runs`.
309    pub fn read_checkpoint(path: &Path, fingerprint: u64) -> io::Result<Self> {
310        let mut file = File::open(path)?;
311        let len = file.metadata()?.len();
312        const MAX_CHECKPOINT_BYTES: u64 = 64 * 1024 * 1024;
313        if len < 4 + 8 + 4 + FOOTER_LEN as u64 || len > MAX_CHECKPOINT_BYTES {
314            return Err(io::Error::new(
315                io::ErrorKind::InvalidData,
316                "run-lookup checkpoint length is out of range",
317            ));
318        }
319        let mut bytes = Vec::with_capacity(len as usize);
320        file.read_to_end(&mut bytes)?;
321        let split = bytes.len() - FOOTER_LEN;
322        let (body, footer) = bytes.split_at(split);
323        let expected = u32::from_le_bytes(footer.try_into().unwrap());
324        let actual = CRC32C.checksum(body);
325        if expected != actual {
326            return Err(io::Error::new(
327                io::ErrorKind::InvalidData,
328                "run-lookup checkpoint CRC mismatch",
329            ));
330        }
331        if body.len() < 4 + 8 + 4 {
332            return Err(io::Error::new(
333                io::ErrorKind::InvalidData,
334                "run-lookup checkpoint body too short",
335            ));
336        }
337        if body[..4] != LOOKUP_MAGIC {
338            return Err(io::Error::new(
339                io::ErrorKind::InvalidData,
340                "run-lookup checkpoint magic mismatch",
341            ));
342        }
343        let stored_fp = u64::from_le_bytes(body[4..12].try_into().unwrap());
344        if stored_fp != fingerprint {
345            return Err(io::Error::new(
346                io::ErrorKind::InvalidData,
347                "run-lookup checkpoint fingerprint does not match active run set",
348            ));
349        }
350        let key_count = u32::from_le_bytes(body[12..16].try_into().unwrap());
351        let mut cursor = &body[16..];
352        let mut postings: BTreeMap<RowId, RunLocatorList> = BTreeMap::new();
353        let mut prev: u64 = 0;
354        for _ in 0..key_count {
355            let (delta, consumed) = decode_varint(cursor)?;
356            cursor = &cursor[consumed..];
357            let cur = prev.checked_add(delta).ok_or_else(|| {
358                io::Error::new(
359                    io::ErrorKind::InvalidData,
360                    "run-lookup checkpoint varint overflows u64",
361                )
362            })?;
363            if cur < prev {
364                return Err(io::Error::new(
365                    io::ErrorKind::InvalidData,
366                    "run-lookup checkpoint row_id sequence is not monotonic",
367                ));
368            }
369            if cursor.len() < 4 {
370                return Err(io::Error::new(
371                    io::ErrorKind::UnexpectedEof,
372                    "run-lookup checkpoint truncates locator count",
373                ));
374            }
375            let locator_count = u32::from_le_bytes(cursor[..4].try_into().unwrap()) as usize;
376            cursor = &cursor[4..];
377            let mut list = RunLocatorList {
378                locators: Vec::with_capacity(locator_count),
379            };
380            for _ in 0..locator_count {
381                // Per-locator fixed prefix: run_id(16) + min_epoch(8) +
382                // max_epoch(8) + min_hlc_presence(1) + max_hlc_presence(1)
383                // + contains_unstamped(1) = 35. The two 16-byte HLC bodies
384                // are checked separately via read_optional_hlc.
385                let need = 16 + 8 + 8 + 1 + 1 + 1;
386                if cursor.len() < need {
387                    return Err(io::Error::new(
388                        io::ErrorKind::UnexpectedEof,
389                        "run-lookup checkpoint truncates locator fields",
390                    ));
391                }
392                let run_id_bytes: [u8; 16] = cursor[..16].try_into().unwrap();
393                let run_id = u128::from_le_bytes(run_id_bytes);
394                cursor = &cursor[16..];
395                let min_epoch = u64::from_le_bytes(cursor[..8].try_into().unwrap());
396                cursor = &cursor[8..];
397                let max_epoch = u64::from_le_bytes(cursor[..8].try_into().unwrap());
398                cursor = &cursor[8..];
399                let (min_hlc, used) = read_optional_hlc(cursor)?;
400                cursor = &cursor[used..];
401                let (max_hlc, used) = read_optional_hlc(cursor)?;
402                cursor = &cursor[used..];
403                if cursor.is_empty() {
404                    return Err(io::Error::new(
405                        io::ErrorKind::UnexpectedEof,
406                        "run-lookup checkpoint truncates unstamped flag",
407                    ));
408                }
409                let contains_unstamped = cursor[0] != 0;
410                cursor = &cursor[1..];
411                list.locators.push(RunLocator {
412                    run_id,
413                    min_epoch: Epoch(min_epoch),
414                    max_epoch: Epoch(max_epoch),
415                    min_hlc,
416                    max_hlc,
417                    contains_unstamped_versions: contains_unstamped,
418                });
419            }
420            postings.insert(RowId(cur), list);
421            prev = cur;
422        }
423        if !cursor.is_empty() {
424            return Err(io::Error::new(
425                io::ErrorKind::InvalidData,
426                "run-lookup checkpoint has trailing bytes after declared key count",
427            ));
428        }
429        Ok(Self {
430            postings,
431            fingerprint: Some(fingerprint),
432        })
433    }
434
435    /// Rebuild the directory by scanning each run's system columns. One
436    /// `RunLocator` is emitted per `(RowId, run_id)` pair, aggregating
437    /// min/max epoch + HLC and the unstamped-row presence flag across every
438    /// version that run carries for that row.
439    ///
440    /// The implementation is the system-column-only iterator
441    /// ([`crate::sorted_run::RunReader::for_each_system`]); the full-row
442    /// fallback is intentionally absent so directory rebuilds never pay to
443    /// materialize user columns. See spec §8.4 step 4.
444    pub fn rebuild_from_runs(runs: &[RunRef], table: &Table) -> Result<Self> {
445        rebuild_from_runs_system_only(runs, table)
446    }
447
448    /// Persist the directory as a sequence of shard files in `base_dir`, one
449    /// per `RowId` range, bounded by `max_shard_bytes` each (default
450    /// [`DEFAULT_SHARD_BYTES`]). Any previous `directory.shard-*.bin` files
451    /// in `base_dir` are unlinked first so a torn-tail publication cannot
452    /// leave a stale shard from the previous run.
453    pub fn write_checkpoint_sharded(
454        &self,
455        base_dir: &Path,
456        max_shard_bytes: u64,
457    ) -> io::Result<()> {
458        let _ = self.fingerprint.ok_or_else(|| {
459            io::Error::new(
460                io::ErrorKind::InvalidData,
461                "run-lookup directory has no fingerprint; refusing to checkpoint",
462            )
463        })?;
464        // Best-effort: clear any prior shards before publishing new ones.
465        clear_shards(base_dir)?;
466        let max = max_shard_bytes.max(1024 * 1024); // sanity floor
467        let mut iter = self.postings.iter().peekable();
468        let mut shard_no: usize = 0;
469        while let Some((first_row_id, _)) = iter.peek() {
470            shard_no += 1;
471            let shard_start = first_row_id.0;
472            // The shard spans at least one entry. Determine a clean upper
473            // boundary in row_id space by accumulating entries until the
474            // projected body length would exceed `max`.
475            let mut shard_entries: Vec<(RowId, RunLocatorList)> = Vec::new();
476            let mut body_len: usize = 4 + 8 + 4; // magic + fp + key_count
477            while let Some((_row_id, list)) = iter.peek() {
478                let entry_cost = 1 + 8 // varint delta
479                    + 4 // locator count
480                    + list.locators.len()
481                        * (16 + 8 + 8 + 1 + 1 + 16 + 16 + 1); // worst case per locator
482                if !shard_entries.is_empty() && body_len + entry_cost > max as usize {
483                    break;
484                }
485                body_len += entry_cost;
486                let (rid, list) = iter.next().unwrap();
487                shard_entries.push((*rid, list.clone()));
488            }
489            let last = shard_entries
490                .last()
491                .map(|(rid, _)| rid.0)
492                .unwrap_or(shard_start);
493            let path = if iter.peek().is_some() {
494                directory_shard_path(base_dir, shard_start, Some(last))
495            } else {
496                directory_shard_path(base_dir, shard_start, None)
497            };
498            write_single_shard(&path, &shard_entries, self.fingerprint.unwrap())?;
499        }
500        if shard_no == 0 {
501            // Empty directory: still emit one zero-row shard so the open path
502            // can confirm the checkpoint was published atomically.
503            let path = directory_shard_path(base_dir, 0, None);
504            write_single_shard(&path, &[], self.fingerprint.unwrap())?;
505        }
506        // Final best-effort directory sync.
507        if let Ok(dir) = OpenOptions::new().read(true).open(base_dir) {
508            let _ = dir.sync_all();
509        }
510        Ok(())
511    }
512
513    /// Read the directory from the sharded layout in `base_dir`. Validates
514    /// every shard's magic, CRC, and fingerprint independently. Returns the
515    /// merged directory; a missing or unreadable shard is propagated as
516    /// `io::Error` so the caller can fall back to rebuild-from-runs.
517    pub fn read_checkpoint_sharded(base_dir: &Path, fingerprint: u64) -> io::Result<Self> {
518        let mut paths: Vec<PathBuf> = Vec::new();
519        for entry in std::fs::read_dir(base_dir)? {
520            let entry = entry?;
521            let name = entry.file_name();
522            let Some(name) = name.to_str() else {
523                continue;
524            };
525            // Shards use the `directory.shard-` prefix; the last shard is
526            // published as `directory.shard-<start>-open` (no `.bin` suffix)
527            // so the open boundary is unambiguous. Both layouts are matched
528            // here.
529            if name.starts_with(DIRECTORY_SHARD_PREFIX)
530                && (name.ends_with(DIRECTORY_SHARD_SUFFIX) || name.ends_with("-open"))
531            {
532                paths.push(entry.path());
533            }
534        }
535        paths.sort();
536        if paths.is_empty() {
537            return Err(io::Error::new(
538                io::ErrorKind::NotFound,
539                "no run-lookup directory shards present",
540            ));
541        }
542        let mut merged: BTreeMap<RowId, RunLocatorList> = BTreeMap::new();
543        for path in paths {
544            let shard = read_single_shard(&path, fingerprint)?;
545            for (rid, list) in shard {
546                merged.insert(rid, list);
547            }
548        }
549        Ok(Self {
550            postings: merged,
551            fingerprint: Some(fingerprint),
552        })
553    }
554}
555
556fn clear_shards(base_dir: &Path) -> io::Result<()> {
557    if !base_dir.exists() {
558        return Ok(());
559    }
560    for entry in std::fs::read_dir(base_dir)? {
561        let entry = entry?;
562        let name = entry.file_name();
563        let Some(name) = name.to_str() else {
564            continue;
565        };
566        if name.starts_with(DIRECTORY_SHARD_PREFIX)
567            && (name.ends_with(DIRECTORY_SHARD_SUFFIX) || name.ends_with("-open"))
568        {
569            let _ = std::fs::remove_file(entry.path());
570        }
571    }
572    Ok(())
573}
574
575fn write_single_shard(
576    path: &Path,
577    entries: &[(RowId, RunLocatorList)],
578    fingerprint: u64,
579) -> io::Result<()> {
580    let mut body: Vec<u8> = Vec::new();
581    body.extend_from_slice(&LOOKUP_MAGIC);
582    body.extend_from_slice(&fingerprint.to_le_bytes());
583    let key_count = entries.len() as u32;
584    body.extend_from_slice(&key_count.to_le_bytes());
585    let mut prev: u64 = 0;
586    for (row_id, list) in entries {
587        let cur = row_id.0;
588        let delta = cur.checked_sub(prev).ok_or_else(|| {
589            io::Error::new(
590                io::ErrorKind::InvalidData,
591                "run-lookup row_id sequence is not non-decreasing",
592            )
593        })?;
594        encode_varint(delta, &mut body);
595        let locator_count = list.locators.len() as u32;
596        body.extend_from_slice(&locator_count.to_le_bytes());
597        for loc in &list.locators {
598            body.extend_from_slice(&loc.run_id.to_le_bytes());
599            body.extend_from_slice(&loc.min_epoch.0.to_le_bytes());
600            body.extend_from_slice(&loc.max_epoch.0.to_le_bytes());
601            write_optional_hlc(loc.min_hlc, &mut body);
602            write_optional_hlc(loc.max_hlc, &mut body);
603            body.push(if loc.contains_unstamped_versions {
604                1
605            } else {
606                0
607            });
608        }
609        prev = cur;
610    }
611    let crc = CRC32C.checksum(&body);
612    body.extend_from_slice(&crc.to_le_bytes());
613
614    let parent = path.parent().unwrap_or_else(|| Path::new("."));
615    let staging = parent.join(format!(
616        ".{}.{}.{}.staging",
617        path.file_name()
618            .and_then(|name| name.to_str())
619            .unwrap_or("shard"),
620        std::process::id(),
621        std::time::SystemTime::now()
622            .duration_since(std::time::UNIX_EPOCH)
623            .map(|d| d.as_nanos())
624            .unwrap_or(0),
625    ));
626    {
627        let _ = std::fs::remove_file(&staging);
628        let mut file = OpenOptions::new()
629            .create(true)
630            .write(true)
631            .truncate(true)
632            .open(&staging)?;
633        file.write_all(&body)?;
634        file.sync_all()?;
635    }
636    match std::fs::rename(&staging, path) {
637        Ok(()) => {}
638        Err(error) => {
639            let _ = std::fs::remove_file(&staging);
640            return Err(error);
641        }
642    }
643    Ok(())
644}
645
646fn read_single_shard(path: &Path, fingerprint: u64) -> io::Result<BTreeMap<RowId, RunLocatorList>> {
647    let mut file = File::open(path)?;
648    let len = file.metadata()?.len();
649    // Shards are bounded by `max_shard_bytes`. A pathologically large shard
650    // is treated as corruption so the caller can fall back to rebuild.
651    if len < 4 + 8 + 4 + FOOTER_LEN as u64 || len > MAX_SHARD_BYTES * 4 {
652        return Err(io::Error::new(
653            io::ErrorKind::InvalidData,
654            "run-lookup shard length is out of range",
655        ));
656    }
657    let mut bytes = Vec::with_capacity(len as usize);
658    file.read_to_end(&mut bytes)?;
659    let split = bytes.len() - FOOTER_LEN;
660    let (body, footer) = bytes.split_at(split);
661    let expected = u32::from_le_bytes(footer.try_into().unwrap());
662    let actual = CRC32C.checksum(body);
663    if expected != actual {
664        return Err(io::Error::new(
665            io::ErrorKind::InvalidData,
666            "run-lookup shard CRC mismatch",
667        ));
668    }
669    if body.len() < 4 + 8 + 4 {
670        return Err(io::Error::new(
671            io::ErrorKind::InvalidData,
672            "run-lookup shard body too short",
673        ));
674    }
675    if body[..4] != LOOKUP_MAGIC {
676        return Err(io::Error::new(
677            io::ErrorKind::InvalidData,
678            "run-lookup shard magic mismatch",
679        ));
680    }
681    let stored_fp = u64::from_le_bytes(body[4..12].try_into().unwrap());
682    if stored_fp != fingerprint {
683        return Err(io::Error::new(
684            io::ErrorKind::InvalidData,
685            "run-lookup shard fingerprint does not match active run set",
686        ));
687    }
688    let key_count = u32::from_le_bytes(body[12..16].try_into().unwrap());
689    let mut cursor = &body[16..];
690    let mut postings: BTreeMap<RowId, RunLocatorList> = BTreeMap::new();
691    let mut prev: u64 = 0;
692    for _ in 0..key_count {
693        let (delta, consumed) = decode_varint(cursor)?;
694        cursor = &cursor[consumed..];
695        let cur = prev.checked_add(delta).ok_or_else(|| {
696            io::Error::new(
697                io::ErrorKind::InvalidData,
698                "run-lookup shard varint overflows u64",
699            )
700        })?;
701        if cur < prev {
702            return Err(io::Error::new(
703                io::ErrorKind::InvalidData,
704                "run-lookup shard row_id sequence is not monotonic",
705            ));
706        }
707        if cursor.len() < 4 {
708            return Err(io::Error::new(
709                io::ErrorKind::UnexpectedEof,
710                "run-lookup shard truncates locator count",
711            ));
712        }
713        let locator_count = u32::from_le_bytes(cursor[..4].try_into().unwrap()) as usize;
714        cursor = &cursor[4..];
715        let mut list = RunLocatorList {
716            locators: Vec::with_capacity(locator_count),
717        };
718        for _ in 0..locator_count {
719            let need = 16 + 8 + 8 + 1 + 1 + 1;
720            if cursor.len() < need {
721                return Err(io::Error::new(
722                    io::ErrorKind::UnexpectedEof,
723                    "run-lookup shard truncates locator fields",
724                ));
725            }
726            let run_id_bytes: [u8; 16] = cursor[..16].try_into().unwrap();
727            let run_id = u128::from_le_bytes(run_id_bytes);
728            cursor = &cursor[16..];
729            let min_epoch = u64::from_le_bytes(cursor[..8].try_into().unwrap());
730            cursor = &cursor[8..];
731            let max_epoch = u64::from_le_bytes(cursor[..8].try_into().unwrap());
732            cursor = &cursor[8..];
733            let (min_hlc, used) = read_optional_hlc(cursor)?;
734            cursor = &cursor[used..];
735            let (max_hlc, used) = read_optional_hlc(cursor)?;
736            cursor = &cursor[used..];
737            if cursor.is_empty() {
738                return Err(io::Error::new(
739                    io::ErrorKind::UnexpectedEof,
740                    "run-lookup shard truncates unstamped flag",
741                ));
742            }
743            let contains_unstamped = cursor[0] != 0;
744            cursor = &cursor[1..];
745            list.locators.push(RunLocator {
746                run_id,
747                min_epoch: Epoch(min_epoch),
748                max_epoch: Epoch(max_epoch),
749                min_hlc,
750                max_hlc,
751                contains_unstamped_versions: contains_unstamped,
752            });
753        }
754        postings.insert(RowId(cur), list);
755        prev = cur;
756    }
757    if !cursor.is_empty() {
758        return Err(io::Error::new(
759            io::ErrorKind::InvalidData,
760            "run-lookup shard has trailing bytes after declared key count",
761        ));
762    }
763    Ok(postings)
764}
765
766// PathBuf is referenced by `read_checkpoint_sharded`; import for clarity.
767use std::path::PathBuf;
768
769fn encode_varint(mut value: u64, out: &mut Vec<u8>) {
770    loop {
771        let mut byte = (value & 0x7F) as u8;
772        value >>= 7;
773        if value != 0 {
774            byte |= 0x80;
775        }
776        out.push(byte);
777        if value == 0 {
778            break;
779        }
780    }
781}
782
783fn decode_varint(input: &[u8]) -> io::Result<(u64, usize)> {
784    let mut value: u64 = 0;
785    let mut shift: u32 = 0;
786    let mut consumed = 0;
787    for byte in input.iter().take(10) {
788        consumed += 1;
789        let low = (byte & 0x7F) as u64;
790        value |= low.checked_shl(shift).ok_or_else(|| {
791            io::Error::new(
792                io::ErrorKind::InvalidData,
793                "run-lookup varint overflows u64",
794            )
795        })?;
796        shift += 7;
797        if byte & 0x80 == 0 {
798            return Ok((value, consumed));
799        }
800    }
801    Err(io::Error::new(
802        io::ErrorKind::InvalidData,
803        "run-lookup varint is truncated or too long",
804    ))
805}
806
807fn write_optional_hlc(ts: Option<HlcTimestamp>, out: &mut Vec<u8>) {
808    match ts {
809        None => out.push(0),
810        Some(ts) => {
811            out.push(1);
812            out.extend_from_slice(&ts.physical_micros.to_le_bytes());
813            out.extend_from_slice(&ts.logical.to_le_bytes());
814            out.extend_from_slice(&ts.node_tiebreaker.to_le_bytes());
815        }
816    }
817}
818
819fn read_optional_hlc(input: &[u8]) -> io::Result<(Option<HlcTimestamp>, usize)> {
820    if input.is_empty() {
821        return Err(io::Error::new(
822            io::ErrorKind::UnexpectedEof,
823            "run-lookup checkpoint truncates hlc presence flag",
824        ));
825    }
826    let present = input[0] != 0;
827    if !present {
828        return Ok((None, 1));
829    }
830    if input.len() < 1 + HLC_BYTES {
831        return Err(io::Error::new(
832            io::ErrorKind::UnexpectedEof,
833            "run-lookup checkpoint truncates hlc body",
834        ));
835    }
836    let physical_micros = u64::from_le_bytes(input[1..9].try_into().unwrap());
837    let logical = u32::from_le_bytes(input[9..13].try_into().unwrap());
838    let node_tiebreaker = u32::from_le_bytes(input[13..17].try_into().unwrap());
839    Ok((
840        Some(HlcTimestamp {
841            physical_micros,
842            logical,
843            node_tiebreaker,
844        }),
845        1 + HLC_BYTES,
846    ))
847}
848
849/// `true` when `newer` is strictly newer than `older` by either epoch or
850/// HLC. Ties compare as `false` (preserve existing order).
851fn locator_is_newer(newer: RunLocator, older: RunLocator) -> bool {
852    if newer.max_epoch > older.max_epoch {
853        return true;
854    }
855    if newer.max_epoch < older.max_epoch {
856        return false;
857    }
858    match (newer.max_hlc, older.max_hlc) {
859        (Some(a), Some(b)) => a > b,
860        (Some(_), None) => true,
861        (None, Some(_)) => false,
862        (None, None) => false,
863    }
864}
865
866impl RunLocator {
867    /// `true` when this locator *cannot* contain a version visible to
868    /// `snapshot`. Conservative: when in doubt, return `false` so the caller
869    /// opens the run. The proof is split into the stamped and unstamped
870    /// possibilities and never uses positional tuple fields (REM-A):
871    ///
872    /// - a stamped version may be visible when the locator's **minimum**
873    ///   HLC is at or below the snapshot (`min_hlc`, not `max_hlc` — a run
874    ///   can span the snapshot and still host an older visible version);
875    /// - an unstamped version may be visible when the locator's minimum
876    ///   epoch is at or below the snapshot epoch.
877    pub fn is_impossible_for(&self, snapshot: Snapshot) -> bool {
878        let stamped_possible = self.may_have_visible_stamped_version(snapshot);
879        let unstamped_possible = self.may_have_visible_unstamped_version(snapshot);
880
881        !stamped_possible && !unstamped_possible
882    }
883
884    /// `true` when a stamped version inside this locator may be visible to
885    /// `snapshot`. HLC-authoritative snapshots compare HLC bounds; epoch-only
886    /// snapshots compare epoch bounds (stamped rows remain epoch-visible
887    /// under the dual-model rule). The locator's `min_epoch` is aggregated
888    /// across stamped and unstamped rows, so a false positive (opening a run
889    /// that could have been skipped) is possible — that is safe; a false
890    /// negative (skipping a run that contains a visible row) is not.
891    fn may_have_visible_stamped_version(&self, snapshot: Snapshot) -> bool {
892        let Some(min_hlc) = self.min_hlc else {
893            return false;
894        };
895
896        if snapshot.uses_hlc_authority() {
897            min_hlc <= snapshot.commit_ts
898        } else {
899            self.min_epoch <= snapshot.epoch
900        }
901    }
902
903    /// `true` when an unstamped version inside this locator may be visible to
904    /// `snapshot`. Unstamped visibility is epoch-based under both snapshot
905    /// modes.
906    fn may_have_visible_unstamped_version(&self, snapshot: Snapshot) -> bool {
907        self.contains_unstamped_versions && self.min_epoch <= snapshot.epoch
908    }
909
910    /// Safe early-stop proof for `Table::get`. Returns `true` when this
911    /// locator **might** still contain a version strictly newer than the
912    /// current winner `best` and visible to `snapshot`. Returns `false`
913    /// only when the locator provably cannot — the caller may then skip
914    /// opening this run and increment the early-stop counter.
915    ///
916    /// The proof asks the stamped and unstamped questions independently
917    /// (REM-B): a locator is skippable only when neither a stamped nor an
918    /// unstamped version inside it can beat `best`. The recency rule of
919    /// [`Snapshot::version_is_newer`] is honored exactly — HLC recency
920    /// applies whenever both candidates are stamped, regardless of whether
921    /// the snapshot itself is HLC-authoritative.
922    ///
923    /// Conservative: when the metadata is too sparse to prove impossibility,
924    /// returns `true` so the caller opens the run (correctness wins over the
925    /// optimization).
926    pub(crate) fn can_contain_version_newer_than(
927        &self,
928        best: VersionStamp,
929        snapshot: Snapshot,
930    ) -> bool {
931        // Unknown metadata: the locator records no usable HLC envelope and
932        // claims no unstamped versions, so neither proof below can establish
933        // anything. Contradictory-by-construction metadata must stay
934        // conservative — open the run.
935        if (self.min_hlc.is_none() || self.max_hlc.is_none()) && !self.contains_unstamped_versions {
936            return true;
937        }
938        self.stamped_version_may_beat(best, snapshot)
939            || self.unstamped_version_may_beat(best, snapshot)
940    }
941
942    /// `true` when a **stamped** version inside this locator may be visible
943    /// to `snapshot` and strictly newer than `best`.
944    ///
945    /// - Stamped candidate versus stamped best compares HLC — under an
946    ///   HLC-authoritative snapshot visibility caps the locator's max HLC at
947    ///   `snapshot.commit_ts`; under an epoch-only snapshot visibility is
948    ///   epoch-based but recency is still HLC-based, so the proof only
949    ///   requires some stamped row to be epoch-visible while the locator's
950    ///   max HLC exceeds the best (the locator does not preserve the
951    ///   per-version epoch↔HLC correlation, so this is conservative).
952    /// - Stamped candidate versus unstamped best compares epoch. Under an
953    ///   HLC-authoritative snapshot the candidate is HLC-visible regardless
954    ///   of its local epoch, so `max_epoch` is **not** capped by
955    ///   `snapshot.epoch`; under an epoch-only snapshot both visibility and
956    ///   recency are epoch-based and the cap applies.
957    fn stamped_version_may_beat(&self, best: VersionStamp, snapshot: Snapshot) -> bool {
958        let (Some(min_hlc), Some(max_hlc)) = (self.min_hlc, self.max_hlc) else {
959            return false;
960        };
961
962        match best.hlc {
963            Some(best_hlc) => {
964                if snapshot.uses_hlc_authority() {
965                    max_hlc.min(snapshot.commit_ts) > best_hlc
966                } else {
967                    self.min_epoch <= snapshot.epoch && max_hlc > best_hlc
968                }
969            }
970            None => {
971                if snapshot.uses_hlc_authority() {
972                    min_hlc <= snapshot.commit_ts && self.max_epoch > best.epoch
973                } else {
974                    self.max_epoch.min(snapshot.epoch) > best.epoch
975                }
976            }
977        }
978    }
979
980    /// `true` when an **unstamped** version inside this locator may be
981    /// visible to `snapshot` and strictly newer than `best`. One side of the
982    /// comparison is unstamped, so recency uses epoch; unstamped visibility
983    /// is also epoch-based under both snapshot modes. `max_epoch` includes
984    /// stamped versions too, so this may be a false positive — it cannot
985    /// produce a false negative.
986    fn unstamped_version_may_beat(&self, best: VersionStamp, snapshot: Snapshot) -> bool {
987        self.contains_unstamped_versions && self.max_epoch.min(snapshot.epoch) > best.epoch
988    }
989}
990
991/// Deterministic fingerprint of the run-set + schema/index generations that
992/// produced this directory. `format_version` is the directory's own wire
993/// version so a future format bump invalidates older checkpoints even if the
994/// run set is unchanged. Uses xxh3 (already a workspace dependency) with a
995/// fixed seed, so the value is reproducible across processes/restarts.
996pub fn compute_fingerprint(
997    run_ids_ordered: &[u128],
998    schema_id: u64,
999    index_generation: u64,
1000    run_generation: u64,
1001    format_version: u32,
1002) -> u64 {
1003    let mut buf: Vec<u8> = Vec::with_capacity(8 + 8 + 8 + 4 + run_ids_ordered.len() * (16 + 2));
1004    buf.extend_from_slice(&schema_id.to_le_bytes());
1005    buf.extend_from_slice(&index_generation.to_le_bytes());
1006    buf.extend_from_slice(&run_generation.to_le_bytes());
1007    buf.extend_from_slice(&format_version.to_le_bytes());
1008    // Include the run level alongside the run id so a compacted-down topology
1009    // (level 0 → level 1 promotion) doesn't accidentally share a fingerprint
1010    // with the pre-compaction set.
1011    for run in run_ids_ordered {
1012        let run_id_bytes = run.to_le_bytes();
1013        buf.extend_from_slice(&run_id_bytes);
1014    }
1015    // Stable, fixed seed (NOT process-local / time-based).
1016    xxhash_rust::xxh3::xxh3_64_with_seed(&buf, 0x9E37_79B1_854A_0001)
1017}
1018
1019/// Build the file path of a single sharded directory checkpoint for the
1020/// `RowId` range `[start, end_inclusive]`. `end_inclusive == None` means
1021/// the trailing shard (last open-ended range).
1022pub fn directory_shard_path(
1023    base_dir: &Path,
1024    start: u64,
1025    end_inclusive: Option<u64>,
1026) -> std::path::PathBuf {
1027    match end_inclusive {
1028        Some(end) => base_dir.join(format!(
1029            "{DIRECTORY_SHARD_PREFIX}{start:020}-{end:020}{DIRECTORY_SHARD_SUFFIX}"
1030        )),
1031        None => base_dir.join(format!("{DIRECTORY_SHARD_PREFIX}{start:020}-open")),
1032    }
1033}
1034
1035/// Build a `RunLookupDirectory` for a single sorted run without materializing
1036/// any user columns. Uses [`crate::sorted_run::RunReader::for_each_system`]
1037/// (a system-column-only iterator) and folds the result into one
1038/// [`RunLocator`] per `RowId` that the run carries. The caller is expected
1039/// to call this from inside an `Arc<Table>` or equivalent — `table` is
1040/// borrowed for `open_reader`.
1041pub fn rebuild_from_runs_system_only(runs: &[RunRef], table: &Table) -> Result<RunLookupDirectory> {
1042    let mut dir = RunLookupDirectory::empty();
1043    for run_ref in runs {
1044        let mut reader = table.open_reader(run_ref.run_id)?;
1045        // Per-row agg: min epoch, max epoch, min hlc, max hlc,
1046        // contains_unstamped.
1047        type RunRowAgg = (
1048            Epoch,
1049            Epoch,
1050            Option<HlcTimestamp>,
1051            Option<HlcTimestamp>,
1052            bool,
1053        );
1054        let mut per_row: BTreeMap<RowId, RunRowAgg> = BTreeMap::new();
1055        reader.for_each_system(|row_id, committed_epoch, commit_ts, deleted| {
1056            let entry = per_row.entry(row_id).or_insert((
1057                committed_epoch,
1058                committed_epoch,
1059                commit_ts,
1060                commit_ts,
1061                commit_ts.is_none(),
1062            ));
1063            if committed_epoch < entry.0 {
1064                entry.0 = committed_epoch;
1065            }
1066            if committed_epoch > entry.1 {
1067                entry.1 = committed_epoch;
1068            }
1069            match (commit_ts, entry.2) {
1070                (Some(ts), Some(prev)) if ts < prev => entry.2 = Some(ts),
1071                (Some(_), None) => entry.2 = commit_ts,
1072                _ => {}
1073            }
1074            match (commit_ts, entry.3) {
1075                (Some(ts), Some(prev)) if ts > prev => entry.3 = Some(ts),
1076                (Some(_), None) => entry.3 = commit_ts,
1077                _ => {}
1078            }
1079            if commit_ts.is_none() {
1080                entry.4 = true;
1081            }
1082            // Tombstones do not change the locator's min/max envelope (they
1083            // share the row's commit epoch/HLC with the live version that
1084            // preceded them), so the deleted flag is intentionally ignored
1085            // for envelope construction. The HOT/manifest already removes
1086            // tombstones that are superseded by a newer live version.
1087            let _ = deleted;
1088            Ok(())
1089        })?;
1090        for (row_id, (min_epoch, max_epoch, min_hlc, max_hlc, contains_unstamped)) in per_row {
1091            dir.insert(
1092                row_id,
1093                RunLocator {
1094                    run_id: run_ref.run_id,
1095                    min_epoch,
1096                    max_epoch,
1097                    min_hlc,
1098                    max_hlc,
1099                    contains_unstamped_versions: contains_unstamped,
1100                },
1101            );
1102        }
1103    }
1104    Ok(dir)
1105}
1106
1107/// Format version of the on-disk directory. Bumped when the wire format
1108/// changes in a way that older readers cannot safely interpret.
1109pub const DIRECTORY_FORMAT_VERSION: u32 = 1;
1110/// Hard upper bound for a single shard, raised from the 64 MiB legacy limit so
1111/// very large tables (e.g. 256-run benchmarks) can checkpoint without
1112/// truncation. The reader enforces only a soft sanity check.
1113pub const MAX_SHARD_BYTES: u64 = 16 * 1024 * 1024;
1114
1115#[cfg(test)]
1116mod tests {
1117    use super::*;
1118    use tempfile::tempdir;
1119
1120    fn hlc_from_phys(physical_micros: u64) -> HlcTimestamp {
1121        HlcTimestamp {
1122            physical_micros,
1123            logical: 0,
1124            node_tiebreaker: 0,
1125        }
1126    }
1127
1128    fn loc(run_id: u128, epoch: u64, hlc: Option<u64>) -> RunLocator {
1129        RunLocator {
1130            run_id,
1131            min_epoch: Epoch(epoch),
1132            max_epoch: Epoch(epoch),
1133            min_hlc: hlc.map(hlc_from_phys),
1134            max_hlc: hlc.map(hlc_from_phys),
1135            contains_unstamped_versions: hlc.is_none(),
1136        }
1137    }
1138
1139    #[test]
1140    fn empty_directory_returns_empty_list() {
1141        let dir = RunLookupDirectory::empty();
1142        assert!(dir.locate(RowId(42)).is_empty());
1143        assert_eq!(dir.key_count(), 0);
1144    }
1145
1146    #[test]
1147    fn insert_orders_locators_newest_first() {
1148        let mut dir = RunLookupDirectory::empty();
1149        dir.insert(RowId(1), loc(0xA, 1, None));
1150        dir.insert(RowId(1), loc(0xB, 3, None));
1151        dir.insert(RowId(1), loc(0xC, 2, None));
1152        let list = dir.locate(RowId(1));
1153        let ids: Vec<u128> = list.locators.iter().map(|l| l.run_id).collect();
1154        assert_eq!(ids, vec![0xB, 0xC, 0xA]);
1155    }
1156
1157    #[test]
1158    fn retain_active_runs_drops_stale_locators() {
1159        let mut dir = RunLookupDirectory::empty();
1160        dir.insert(RowId(1), loc(10, 1, None));
1161        dir.insert(RowId(1), loc(20, 2, None));
1162        dir.insert(RowId(2), loc(20, 1, None));
1163        let removed = dir.retain_active_runs(&[10]);
1164        assert_eq!(removed, 2);
1165        assert!(dir.locate(RowId(1)).locators.iter().all(|l| l.run_id == 10));
1166        assert!(dir.locate(RowId(2)).is_empty());
1167    }
1168
1169    #[test]
1170    fn hlc_tie_break_resolves_above_epoch_only() {
1171        let mut dir = RunLookupDirectory::empty();
1172        dir.insert(RowId(1), loc(0xA, 5, None));
1173        dir.insert(RowId(1), loc(0xB, 5, Some(100)));
1174        let list = dir.locate(RowId(1));
1175        assert_eq!(list.locators[0].run_id, 0xB, "HLC newer wins on tie");
1176    }
1177
1178    #[test]
1179    fn memory_metrics_report_postings() {
1180        let mut dir = RunLookupDirectory::empty();
1181        dir.insert(RowId(1), loc(1, 1, None));
1182        dir.insert(RowId(1), loc(2, 2, None));
1183        dir.insert(RowId(2), loc(1, 1, None));
1184        assert_eq!(dir.total_locators(), 3);
1185        assert_eq!(dir.key_count(), 2);
1186        assert_eq!(dir.max_locators_per_row(), 2);
1187        assert!((dir.avg_locators_per_row() - 1.5).abs() < 1e-9);
1188    }
1189
1190    #[test]
1191    fn checkpoint_roundtrip_preserves_all_locators() {
1192        let dir = tempdir().unwrap();
1193        let mut src = RunLookupDirectory::empty();
1194        src.set_fingerprint(0x0102_0304_0506_0708);
1195        src.insert(RowId(7), loc(0x11, 1, None));
1196        src.insert(RowId(7), loc(0x22, 5, Some(100)));
1197        src.insert(RowId(8), loc(0x33, 3, Some(50)));
1198        src.insert(RowId(1_000_000), loc(0x44, 9, None));
1199        let path = dir.path().join("lookup.ckpt");
1200        src.write_checkpoint(&path).unwrap();
1201
1202        let restored = RunLookupDirectory::read_checkpoint(&path, 0x0102_0304_0506_0708).unwrap();
1203        assert_eq!(restored.fingerprint(), Some(0x0102_0304_0506_0708));
1204        assert_eq!(restored.key_count(), 3);
1205        assert_eq!(restored.total_locators(), 4);
1206        for (row_id, expected) in [
1207            (RowId(7), vec![0x22, 0x11]),
1208            (RowId(8), vec![0x33]),
1209            (RowId(1_000_000), vec![0x44]),
1210        ] {
1211            let actual: Vec<u128> = restored
1212                .locate(row_id)
1213                .locators
1214                .iter()
1215                .map(|l| l.run_id)
1216                .collect();
1217            assert_eq!(actual, expected, "row_id {row_id} locator order");
1218        }
1219        let list7 = restored.locate(RowId(7));
1220        assert_eq!(
1221            list7.locators[0].min_hlc.map(|t| t.physical_micros),
1222            Some(100)
1223        );
1224        assert!(list7.locators[1].contains_unstamped_versions);
1225    }
1226
1227    #[test]
1228    fn read_checkpoint_rejects_mismatched_fingerprint() {
1229        let dir = tempdir().unwrap();
1230        let mut src = RunLookupDirectory::empty();
1231        src.set_fingerprint(42);
1232        src.insert(RowId(1), loc(7, 1, None));
1233        let path = dir.path().join("lookup.ckpt");
1234        src.write_checkpoint(&path).unwrap();
1235        let err = RunLookupDirectory::read_checkpoint(&path, 43).unwrap_err();
1236        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
1237        assert!(err.to_string().contains("fingerprint"));
1238    }
1239
1240    fn stamped_loc(run_id: u128, epoch: u64, hlc: u64) -> RunLocator {
1241        RunLocator {
1242            run_id,
1243            min_epoch: Epoch(epoch),
1244            max_epoch: Epoch(epoch),
1245            min_hlc: Some(hlc_from_phys(hlc)),
1246            max_hlc: Some(hlc_from_phys(hlc)),
1247            contains_unstamped_versions: false,
1248        }
1249    }
1250
1251    fn mixed_loc(run_id: u128, epoch: u64, hlc: u64) -> RunLocator {
1252        RunLocator {
1253            run_id,
1254            min_epoch: Epoch(epoch),
1255            max_epoch: Epoch(epoch),
1256            min_hlc: Some(hlc_from_phys(hlc)),
1257            max_hlc: Some(hlc_from_phys(hlc)),
1258            contains_unstamped_versions: true,
1259        }
1260    }
1261
1262    #[test]
1263    fn decide_distinguishes_complete_miss_from_candidates() {
1264        let mut dir = RunLookupDirectory::empty();
1265        // No insert for RowId(99): a complete miss.
1266        match dir.decide(RowId(99)) {
1267            DirectoryLookupDecision::CompleteMiss => {}
1268            other => panic!("expected CompleteMiss, got {other:?}"),
1269        }
1270        // Empty posting list is also a complete miss (defensive — should
1271        // not happen in production, but the contract still says miss).
1272        dir.insert(RowId(7), stamped_loc(1, 1, 100));
1273        dir.insert(RowId(7), stamped_loc(2, 2, 200));
1274        match dir.decide(RowId(7)) {
1275            DirectoryLookupDecision::Candidates(list) => assert_eq!(list.len(), 2),
1276            other => panic!("expected Candidates, got {other:?}"),
1277        }
1278    }
1279
1280    #[test]
1281    fn early_stop_epoch_only_proves_no_beat() {
1282        // Locator max_epoch = 5; best = epoch 10; cannot beat.
1283        let l = loc(1, 5, None);
1284        let snap = Snapshot::at(Epoch(20));
1285        let best = VersionStamp {
1286            epoch: Epoch(10),
1287            hlc: None,
1288        };
1289        assert!(
1290            !l.can_contain_version_newer_than(best, snap),
1291            "max_epoch <= best.epoch must early-stop"
1292        );
1293    }
1294
1295    #[test]
1296    fn early_stop_epoch_only_proves_beat() {
1297        // Locator max_epoch = 15; best = epoch 10; visible cap 20.
1298        let l = loc(1, 15, None);
1299        let snap = Snapshot::at(Epoch(20));
1300        let best = VersionStamp {
1301            epoch: Epoch(10),
1302            hlc: None,
1303        };
1304        assert!(
1305            l.can_contain_version_newer_than(best, snap),
1306            "max_epoch > best.epoch must not early-stop"
1307        );
1308    }
1309
1310    #[test]
1311    fn early_stop_pure_hlc_proves_no_beat() {
1312        // Locator max HLC = 100; best HLC = 200; snapshot HLC = 1000.
1313        // Visible cap = min(100, 1000) = 100 <= best.hlc=200 → no beat.
1314        let l = stamped_loc(1, 5, 100);
1315        let snap = Snapshot::at_hlc(Epoch(20), hlc_from_phys(1000));
1316        let best = VersionStamp {
1317            epoch: Epoch(5),
1318            hlc: Some(hlc_from_phys(200)),
1319        };
1320        assert!(
1321            !l.can_contain_version_newer_than(best, snap),
1322            "pure HLC: max visible HLC <= best HLC must early-stop"
1323        );
1324    }
1325
1326    #[test]
1327    fn early_stop_pure_hlc_proves_beat() {
1328        // Locator max HLC = 500; best HLC = 200; snapshot HLC = 1000.
1329        // Visible cap = min(500, 1000) = 500 > best.hlc=200 → beat possible.
1330        let l = stamped_loc(1, 5, 500);
1331        let snap = Snapshot::at_hlc(Epoch(20), hlc_from_phys(1000));
1332        let best = VersionStamp {
1333            epoch: Epoch(5),
1334            hlc: Some(hlc_from_phys(200)),
1335        };
1336        assert!(
1337            l.can_contain_version_newer_than(best, snap),
1338            "pure HLC: max visible HLC > best HLC must not early-stop"
1339        );
1340    }
1341
1342    #[test]
1343    fn early_stop_pure_hlc_respects_snapshot_cap() {
1344        // Locator max HLC = 5000; best HLC = 200; snapshot HLC = 100.
1345        // Visibility caps at snapshot, so locator cannot contain anything
1346        // strictly newer than best AND visible. Note: this is the case
1347        // where `is_impossible_for` would skip the run entirely; the early-
1348        // stop proof must agree when best is already pinned.
1349        let l = stamped_loc(1, 5, 5000);
1350        let snap = Snapshot::at_hlc(Epoch(20), hlc_from_phys(100));
1351        let best = VersionStamp {
1352            epoch: Epoch(5),
1353            hlc: Some(hlc_from_phys(200)),
1354        };
1355        assert!(
1356            !l.can_contain_version_newer_than(best, snap),
1357            "snapshot HLC cap below locator max must early-stop when best > cap"
1358        );
1359    }
1360
1361    #[test]
1362    fn early_stop_mixed_with_unstamped_falls_back_to_epoch() {
1363        // Locator has stamped HLC=100 AND unstamped; best has high epoch
1364        // 200 but no HLC. Mixed comparison falls back to epoch. Even
1365        // though the locator's max HLC is below any sensible best, the
1366        // unstamped rows can beat on epoch alone.
1367        let l = mixed_loc(1, 250, 100);
1368        let snap = Snapshot::at_hlc(Epoch(300), hlc_from_phys(1000));
1369        let best = VersionStamp {
1370            epoch: Epoch(200),
1371            hlc: None,
1372        };
1373        assert!(
1374            l.can_contain_version_newer_than(best, snap),
1375            "mixed: unstamped epoch beat path must not early-stop"
1376        );
1377        // Inverse: best's epoch exceeds the locator's max.
1378        let l = mixed_loc(1, 100, 5000);
1379        let best = VersionStamp {
1380            epoch: Epoch(200),
1381            hlc: None,
1382        };
1383        assert!(
1384            !l.can_contain_version_newer_than(best, snap),
1385            "mixed: max epoch <= best.epoch must early-stop"
1386        );
1387    }
1388
1389    #[test]
1390    fn early_stop_hlc_snap_with_unstamped_best_uses_epoch_path() {
1391        // best has no HLC; even under HLC-authoritative snap, comparison
1392        // falls back to epoch (mixed comparison rule).
1393        let l = stamped_loc(1, 250, 999);
1394        let snap = Snapshot::at_hlc(Epoch(300), hlc_from_phys(1000));
1395        let best = VersionStamp {
1396            epoch: Epoch(200),
1397            hlc: None,
1398        };
1399        assert!(
1400            l.can_contain_version_newer_than(best, snap),
1401            "unstamped best: epoch path wins even under HLC-authority snap"
1402        );
1403    }
1404
1405    #[test]
1406    fn early_stop_unknown_metadata_stays_conservative() {
1407        // Spec §36.4: when locator metadata cannot prove a remaining run
1408        // is unable to beat the winner, open the run. Construct a locator
1409        // whose epoch bound is *equal* to the best — strict inequality is
1410        // required to prove the locator cannot beat, so a tie must stay
1411        // conservative.
1412        let l = RunLocator {
1413            run_id: 1,
1414            min_epoch: Epoch(10),
1415            max_epoch: Epoch(10),
1416            min_hlc: Some(hlc_from_phys(50)),
1417            max_hlc: Some(hlc_from_phys(50)),
1418            contains_unstamped_versions: false,
1419        };
1420        let snap = Snapshot::at_hlc(Epoch(20), hlc_from_phys(100));
1421        let best = VersionStamp {
1422            epoch: Epoch(10),
1423            hlc: Some(hlc_from_phys(50)),
1424        };
1425        // Strict equality is not a beat — but the proof is conservative
1426        // and never asserts a tie is a win.
1427        assert!(
1428            !l.can_contain_version_newer_than(best, snap),
1429            "strict tie (equal epoch+HLC) is not a beat; safe to early-stop"
1430        );
1431
1432        // Truly unknown: locator with no HLC bounds AND no
1433        // contains_unstamped flag — metadata is contradictory and we
1434        // cannot prove anything in either direction. The proof must stay
1435        // conservative and keep the run open.
1436        let l_unknown = RunLocator {
1437            run_id: 2,
1438            min_epoch: Epoch(10),
1439            max_epoch: Epoch(10),
1440            min_hlc: None,
1441            max_hlc: None,
1442            contains_unstamped_versions: false,
1443        };
1444        let best_lower = VersionStamp {
1445            epoch: Epoch(5),
1446            hlc: Some(hlc_from_phys(50)),
1447        };
1448        assert!(
1449            l_unknown.can_contain_version_newer_than(best_lower, snap),
1450            "unknown metadata with higher possible epoch must stay conservative"
1451        );
1452    }
1453
1454    // ------------------------------------------------------------------
1455    // REM-A (spec §5.6): the HLC impossibility proof must use `min_hlc`.
1456    // ------------------------------------------------------------------
1457
1458    #[test]
1459    fn locator_spanning_hlc_snapshot_is_not_impossible() {
1460        // One run carries an old visible version (HLC 100) and a newer
1461        // invisible version (HLC 1,000); the snapshot sits between them.
1462        let locator = RunLocator {
1463            run_id: 1,
1464            min_epoch: Epoch(10),
1465            max_epoch: Epoch(20),
1466            min_hlc: Some(hlc_from_phys(100)),
1467            max_hlc: Some(hlc_from_phys(1_000)),
1468            contains_unstamped_versions: false,
1469        };
1470        let snapshot = Snapshot::at_hlc(Epoch(15), hlc_from_phys(500));
1471        assert!(
1472            !locator.is_impossible_for(snapshot),
1473            "min_hlc 100 <= snapshot 500: the run spans the snapshot and must be opened"
1474        );
1475    }
1476
1477    #[test]
1478    fn locator_entirely_after_hlc_snapshot_is_impossible() {
1479        let locator = RunLocator {
1480            run_id: 1,
1481            min_epoch: Epoch(10),
1482            max_epoch: Epoch(20),
1483            min_hlc: Some(hlc_from_phys(600)),
1484            max_hlc: Some(hlc_from_phys(1_000)),
1485            contains_unstamped_versions: false,
1486        };
1487        let snapshot = Snapshot::at_hlc(Epoch(15), hlc_from_phys(500));
1488        assert!(
1489            locator.is_impossible_for(snapshot),
1490            "min_hlc 600 > snapshot 500: every stamped version is too new"
1491        );
1492    }
1493
1494    #[test]
1495    fn locator_entirely_before_hlc_snapshot_is_possible() {
1496        let locator = RunLocator {
1497            run_id: 1,
1498            min_epoch: Epoch(10),
1499            max_epoch: Epoch(20),
1500            min_hlc: Some(hlc_from_phys(100)),
1501            max_hlc: Some(hlc_from_phys(200)),
1502            contains_unstamped_versions: false,
1503        };
1504        let snapshot = Snapshot::at_hlc(Epoch(15), hlc_from_phys(500));
1505        assert!(
1506            !locator.is_impossible_for(snapshot),
1507            "locator entirely below the snapshot is obviously possible"
1508        );
1509    }
1510
1511    #[test]
1512    fn mixed_locator_is_impossible_only_when_both_paths_are_excluded() {
1513        // Stamped path open (min_hlc visible) even though the epoch bound
1514        // exceeds the snapshot epoch.
1515        let stamped_side_open = RunLocator {
1516            run_id: 1,
1517            min_epoch: Epoch(60),
1518            max_epoch: Epoch(70),
1519            min_hlc: Some(hlc_from_phys(100)),
1520            max_hlc: Some(hlc_from_phys(900)),
1521            contains_unstamped_versions: true,
1522        };
1523        let snap = Snapshot::at_hlc(Epoch(50), hlc_from_phys(500));
1524        assert!(
1525            !stamped_side_open.is_impossible_for(snap),
1526            "stamped min_hlc 100 <= 500 keeps the mixed locator possible"
1527        );
1528        // Unstamped path open (min_epoch visible) even though every HLC is
1529        // above the snapshot.
1530        let unstamped_side_open = RunLocator {
1531            run_id: 2,
1532            min_epoch: Epoch(40),
1533            max_epoch: Epoch(70),
1534            min_hlc: Some(hlc_from_phys(600)),
1535            max_hlc: Some(hlc_from_phys(900)),
1536            contains_unstamped_versions: true,
1537        };
1538        assert!(
1539            !unstamped_side_open.is_impossible_for(snap),
1540            "unstamped min_epoch 40 <= 50 keeps the mixed locator possible"
1541        );
1542        // Both paths excluded: min_hlc above the snapshot AND min_epoch
1543        // above the snapshot epoch.
1544        let fully_after = RunLocator {
1545            run_id: 3,
1546            min_epoch: Epoch(60),
1547            max_epoch: Epoch(70),
1548            min_hlc: Some(hlc_from_phys(600)),
1549            max_hlc: Some(hlc_from_phys(900)),
1550            contains_unstamped_versions: true,
1551        };
1552        assert!(
1553            fully_after.is_impossible_for(snap),
1554            "every stamped version is HLC-invisible and every unstamped version is epoch-invisible"
1555        );
1556    }
1557
1558    // ------------------------------------------------------------------
1559    // REM-B (spec §6.12): authority-matrix tests for the split
1560    // stamped/unstamped early-stop proof.
1561    // ------------------------------------------------------------------
1562
1563    /// Spec §6.5: a mixed locator (unstamped epoch 50 + stamped epoch 40 /
1564    /// HLC 300) must not be epoch-pruned against a stamped best (epoch 100 /
1565    /// HLC 200) — the stamped candidate wins by HLC.
1566    #[test]
1567    fn mixed_locator_stamped_candidate_can_beat_stamped_best_by_hlc() {
1568        let locator = RunLocator {
1569            run_id: 1,
1570            min_epoch: Epoch(40),
1571            max_epoch: Epoch(50),
1572            min_hlc: Some(hlc_from_phys(300)),
1573            max_hlc: Some(hlc_from_phys(300)),
1574            contains_unstamped_versions: true,
1575        };
1576        let snapshot = Snapshot::at_hlc(Epoch(200), hlc_from_phys(400));
1577        let best = VersionStamp {
1578            epoch: Epoch(100),
1579            hlc: Some(hlc_from_phys(200)),
1580        };
1581        assert!(
1582            locator.can_contain_version_newer_than(best, snapshot),
1583            "stamped candidate HLC 300 > best HLC 200; the locator must be opened"
1584        );
1585    }
1586
1587    /// Spec §6.6: under an epoch-only snapshot, two stamped candidates are
1588    /// still ordered by HLC — the epoch-only snapshot must not disable HLC
1589    /// recency.
1590    #[test]
1591    fn epoch_snapshot_stamped_candidate_can_beat_stamped_best_by_hlc() {
1592        let locator = stamped_loc(1, 50, 300);
1593        let snapshot = Snapshot::at(Epoch(200));
1594        let best = VersionStamp {
1595            epoch: Epoch(100),
1596            hlc: Some(hlc_from_phys(200)),
1597        };
1598        assert!(
1599            locator.can_contain_version_newer_than(best, snapshot),
1600            "stamped vs stamped recency is HLC-based even under an epoch-only snapshot"
1601        );
1602        // Negative cell: the locator's max HLC does not exceed the best.
1603        let older = stamped_loc(2, 50, 150);
1604        assert!(
1605            !older.can_contain_version_newer_than(best, snapshot),
1606            "max_hlc 150 <= best HLC 200 and no unstamped rows: provably cannot beat"
1607        );
1608        // Negative cell: no stamped row can be epoch-visible.
1609        let future = RunLocator {
1610            run_id: 3,
1611            min_epoch: Epoch(300),
1612            max_epoch: Epoch(400),
1613            min_hlc: Some(hlc_from_phys(300)),
1614            max_hlc: Some(hlc_from_phys(300)),
1615            contains_unstamped_versions: false,
1616        };
1617        assert!(
1618            !future.can_contain_version_newer_than(best, snapshot),
1619            "min_epoch 300 > snapshot epoch 200: no stamped row is epoch-visible"
1620        );
1621    }
1622
1623    /// Spec §6.7: a stamped candidate under HLC visibility is visible
1624    /// regardless of its local epoch, so its epoch must NOT be capped by
1625    /// `snapshot.epoch` when the best is unstamped.
1626    #[test]
1627    fn hlc_snapshot_stamped_candidate_can_beat_unstamped_best_by_epoch() {
1628        let locator = stamped_loc(1, 500, 200);
1629        let snapshot = Snapshot::at_hlc(Epoch(300), hlc_from_phys(300));
1630        let best = VersionStamp {
1631            epoch: Epoch(400),
1632            hlc: None,
1633        };
1634        assert!(
1635            locator.can_contain_version_newer_than(best, snapshot),
1636            "candidate HLC 200 <= snapshot 300 and epoch 500 > best epoch 400: must open"
1637        );
1638        // Negative cell: the stamped candidate is HLC-invisible.
1639        let invisible = stamped_loc(2, 500, 400);
1640        assert!(
1641            !invisible.can_contain_version_newer_than(best, snapshot),
1642            "min_hlc 400 > snapshot 300: no stamped version is visible"
1643        );
1644        // Negative cell: visible, but its epoch cannot beat the best.
1645        let older = stamped_loc(3, 350, 200);
1646        assert!(
1647            !older.can_contain_version_newer_than(best, snapshot),
1648            "max_epoch 350 <= best epoch 400: provably cannot beat"
1649        );
1650    }
1651
1652    /// Unstamped candidates use epoch visibility and epoch recency under
1653    /// both snapshot modes.
1654    #[test]
1655    fn unstamped_candidate_uses_epoch_visibility_under_hlc_snapshot() {
1656        let locator = loc(1, 250, None); // unstamped-only, epoch 250
1657        let snapshot = Snapshot::at_hlc(Epoch(300), hlc_from_phys(1_000));
1658        let best = VersionStamp {
1659            epoch: Epoch(200),
1660            hlc: Some(hlc_from_phys(50)),
1661        };
1662        assert!(
1663            locator.can_contain_version_newer_than(best, snapshot),
1664            "unstamped epoch 250 > best epoch 200 and visible at snapshot epoch 300"
1665        );
1666        // Visibility cap: the snapshot epoch hides the whole locator.
1667        let tight_snap = Snapshot::at_hlc(Epoch(240), hlc_from_phys(1_000));
1668        let tight_best = VersionStamp {
1669            epoch: Epoch(240),
1670            hlc: Some(hlc_from_phys(50)),
1671        };
1672        assert!(
1673            !locator.can_contain_version_newer_than(tight_best, tight_snap),
1674            "min(250, 240) = 240 <= best epoch 240: no visible unstamped version can beat"
1675        );
1676        // Epoch-only snapshot behaves identically for unstamped candidates.
1677        let epoch_snap = Snapshot::at(Epoch(300));
1678        assert!(
1679            locator.can_contain_version_newer_than(best, epoch_snap),
1680            "unstamped epoch comparison does not depend on the snapshot mode"
1681        );
1682    }
1683
1684    /// Metadata that cannot prove anything in either direction must keep the
1685    /// run open.
1686    #[test]
1687    fn unknown_metadata_is_conservative() {
1688        let best = VersionStamp {
1689            epoch: Epoch(5),
1690            hlc: Some(hlc_from_phys(50)),
1691        };
1692        let hlc_snap = Snapshot::at_hlc(Epoch(20), hlc_from_phys(100));
1693        let epoch_snap = Snapshot::at(Epoch(20));
1694        // Contradictory: claims fully stamped but records no HLC envelope.
1695        let no_envelope = RunLocator {
1696            run_id: 1,
1697            min_epoch: Epoch(10),
1698            max_epoch: Epoch(10),
1699            min_hlc: None,
1700            max_hlc: None,
1701            contains_unstamped_versions: false,
1702        };
1703        for snap in [hlc_snap, epoch_snap] {
1704            assert!(
1705                no_envelope.can_contain_version_newer_than(best, snap),
1706                "missing HLC envelope without the unstamped flag is unknown: stay conservative"
1707            );
1708        }
1709        // Partial envelope (min without max) is equally unprovable.
1710        let partial = RunLocator {
1711            min_hlc: Some(hlc_from_phys(10)),
1712            ..no_envelope
1713        };
1714        assert!(
1715            partial.can_contain_version_newer_than(best, hlc_snap),
1716            "a partial HLC envelope cannot prove impossibility: stay conservative"
1717        );
1718    }
1719
1720    /// Strict inequality is required everywhere: equal HLC or equal epoch
1721    /// never counts as newer.
1722    #[test]
1723    fn equal_authority_does_not_count_as_strictly_newer() {
1724        let hlc_snap = Snapshot::at_hlc(Epoch(20), hlc_from_phys(100));
1725        // Stamped vs stamped, equal HLC.
1726        let stamped = stamped_loc(1, 10, 50);
1727        let stamped_best = VersionStamp {
1728            epoch: Epoch(10),
1729            hlc: Some(hlc_from_phys(50)),
1730        };
1731        assert!(
1732            !stamped.can_contain_version_newer_than(stamped_best, hlc_snap),
1733            "equal HLC is not strictly newer"
1734        );
1735        // Unstamped vs unstamped, equal epoch.
1736        let unstamped = loc(2, 10, None);
1737        let unstamped_best = VersionStamp {
1738            epoch: Epoch(10),
1739            hlc: None,
1740        };
1741        assert!(
1742            !unstamped.can_contain_version_newer_than(unstamped_best, hlc_snap),
1743            "equal epoch is not strictly newer"
1744        );
1745        // Stamped vs unstamped best under an epoch-only snapshot, equal
1746        // epoch.
1747        let epoch_snap = Snapshot::at(Epoch(20));
1748        assert!(
1749            !stamped.can_contain_version_newer_than(unstamped_best, epoch_snap),
1750            "equal epoch is not strictly newer on the stamped-vs-unstamped path"
1751        );
1752        // Unstamped candidate vs stamped best, equal epoch.
1753        assert!(
1754            !unstamped.can_contain_version_newer_than(stamped_best, epoch_snap),
1755            "equal epoch is not strictly newer on the unstamped path"
1756        );
1757    }
1758}