Skip to main content

mongreldb_core/
trace.rs

1//! Query path instrumentation (OPTIMIZATIONS.md Priority 0 / 16).
2//!
3//! MongrelDB has many physical paths a query can take: an O(1) metadata count,
4//! a zero-copy Arrow IPC shadow, a single-run lazy page cursor, a multi-run
5//! k-way merge cursor, an index-pushdown columnar gather, or a full row
6//! materialization. Correctness tests verify *results* but never reveal *which*
7//! path ran, so performance regressions are currently invisible until a
8//! benchmark accidentally trips one.
9//!
10//! [`QueryTrace`] makes those path decisions observable. It is a lightweight,
11//! opt-in record filled at decision points via a thread-local scope collector.
12//! The hot path (no capture active) pays only a single TLS load per record site
13//! and then returns immediately — there is no allocation, no lock, and no
14//! signature change to the hundreds of internal functions that serve queries.
15//!
16//! ## Usage
17//!
18//! The public `_traced` methods on [`crate::engine::Table`] (and
19//! `MongrelSession::run_sql_traced` in the query crate) wrap the corresponding
20//! query in [`QueryTrace::capture`] and return the result alongside the trace:
21//!
22//! ```no_run
23//! # use mongreldb_core::*;
24//! # let mut db: Table = unimplemented!();
25//! # let snap = db.snapshot();
26//! # let conditions = &[];
27//! # let proj = &[];
28//! let (cols, trace) = db.query_columns_native_traced(conditions, Some(proj), snap).unwrap();
29//! assert_eq!(trace.scan_mode, trace::ScanMode::NativePushdown);
30//! assert_eq!(trace.index_rebuild, trace::IndexRebuild::AlreadyComplete);
31//! ```
32//!
33//! ## Extensibility
34//!
35//! New fields can be added to [`QueryTrace`] freely — it is `#[derive(Default)]`,
36//! so existing callers and tests continue to compile. New recording sites are a
37//! single [`QueryTrace::record`] call at the decision point; no plumbing is
38//! required because the thread-local stack is the transport.
39
40use std::cell::RefCell;
41use std::fmt;
42
43thread_local! {
44    /// A stack of in-progress traces, supporting nested captures (an inner
45    /// `capture` gets its own fresh trace; the outer trace is unaffected).
46    static STACK: RefCell<Vec<QueryTrace>> = const { RefCell::new(Vec::new()) };
47}
48
49/// Which physical scan path served a query. Recorded by the SQL scan
50/// ([`crate::scan`]) and the native query entry points. Used in benchmarks and
51/// path-sensitive tests to assert that the expected path was taken.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum ScanMode {
54    /// The trace was never filled (no recording site ran). Indicates a path
55    /// that has not yet been instrumented.
56    #[default]
57    Unknown,
58    /// `COUNT(*)` / empty projection answered from the maintained `live_count`
59    /// metadata in O(1) — no run read, no index resolve.
60    CountMetadata,
61    /// `COUNT(*)` with a pushed `WHERE` answered from survivor set cardinality
62    /// via [`crate::engine::Table::count_conditions`] — index resolve only, no
63    /// column decode.
64    CountSurvivors,
65    /// Zero-copy Arrow IPC shadow read — no per-column decode at all (clean
66    /// single-run unfiltered table).
67    ArrowShadow,
68    /// Single-run lazy page cursor: fused predicate + page skip + late
69    /// materialization ([`crate::cursor::NativePageCursor`]).
70    NativePageCursor,
71    /// Multi-run k-way merge cursor ([`crate::cursor::MultiRunCursor`]).
72    MultiRunCursor,
73    /// Index pushdown fast path: survivors resolved then gathered column-wise
74    /// from a single reader ([`crate::engine::Table::query_columns_native`]
75    /// fast path — no cursor streaming, but no row materialization either).
76    NativePushdown,
77    /// Full materialization fallback: `visible_columns_native` or
78    /// `rows_for_rids` — rows go through the `Row { HashMap }` shape. This is
79    /// the path optimizations try to avoid.
80    Materialized,
81    /// §5.3 direct SQL dispatch: a simple single-table `SELECT` recognized from
82    /// the raw SQL (sqlparser AST) and served straight from the native column
83    /// cursor, bypassing DataFusion parse+plan+optimize entirely.
84    DirectDispatch,
85    /// DataFusion scan served by an external table module / virtual table
86    /// provider rather than a native MongrelDB storage table.
87    ExternalModule,
88}
89
90impl fmt::Display for ScanMode {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        let s = match self {
93            ScanMode::Unknown => "unknown",
94            ScanMode::CountMetadata => "count-metadata",
95            ScanMode::CountSurvivors => "count-survivors",
96            ScanMode::ArrowShadow => "arrow-shadow",
97            ScanMode::NativePageCursor => "native-page-cursor",
98            ScanMode::MultiRunCursor => "multi-run-cursor",
99            ScanMode::NativePushdown => "native-pushdown",
100            ScanMode::Materialized => "materialized",
101            ScanMode::DirectDispatch => "direct-dispatch",
102            ScanMode::ExternalModule => "external-module",
103        };
104        f.write_str(s)
105    }
106}
107
108/// Which join execution path served a query (Priority 13: join diagnostics).
109/// `None` for non-join queries.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
111pub enum JoinMode {
112    /// No join in the query (or the join path was never reached).
113    #[default]
114    None,
115    /// Native FK↔PK roaring-bitmap intersection — no hash-join materialization
116    /// ([`crate::engine::Table`] index resolve only).
117    FkBitmap,
118    /// Fell back to DataFusion's hash join (shape the native path can't serve).
119    DataFusionHash,
120}
121
122impl fmt::Display for JoinMode {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        f.write_str(match self {
125            JoinMode::None => "none",
126            JoinMode::FkBitmap => "fk-bitmap",
127            JoinMode::DataFusionHash => "datafusion-hash",
128        })
129    }
130}
131
132/// Whether `ensure_indexes_complete` rebuilt indexes during this query. A
133/// rebuild is the user-facing stall case (Priority 10); this field exposes it.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
135pub enum IndexRebuild {
136    /// No index rebuild happened (no query ran, or the table had no indexes).
137    #[default]
138    None,
139    /// Indexes were already complete; `ensure_indexes_complete` was a no-op.
140    AlreadyComplete,
141    /// Indexes were rebuilt from runs during this query (the stall case).
142    Rebuilt,
143}
144
145/// Records which engine path a query took. Filled at decision points via
146/// [`QueryTrace::record`]; collected via [`QueryTrace::capture`].
147///
148/// All fields are `#[derive(Default)]`, so adding a new field is non-breaking.
149/// Fields default to zero / `false` / `None` so a trace from an uninstrumented
150/// path still reads cleanly.
151#[derive(Debug, Default, Clone)]
152pub struct QueryTrace {
153    /// The physical scan path that served this query.
154    pub scan_mode: ScanMode,
155    /// Number of sorted runs in the table at query time. `1` = single-run fast
156    /// path eligible; `>1` = k-way merge; `0` = empty/memtable-only table.
157    pub run_count: usize,
158    /// Rows in the memtable overlay (unflushed puts/updates/deletes).
159    pub memtable_rows: usize,
160    /// Rows in the mutable-run tier overlay.
161    pub mutable_run_rows: usize,
162    /// Rows in the materialized overlay batch yielded by a cursor (memtable +
163    /// mutable-run combined, post-filter). Non-zero means the query paid
164    /// overlay materialization cost.
165    pub overlay_rows: usize,
166    /// How many conditions were translated to native pushdown (index-served).
167    pub conditions_pushed: usize,
168    /// How many conditions could not be pushed down (residual / fallback).
169    pub conditions_residual: usize,
170    /// Survivor row count after predicate resolution, if known without decoding
171    /// data columns (set for index-served and count paths).
172    pub survivor_count: Option<usize>,
173    /// Whether `ensure_indexes_complete` rebuilt indexes during this query.
174    pub index_rebuild: IndexRebuild,
175    /// Whether the fast clean-run row-id→position arithmetic was used (avoids
176    /// decoding + binary-searching the system row-id column).
177    pub fast_row_id_map: bool,
178    /// Whether a learned (PGM) range index served a `Range`/`RangeF64`
179    /// condition (in-memory, no column read).
180    pub learned_range_used: bool,
181    /// Whether the result cache returned a hit (no re-decode / re-resolve).
182    pub result_cache_hit: bool,
183    /// Whether rows were materialized as `Row { HashMap }` (the slow path).
184    pub row_materialized: bool,
185    /// Number of pages decoded (lazily filled by cursors when capturing).
186    pub pages_decoded: usize,
187    /// Number of pages skipped by page-stat pruning or empty page plans.
188    pub pages_skipped: usize,
189    /// Which join path served the query (Priority 13). `None` for non-joins.
190    pub join_mode: JoinMode,
191    /// Logical-planning time in nanoseconds (Priority 8: parse + plan, separate
192    /// from execution). `0` on a result-cache hit (planning was skipped) or when
193    /// the plan came from the logical-plan cache.
194    pub planning_nanos: u64,
195    /// Authorization/RLS candidate-cache work for the query.
196    pub authorization_nanos: u64,
197    pub rls_cache_hit: bool,
198    pub rls_rows_evaluated: usize,
199    pub rls_policy_columns_decoded: usize,
200    pub authorization_retries: usize,
201    /// AI retrieval stage timings and bounded cardinalities.
202    pub hard_filter_nanos: u64,
203    /// ANN backend selected by the authoritative index schema.
204    pub ann_algorithm: Option<crate::schema::AnnAlgorithm>,
205    /// ANN representation selected by the authoritative index schema.
206    pub ann_quantization: Option<crate::schema::AnnQuantization>,
207    /// Concrete backend executing the ANN candidate search.
208    pub ann_backend: Option<&'static str>,
209    pub ann_candidate_nanos: u64,
210    pub ann_candidate_cap_hit: bool,
211    pub sparse_candidate_nanos: u64,
212    pub minhash_candidate_nanos: u64,
213    pub candidate_count: usize,
214    pub union_size: usize,
215    pub fusion_nanos: u64,
216    pub exact_vector_gather_nanos: u64,
217    pub exact_vector_score_nanos: u64,
218    pub exact_set_gather_nanos: u64,
219    pub exact_set_parse_nanos: u64,
220    pub exact_set_score_nanos: u64,
221    pub projection_nanos: u64,
222    pub projection_rows: usize,
223    pub projection_cells: usize,
224    pub work_consumed: usize,
225    pub total_nanos: u64,
226
227    // ---- TODO §1: point-lookup directory trace fields --------------------
228    /// Whether the `RunLookupDirectory` was consulted and produced a complete
229    /// candidate set for this get. `false` means the legacy range-scan fallback
230    /// ran (which is acceptable but not optimal).
231    pub directory_complete: bool,
232    /// Number of run locators the directory returned for the queried `RowId`.
233    pub directory_candidates: usize,
234    /// Number of runs rejected by the header-derived `run_row_id_ranges` filter.
235    pub run_range_rejects: usize,
236    /// Number of runs rejected by membership / predicate filters before open.
237    pub membership_filter_rejects: usize,
238    /// How many immutable run readers were actually opened.
239    pub run_readers_opened: usize,
240    /// Set when the lookup short-circuited because the best candidate was
241    /// provably newer than every remaining locator's upper bound.
242    pub early_stop: bool,
243    /// Point cache hits (replay of a recent in-process lookup result).
244    pub point_cache_hits: usize,
245
246    // ---- TODO §3: controlled-scan streaming trace fields ------------------
247    /// Total versions examined across all segments during the scan.
248    pub controlled_scan_versions_examined: usize,
249    /// Rows emitted by the scan (post-filter).
250    pub controlled_scan_rows_emitted: usize,
251    /// Number of times a segment cursor was refilled.
252    pub controlled_scan_source_refills: usize,
253    /// Peak number of buffered rows held by the streaming merge at any moment.
254    pub controlled_scan_peak_source_buffer_rows: usize,
255    /// Peak number of versions seen for any single `RowId` during the scan.
256    pub controlled_scan_peak_same_row_versions: usize,
257    /// Number of `ExecutionControl::checkpoint` calls issued during the scan.
258    pub controlled_scan_checkpoints: usize,
259    /// Wall-clock time to produce the first row, in microseconds.
260    pub controlled_scan_time_to_first_row_us: u64,
261    /// Time from `cancel` to the scan actually observing cancellation, in µs.
262    pub controlled_scan_cancel_latency_us: u64,
263    /// Wall-clock time spent constructing the controlled-scan sources, in µs.
264    pub controlled_scan_setup_time_us: u64,
265
266    // ---- TODO §5: HOT fallback trace fields ------------------------------
267    /// Whether a HOT lookup was attempted for this query.
268    pub hot_lookup_attempted: bool,
269    /// Whether the HOT lookup was a hit (no fallback).
270    pub hot_lookup_hit: bool,
271    /// Stable reason label when the HOT lookup fell back. `None` on hit.
272    pub hot_fallback_reason: Option<&'static str>,
273    /// Overlay versions examined during the fallback path.
274    pub hot_fallback_overlay_versions: usize,
275    /// Sorted runs considered during the fallback path.
276    pub hot_fallback_runs_considered: usize,
277    /// Sorted run readers actually opened during the fallback path.
278    pub hot_fallback_runs_opened: usize,
279    /// Pages decoded during the fallback path.
280    pub hot_fallback_pages_decoded: usize,
281    /// Rows materialized during the fallback path.
282    pub hot_fallback_rows_materialized: usize,
283    /// Wall-clock time spent on the HOT fast-path lookup, in nanoseconds.
284    pub hot_lookup_nanos: u64,
285    /// Wall-clock time spent on the fallback path, in nanoseconds.
286    pub hot_fallback_nanos: u64,
287
288    // ---- TODO §4: per-family retrieval trace fields ----------------------
289    /// Raw candidates produced by the index backend before dedup.
290    pub raw_candidates: usize,
291    /// Unique candidates after `(RowId)` dedup.
292    pub unique_candidates: usize,
293    /// Duplicate candidates dropped during dedup.
294    pub duplicate_candidates: usize,
295    /// Candidates rejected by `Snapshot::observes_row`.
296    pub visibility_rejected: usize,
297    /// Candidates rejected as tombstones.
298    pub tombstone_rejected: usize,
299    /// Candidates rejected by TTL.
300    pub ttl_rejected: usize,
301    /// Candidates rejected by authorization (RLS / allowed set).
302    pub authorization_rejected: usize,
303    /// Candidates rejected by hard filter.
304    pub hard_filter_rejected: usize,
305    /// Configured candidate cap for the retrieval.
306    pub candidate_cap: usize,
307    /// Whether the candidate cap was hit.
308    pub candidate_cap_hit: bool,
309    /// Final number of hits returned to the caller.
310    pub final_hits: usize,
311    /// Stable explanation when a top-k retrieval legally returns fewer than k.
312    pub underfill_reason: Option<&'static str>,
313}
314
315/// Reasons a HOT (`Hash-Organized Table`) PK lookup may fall back to the slower
316/// overlay + sorted-run path. Stable identifiers; the string literals are the
317/// stable wire/label form (see TODO §5.1).
318#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
319pub enum HotFallbackReason {
320    MissingMapping,
321    StaleRowId,
322    InvisibleAtSnapshot,
323    HistoricalSnapshot,
324    Tombstone,
325    TtlExpired,
326    PrimaryKeyMismatch,
327    IndexIncomplete,
328    CheckpointRejected,
329}
330
331impl HotFallbackReason {
332    pub fn as_str(self) -> &'static str {
333        match self {
334            HotFallbackReason::MissingMapping => "missing_mapping",
335            HotFallbackReason::StaleRowId => "stale_row_id",
336            HotFallbackReason::InvisibleAtSnapshot => "invisible_at_snapshot",
337            HotFallbackReason::HistoricalSnapshot => "historical_snapshot",
338            HotFallbackReason::Tombstone => "tombstone",
339            HotFallbackReason::TtlExpired => "ttl_expired",
340            HotFallbackReason::PrimaryKeyMismatch => "primary_key_mismatch",
341            HotFallbackReason::IndexIncomplete => "index_incomplete",
342            HotFallbackReason::CheckpointRejected => "checkpoint_rejected",
343        }
344    }
345}
346
347impl fmt::Display for HotFallbackReason {
348    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
349        f.write_str(self.as_str())
350    }
351}
352
353/// Diagnostic classification of a HOT (`Hash-Organized Table`) candidate row.
354///
355/// `Table::get` collapses several distinct failure modes into `None`
356/// (tombstone, TTL expiry, snapshot invisibility). To preserve observability
357/// across every HOT fallback path, [`Table::resolve_pk_with_hot_fallback`]
358/// inspects the candidate directly and tags the failure with one of these
359/// reasons before delegating to the PK equality scanner.
360///
361/// `PrimaryKeyMismatch` carries the mismatched bytes for diagnostics — the
362/// `materialized_pk` is the value read from the row's PK column and
363/// `requested` is the lookup key bytes (after HMAC tokenization). Neither is
364/// used as the authoritative result; the fallback scanner is the only
365/// source of the returned row set.
366#[derive(Debug, Clone, PartialEq)]
367pub enum HotCandidateInspection {
368    /// Mapped `RowId` materialized, is visible, live, TTL-valid, and carries
369    /// the requested PK. The branch may return this row without a fallback.
370    Hit(crate::memtable::Row),
371    /// Row does not exist at the mapped `RowId` — overlay and all runs
372    /// returned no version. The base row was removed entirely.
373    MissingRow,
374    /// Row exists but is stamped with a `(committed_epoch, commit_ts)` that
375    /// the calling `Snapshot` does not observe. Distinct from a tombstone:
376    /// the row may still be visible at a later snapshot.
377    Invisible,
378    /// The newest visible row at the mapped `RowId` is a tombstone
379    /// (`deleted = true`). A replacement row with the same PK may exist
380    /// elsewhere — the scanner is responsible for finding it.
381    Tombstone,
382    /// The newest visible row exists and is not deleted, but the row's TTL
383    /// metadata says the row expired at the calling wall clock. Distinct
384    /// from `Tombstone`: the row is still durable, just hidden by TTL.
385    TtlExpired,
386    /// Row materialized with valid visibility/TTL, but its materialized PK
387    /// does not match the requested PK bytes. The HOT map is stale and must
388    /// not return this row — only the scanner's verified result is returned.
389    PrimaryKeyMismatch {
390        /// PK bytes encoded from the materialized row's PK column.
391        materialized_pk: Vec<u8>,
392        /// PK bytes that the caller requested (post-HMAC tokenization).
393        requested: Vec<u8>,
394    },
395}
396
397impl QueryTrace {
398    /// Execute `f` with path tracing active on the current thread, returning
399    /// the result and the captured trace. Recording calls inside `f` (and
400    /// anything `f` calls on this thread) fill the returned trace.
401    ///
402    /// Nesting is supported: an inner `capture` pushes a fresh trace onto the
403    /// stack and gets its own result; the outer trace is unaffected by inner
404    /// recordings.
405    ///
406    /// When no capture is active (the normal hot path), [`QueryTrace::record`]
407    /// is a single TLS load + empty-check + return — zero allocation, zero
408    /// locking, no measurable cost.
409    pub fn capture<F, T>(f: F) -> (T, QueryTrace)
410    where
411        F: FnOnce() -> T,
412    {
413        Self::push_scope();
414        let result = f();
415        let trace = Self::pop_scope();
416        (result, trace)
417    }
418
419    /// Push a fresh trace onto the thread-local stack, starting a capture scope.
420    /// Pair with [`Self::pop_scope`] to retrieve the trace. This is the async-
421    /// compatible alternative to [`Self::capture`]: push before `await`, pop
422    /// after.
423    ///
424    /// **Thread affinity:** the trace is thread-local, so recordings must happen
425    /// on the same OS thread as the push/pop pair. This holds for synchronous
426    /// query paths (the common case) and for single-partition DataFusion scans
427    /// (physical planning + leaf execution run inline on the polling thread).
428    pub fn push_scope() {
429        STACK.with(|s| s.borrow_mut().push(QueryTrace::default()));
430    }
431
432    /// Pop the innermost trace from the thread-local stack, ending a capture
433    /// scope. Returns the captured trace. Panics if the stack is empty (unpaired
434    /// pop); see [`Self::push_scope`].
435    pub fn pop_scope() -> QueryTrace {
436        STACK.with(|s| s.borrow_mut().pop()).unwrap_or_default()
437    }
438
439    /// Whether path tracing is active on this thread (at least one
440    /// [`QueryTrace::capture`] scope is open).
441    #[inline]
442    pub fn capturing() -> bool {
443        STACK.with(|s| !s.borrow().is_empty())
444    }
445
446    /// Record into the innermost active trace via `f`. **No-op when not
447    /// capturing** — the hot path pays only the TLS load to check the stack.
448    ///
449    /// This is the primary recording primitive: call it at every decision point
450    /// (path selection, index rebuild, fast-path hit/miss). It is cheap enough
451    /// to call once per query entry point without measurable overhead.
452    #[inline]
453    pub fn record<F>(f: F)
454    where
455        F: FnOnce(&mut QueryTrace),
456    {
457        STACK.with(|s| {
458            if let Some(trace) = s.borrow_mut().last_mut() {
459                f(trace);
460            }
461        });
462    }
463
464    /// Returns `true` when this trace took a "good" (non-materializing) path:
465    /// a cursor, a pushdown, a shadow, or a count shortcut — and did **not**
466    /// rebuild indexes or materialize rows. Useful as a quick sanity check in
467    /// path-sensitive tests.
468    pub fn is_fast(&self) -> bool {
469        !matches!(self.scan_mode, ScanMode::Materialized | ScanMode::Unknown)
470            && self.index_rebuild != IndexRebuild::Rebuilt
471            && !self.row_materialized
472    }
473}
474
475impl fmt::Display for QueryTrace {
476    /// Compact one-line summary for benchmark output and ad-hoc inspection:
477    /// `native-pushdown pushed=2 survivors=12500 runs=1 idx=complete fast-rid`.
478    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
479        write!(f, "{}", self.scan_mode)?;
480        if self.run_count > 0 {
481            write!(f, " runs={}", self.run_count)?;
482        }
483        if self.conditions_pushed > 0 {
484            write!(f, " pushed={}", self.conditions_pushed)?;
485        }
486        if self.conditions_residual > 0 {
487            write!(f, " residual={}", self.conditions_residual)?;
488        }
489        if let Some(n) = self.survivor_count {
490            write!(f, " survivors={}", n)?;
491        }
492        let idx = match self.index_rebuild {
493            IndexRebuild::None => "",
494            IndexRebuild::AlreadyComplete => " idx=complete",
495            IndexRebuild::Rebuilt => " idx=REBUILT",
496        };
497        f.write_str(idx)?;
498        if self.result_cache_hit {
499            f.write_str(" cache=hit")?;
500        }
501        if self.learned_range_used {
502            f.write_str(" learned-range")?;
503        }
504        if self.fast_row_id_map {
505            f.write_str(" fast-rid")?;
506        }
507        if self.row_materialized {
508            f.write_str(" row-mat")?;
509        }
510        if self.overlay_rows > 0 {
511            write!(f, " overlay={}", self.overlay_rows)?;
512        }
513        if self.pages_decoded > 0 {
514            write!(f, " pages={}", self.pages_decoded)?;
515        }
516        if self.pages_skipped > 0 {
517            write!(f, " skipped={}", self.pages_skipped)?;
518        }
519        if self.directory_complete {
520            write!(f, " dir=complete candidates={}", self.directory_candidates)?;
521        }
522        if self.run_readers_opened > 0 {
523            write!(f, " run-readers={}", self.run_readers_opened)?;
524        }
525        if self.early_stop {
526            f.write_str(" early-stop")?;
527        }
528        if self.controlled_scan_versions_examined > 0 {
529            write!(
530                f,
531                " controlled=versions={} emitted={}",
532                self.controlled_scan_versions_examined, self.controlled_scan_rows_emitted
533            )?;
534        }
535        if self.hot_lookup_attempted {
536            if self.hot_lookup_hit {
537                f.write_str(" hot=hit")?;
538            } else {
539                write!(
540                    f,
541                    " hot=fallback reason={}",
542                    self.hot_fallback_reason.unwrap_or("unspecified")
543                )?;
544            }
545        }
546        if self.candidate_cap_hit {
547            write!(f, " cap-hit={}", self.final_hits)?;
548        }
549        if let Some(reason) = self.underfill_reason {
550            write!(f, " underfill={reason}")?;
551        }
552        Ok(())
553    }
554}
555
556// ---------------------------------------------------------------------------
557// Test assertion helpers — fluent methods for path-sensitive performance tests.
558// Available in integration tests and downstream test suites (not gated behind
559// #[cfg(test)] so they work from external test crates).
560// ---------------------------------------------------------------------------
561
562impl QueryTrace {
563    /// Assert the scan mode equals `expected`. Returns `&self` for chaining.
564    pub fn assert_mode(&self, expected: ScanMode) -> &Self {
565        assert_eq!(
566            self.scan_mode, expected,
567            "expected scan mode {expected:?} but got {:?} ({self})",
568            self.scan_mode
569        );
570        self
571    }
572
573    /// Assert no index rebuild happened during this query (Priority 10 guard).
574    pub fn assert_no_index_rebuild(&self) -> &Self {
575        assert_ne!(
576            self.index_rebuild,
577            IndexRebuild::Rebuilt,
578            "expected no index rebuild but indexes were rebuilt ({self})"
579        );
580        self
581    }
582
583    /// Assert the query did not materialize `Row { HashMap }` objects.
584    pub fn assert_not_materialized(&self) -> &Self {
585        assert!(
586            !self.row_materialized,
587            "expected columnar/cursor path but rows were materialized ({self})"
588        );
589        self
590    }
591
592    /// Assert the result cache returned a hit.
593    pub fn assert_cache_hit(&self) -> &Self {
594        assert!(
595            self.result_cache_hit,
596            "expected result cache hit but got miss ({self})"
597        );
598        self
599    }
600
601    /// Assert the fast clean-run row-id→position arithmetic was used.
602    pub fn assert_fast_row_id_map(&self) -> &Self {
603        assert!(
604            self.fast_row_id_map,
605            "expected fast row-id map but got fallback ({self})"
606        );
607        self
608    }
609}
610
611/// Inspect a HOT candidate at `rid` against the calling `snapshot` and the
612/// table's TTL policy. Returns the [`HotCandidateInspection`] diagnostic
613/// that the [`crate::engine::Table::resolve_pk_with_hot_fallback`] helper
614/// uses to choose between the fast-path hit and the per-reason fallback.
615///
616/// The classification preserves the **full reason** (HLC + epoch + TTL +
617/// materialized PK equality) — `Table::get` collapses several of these to
618/// `None` and is not sufficient on its own.
619///
620/// `now_nanos` is the wall-clock anchor for TTL evaluation; pass the same
621/// value the caller would use to materialize rows (`unix_nanos_now()` for the
622/// normal query path). `ttl_policy` is the table's [`TtlPolicy`] — when
623/// `None`, the candidate can never be classified as [`HotCandidateInspection::TtlExpired`].
624///
625/// `pk_encoded` is the encoded PK lookup bytes the caller would pass to
626/// `index_lookup_key` (post-HMAC tokenization). It is only compared against
627/// the materialized PK column when the row materializes successfully and is
628/// not tombstoned / expired / invisible.
629pub fn inspect_hot_candidate(
630    row: Option<&crate::memtable::Row>,
631    snapshot: crate::epoch::Snapshot,
632    ttl_policy: Option<crate::manifest::TtlPolicy>,
633    now_nanos: i64,
634    pk_column_id: u16,
635    pk_encoded: &[u8],
636    tokenize_pk: impl FnOnce(&crate::memtable::Row) -> Vec<u8>,
637) -> HotCandidateInspection {
638    let Some(row) = row else {
639        return HotCandidateInspection::MissingRow;
640    };
641    if !snapshot.observes_row(row.committed_epoch, row.commit_ts) {
642        return HotCandidateInspection::Invisible;
643    }
644    if row.deleted {
645        return HotCandidateInspection::Tombstone;
646    }
647    if let Some(policy) = ttl_policy {
648        if let Some(crate::Value::Int64(timestamp)) = row.columns.get(&policy.column_id) {
649            if timestamp.saturating_add(policy.duration_nanos as i64) <= now_nanos {
650                return HotCandidateInspection::TtlExpired;
651            }
652        }
653    }
654    let _ = pk_column_id; // pk_column_id documents the API; tokenize_pk encapsulates the encode.
655    let materialized = tokenize_pk(row);
656    if materialized != pk_encoded {
657        return HotCandidateInspection::PrimaryKeyMismatch {
658            materialized_pk: materialized,
659            requested: pk_encoded.to_vec(),
660        };
661    }
662    HotCandidateInspection::Hit(row.clone())
663}
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668
669    #[test]
670    fn capture_collects_records() {
671        // Recording outside a capture is a no-op (no panic).
672        QueryTrace::record(|t| {
673            t.run_count = 999;
674        });
675        assert!(!QueryTrace::capturing());
676
677        let (result, trace) = QueryTrace::capture(|| {
678            assert!(QueryTrace::capturing());
679            QueryTrace::record(|t| {
680                t.run_count = 3;
681                t.scan_mode = ScanMode::NativePushdown;
682            });
683            42
684        });
685        assert_eq!(result, 42);
686        assert_eq!(trace.run_count, 3);
687        assert_eq!(trace.scan_mode, ScanMode::NativePushdown);
688        assert!(!QueryTrace::capturing());
689    }
690
691    #[test]
692    fn nested_captures_are_independent() {
693        let (outer, outer_trace) = QueryTrace::capture(|| {
694            QueryTrace::record(|t| t.run_count = 1);
695            let (inner, inner_trace) = QueryTrace::capture(|| {
696                QueryTrace::record(|t| t.run_count = 99);
697                "inner"
698            });
699            assert_eq!(inner, "inner");
700            // The inner capture's records must NOT bleed into the outer trace.
701            assert_eq!(inner_trace.run_count, 99);
702            // But subsequent outer records still hit the outer trace.
703            QueryTrace::record(|t| t.conditions_pushed = 2);
704            "outer"
705        });
706        assert_eq!(outer, "outer");
707        assert_eq!(outer_trace.run_count, 1);
708        assert_eq!(outer_trace.conditions_pushed, 2);
709    }
710
711    #[test]
712    fn display_summary_is_compact() {
713        let t = QueryTrace {
714            scan_mode: ScanMode::NativePushdown,
715            run_count: 1,
716            conditions_pushed: 2,
717            survivor_count: Some(12500),
718            index_rebuild: IndexRebuild::AlreadyComplete,
719            fast_row_id_map: true,
720            ..Default::default()
721        };
722        let s = format!("{t}");
723        assert!(s.contains("native-pushdown"));
724        assert!(s.contains("runs=1"));
725        assert!(s.contains("pushed=2"));
726        assert!(s.contains("survivors=12500"));
727        assert!(s.contains("idx=complete"));
728        assert!(s.contains("fast-rid"));
729    }
730
731    #[test]
732    fn is_fast_distinguishes_paths() {
733        let good = QueryTrace {
734            scan_mode: ScanMode::NativePageCursor,
735            index_rebuild: IndexRebuild::AlreadyComplete,
736            ..Default::default()
737        };
738        assert!(good.is_fast());
739
740        let mut bad = good.clone();
741        bad.index_rebuild = IndexRebuild::Rebuilt;
742        assert!(!bad.is_fast());
743
744        let mut mat = good.clone();
745        mat.scan_mode = ScanMode::Materialized;
746        assert!(!mat.is_fast());
747    }
748
749    #[test]
750    fn assertion_helpers_chain() {
751        let t = QueryTrace {
752            scan_mode: ScanMode::ArrowShadow,
753            index_rebuild: IndexRebuild::AlreadyComplete,
754            ..Default::default()
755        };
756        t.assert_mode(ScanMode::ArrowShadow)
757            .assert_no_index_rebuild()
758            .assert_not_materialized();
759    }
760}