Skip to main content

reddb_server/runtime/
statement_frame.rs

1use std::cell::RefCell;
2use std::collections::HashSet;
3use std::rc::Rc;
4use std::sync::Arc;
5
6use super::execution_context::capture_current_snapshot;
7use super::impl_core::{
8    collections_referenced, current_auth_identity, current_connection_id, current_tenant,
9    has_with_prefix, intent_lock_modes_for, peek_top_level_as_of_with_table,
10    query_has_volatile_builtin, query_is_ask_statement, ConfigSnapshotGuard, CurrentSnapshotGuard,
11    KvStoreGuard, SecretStoreGuard, SnapshotContext, TxLocalTenantGuard,
12};
13use super::{RedDBRuntime, RuntimeQueryResult, RuntimeResultCacheEntry};
14use crate::api::{RedDBError, RedDBResult};
15use crate::auth::Role;
16use crate::storage::transaction::snapshot::{Snapshot, SnapshotRetentionGuard, Xid};
17use reddb_rql::ast::QueryExpr;
18use reddb_rql::modes::{detect_mode, parse_multi, QueryMode};
19
20/// Coarse privilege classification for a statement, computed once at
21/// frame-build time from the SQL text. Mirrors the three-role auth
22/// model (`Role::Read < Role::Write < Role::Admin`) so the frame can
23/// answer "can this identity run this statement?" without re-walking
24/// the parsed `QueryExpr` at every call site.
25///
26/// `None` means the statement does not touch the privilege gate at
27/// all (transaction control, SET, SHOW). Such statements must remain
28/// runnable under any authenticated identity.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub(crate) enum Privilege {
31    /// Read-only data access (SELECT, EXPLAIN, SHOW). Satisfied by
32    /// any role from `Role::Read` upward.
33    Read,
34    /// Mutation of user data or schema author DDL (INSERT, UPDATE,
35    /// DELETE, CREATE/ALTER/DROP TABLE, CREATE MIGRATION). Requires
36    /// at least `Role::Write`.
37    Write,
38    /// Authority statements — GRANT, REVOKE, ALTER USER, APPLY /
39    /// ROLLBACK MIGRATION, IAM policy mutation. Requires `Role::Admin`.
40    Admin,
41    /// Statement does not consult the privilege gate (BEGIN, COMMIT,
42    /// ROLLBACK, SET, SHOW with no data exposure). Always permitted
43    /// for any authenticated identity.
44    None,
45}
46
47impl Privilege {
48    /// `true` iff `role` is sufficient to execute a statement carrying
49    /// this required privilege. Encodes the standard `Read ⊆ Write ⊆
50    /// Admin` containment used by the auth fallback path.
51    pub(crate) fn is_satisfied_by(self, role: Role) -> bool {
52        match self {
53            Self::None => true,
54            Self::Read => role.can_read(),
55            Self::Write => role.can_write(),
56            Self::Admin => role.can_admin(),
57        }
58    }
59}
60
61/// Coarse lock intent for a statement, computed once at frame-build
62/// time. Maps onto the storage-layer's `LockMode` matrix downstream
63/// but stays decoupled here so the runtime can answer "does this
64/// statement need the lock manager at all?" without a `use storage::`
65/// at every call site.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub(crate) enum LockIntent {
68    /// No collection-level lock needed (transaction control, SET,
69    /// SHOW, EXPLAIN). The lock-acquisition path can short-circuit.
70    None,
71    /// Reader-style intent: SELECT, joins, graph / queue / search
72    /// reads. Maps to `(IS, IS)` at the storage layer.
73    Shared,
74    /// Writer- or DDL-style intent: INSERT/UPDATE/DELETE (`(IX, IX)`)
75    /// and CREATE/ALTER/DROP (`(IX, X)`). Both are surfaced as
76    /// `Exclusive` at this granularity — call sites that need the
77    /// finer distinction still consult `intent_lock_modes_for`.
78    Exclusive,
79}
80
81/// Small, stable Interface that *represents* a read statement's
82/// execution context. Every read caller that needs to know "under
83/// what scope / identity / snapshot am I running, and is there an
84/// AS OF floor in effect?" consults this trait — never the
85/// underlying thread-locals or runtime fields directly.
86///
87/// The deletion test: removing this trait would force the four
88/// concerns it exposes back into ad-hoc lookups at every read
89/// callsite (`current_tenant()`, `current_auth_identity()`,
90/// `capture_current_snapshot()`, AS OF re-parsing). The trait
91/// concentrates them in one place so future changes (per-statement
92/// logging, audit, scope policy) have a single seam to extend.
93pub(crate) trait ReadFrame {
94    /// Effective tenant scope for the statement after WITHIN /
95    /// SET LOCAL TENANT / SET TENANT resolution. `None` means
96    /// "no tenant bound" (RLS deny-default applies).
97    fn effective_scope(&self) -> Option<&str>;
98
99    /// Authenticated identity observed at frame-build time, if any.
100    /// Returns `(username, role)` so callers can render audit lines
101    /// or feed RLS policy lookups without re-reading thread-locals.
102    fn identity(&self) -> Option<(&str, Role)>;
103
104    /// MVCC snapshot the statement reads against. For autocommit
105    /// this is a fresh snapshot; inside an active transaction it
106    /// is the txn's snapshot; under AS OF it is the resolved
107    /// historical xid.
108    fn snapshot(&self) -> &Snapshot;
109
110    /// Complete visibility context, including the reader's savepoint writes.
111    fn snapshot_context(&self, runtime: &RedDBRuntime) -> SnapshotContext {
112        SnapshotContext {
113            snapshot: self.snapshot().clone(),
114            manager: runtime.inner.transaction_state.snapshot_manager(),
115            own_xids: HashSet::new(),
116            requires_index_fallback: true,
117            serializable_reader: None,
118        }
119    }
120
121    /// AS OF xid floor when AS OF was applied for this statement,
122    /// `None` for live reads. Useful for downstream callers that
123    /// want to gate behaviour on historical-read mode without
124    /// re-parsing the query.
125    fn as_of_floor(&self) -> Option<Xid>;
126
127    /// Stable result-cache key for the statement (already mixes
128    /// effective tenant + identity).
129    fn cache_key(&self) -> &str;
130
131    /// Whether the statement is safe to serve from / populate the
132    /// result cache. Combines two underlying signals:
133    ///
134    ///   * the query does not call a volatile builtin (e.g. `NOW()`,
135    ///     `RANDOM()`, `UUID()`), which would change between calls,
136    ///   * the connection is not inside an active transaction with
137    ///     uncommitted writes that other readers shouldn't observe.
138    ///
139    /// SELECT cache callsites (read + write) consult this method
140    /// instead of re-deriving safety from globals or poking the
141    /// frame's private fields. Removing it would force every cache
142    /// callsite to re-run `query_has_volatile_builtin` plus
143    /// `result_cache_safe(conn_id)` inline.
144    fn should_cache_result(&self) -> bool;
145
146    /// Coarse privilege class the statement requires, computed once
147    /// at frame-build time from the SQL prefix. Read/write dispatch
148    /// sites consult this instead of re-classifying the parsed
149    /// `QueryExpr` inline at every callsite.
150    ///
151    /// Removing this method would force every privilege gate to
152    /// recompute the (action, resource) classification from the
153    /// parsed expression and re-check the role hierarchy inline.
154    fn required_privilege(&self) -> Privilege;
155
156    /// Coarse collection-level lock intent the statement implies.
157    /// `None` lets the lock-acquisition path short-circuit without
158    /// touching the lock manager.
159    ///
160    /// Removing this method would force the lock-acquisition path
161    /// to always invoke `intent_lock_modes_for` (which itself walks
162    /// the parsed expression) even for transaction-control / SET /
163    /// SHOW statements that need no collection lock at all.
164    fn lock_intent(&self) -> LockIntent;
165
166    /// Set of collection ids the calling identity is allowed to
167    /// observe under the active `(tenant, role)` scope. Computed once
168    /// at frame-build time via the `AuthStore` visible-collections
169    /// cache (see `auth::scope_cache`) and used by `AuthorizedSearch`
170    /// to pre-filter SEARCH SIMILAR / SEARCH CONTEXT candidate sets
171    /// before any similarity score is computed (issue #119).
172    ///
173    /// `None` means the frame was built without an auth store wired —
174    /// embedded / single-tenant tests run that way. AI search call
175    /// sites refuse to proceed with `None`, which is the deny-default
176    /// the issue requires; pure SELECT paths fall back to the existing
177    /// per-row RLS gate.
178    fn visible_collections(&self) -> Option<&std::collections::HashSet<String>>;
179}
180
181/// Cheap first-word classification of a SQL statement, used at
182/// frame-build time to derive `Privilege` + `LockIntent` without
183/// re-parsing the query. Matches the keywords that the legacy
184/// inline checks in `RedDBRuntime::check_query_privilege` and
185/// `intent_lock_modes_for` already key on.
186/// Whether [`statement_kind`] reads `query` as a mutating statement.
187/// The Request seam uses it to keep its pre-write commit-policy check off
188/// read paths.
189pub(crate) fn statement_is_write(query: &str) -> bool {
190    statement_kind(query) == "write"
191}
192
193fn statement_kind(query: &str) -> &'static str {
194    let trimmed = query.trim_start();
195    // Skip a leading line / block comment so the classifier doesn't
196    // misread `/* ... */ SELECT ...` as an unknown statement.
197    let trimmed = if let Some(rest) = trimmed.strip_prefix("--") {
198        rest.split_once('\n')
199            .map(|(_, r)| r)
200            .unwrap_or("")
201            .trim_start()
202    } else {
203        trimmed
204    };
205    let mut tokens = trimmed.split(|c: char| c.is_whitespace() || c == '(' || c == ';');
206    let first = tokens.next().unwrap_or("");
207    let second = tokens.next().unwrap_or("");
208    if first.eq_ignore_ascii_case("KV") {
209        if second.eq_ignore_ascii_case("GET")
210            || second.eq_ignore_ascii_case("LIST")
211            || second.eq_ignore_ascii_case("WATCH")
212        {
213            return "read";
214        }
215        return "write";
216    }
217    if first.eq_ignore_ascii_case("VAULT") {
218        if second.eq_ignore_ascii_case("GET")
219            || second.eq_ignore_ascii_case("REVEAL")
220            || second.eq_ignore_ascii_case("UNSEAL")
221            || second.eq_ignore_ascii_case("LIST")
222            || second.eq_ignore_ascii_case("WATCH")
223            || second.eq_ignore_ascii_case("HISTORY")
224        {
225            return "read";
226        }
227        return "write";
228    }
229    // ASCII-uppercase compare without allocating: SQL keywords are ASCII.
230    let mut buf = [0u8; 16];
231    let bytes = first.as_bytes();
232    let n = bytes.len().min(buf.len());
233    for i in 0..n {
234        buf[i] = bytes[i].to_ascii_uppercase();
235    }
236    match &buf[..n] {
237        b"SELECT" | b"WITH" | b"SHOW" | b"EXPLAIN" | b"DESCRIBE" | b"DESC" | b"RANK"
238        | b"APPROX" | b"APPROXIMATE" | b"ZRANK" | b"ZRANGE" | b"LIST" | b"WATCH" | b"GET"
239        | b"HISTORY" | b"REVEAL" | b"UNSEAL" => "read",
240        b"INSERT" | b"UPDATE" | b"DELETE" | b"UPSERT" | b"MERGE" | b"COPY" | b"TRUNCATE" => "write",
241        b"CREATE" | b"ALTER" | b"DROP" | b"PROMOTE" | b"REINDEX" | b"VACUUM" | b"ANALYZE" => "ddl",
242        b"GRANT" | b"REVOKE" => "admin",
243        b"BEGIN" | b"START" | b"COMMIT" | b"ROLLBACK" | b"SAVEPOINT" | b"RELEASE" | b"END"
244        | b"SET" | b"RESET" | b"PREPARE" | b"EXECUTE" | b"DEALLOCATE" | b"USE" => "control",
245        b"PUT" | b"INCR" | b"DECR" | b"ADD" | b"ROTATE" | b"PURGE" | b"INVALIDATE" => "write",
246        _ => "unknown",
247    }
248}
249
250/// Refuse a pre-parsed expression that carries a top-level `AS OF`.
251///
252/// `AS OF` is resolved from the statement text (`AsOfSpec` -> commit /
253/// branch / tag / timestamp -> xid) and `TableQuery::as_of` is consumed
254/// nowhere in the runtime or scan path. An expression-only frame
255/// therefore cannot honour it, and installing a live snapshot for a
256/// historical read would answer with current rows presented as history.
257/// Fail loudly instead; callers that have the text (`execute_query`,
258/// `execute_prepared_query`, `execute_query_with_params`) resolve it
259/// correctly. Mirrors `peek_top_level_as_of_with_table`, which likewise
260/// only recognises a top-level `AS OF`.
261fn reject_unresolvable_as_of(expr: &QueryExpr) -> RedDBResult<()> {
262    let QueryExpr::Table(table) = expr else {
263        return Ok(());
264    };
265    if table.as_of.is_none() {
266        return Ok(());
267    }
268    Err(RedDBError::InvalidOperation(
269        "AS OF cannot be resolved from a pre-parsed expression — \
270         execute the statement text (execute_query / execute_prepared_query / \
271         execute_query_with_params) so the historical snapshot is resolved"
272            .to_string(),
273    ))
274}
275
276fn classify_privilege(query: &str) -> Privilege {
277    match statement_kind(query) {
278        "read" => Privilege::Read,
279        "write" => Privilege::Write,
280        // DDL is gated at `Role::Write` in the legacy fallback (see
281        // `RedDBRuntime::check_query_privilege` for CreateTable et al.),
282        // so it classifies as Write here. APPLY / ROLLBACK MIGRATION and
283        // GRANT / REVOKE upgrade to Admin via finer checks at the call
284        // site — the frame surfaces only the coarse class.
285        "ddl" => Privilege::Write,
286        "admin" => Privilege::Admin,
287        _ => Privilege::None,
288    }
289}
290
291fn classify_lock_intent(query: &str) -> LockIntent {
292    match statement_kind(query) {
293        "read" => LockIntent::Shared,
294        "write" | "ddl" => LockIntent::Exclusive,
295        _ => LockIntent::None,
296    }
297}
298
299pub(super) struct StatementExecutionFrame {
300    tx_local_tenant: Option<Option<String>>,
301    snapshot: Snapshot,
302    snapshot_retention: RefCell<Option<SnapshotRetentionGuard>>,
303    own_xids: HashSet<Xid>,
304    serializable_reader: Option<Xid>,
305    cache_key: String,
306    is_volatile_query: bool,
307    cache_safe: bool,
308    /// Effective tenant captured at frame-build time after WITHIN /
309    /// SET LOCAL TENANT / SET TENANT resolution. Stored on the frame
310    /// so the `ReadFrame` Interface can return a borrow without
311    /// re-touching the thread-local stack.
312    effective_scope: Option<String>,
313    /// Auth identity captured at frame-build time. `None` for
314    /// embedded / anonymous callers.
315    identity: Option<(String, Role)>,
316    /// `Some(xid)` when AS OF resolved to a historical xid; `None`
317    /// for live reads.
318    as_of_floor: Option<Xid>,
319    /// True when the statement snapshot can require tuple versions that
320    /// current secondary indexes no longer contain.
321    requires_index_fallback: bool,
322    /// Privilege class required by the statement, derived from the
323    /// SQL text at frame-build time. Read/write dispatch sites
324    /// consult this instead of re-classifying the parsed expression.
325    required_privilege: Privilege,
326    /// Collection-level lock intent the statement implies. The
327    /// lock-acquisition path short-circuits when this is `None`.
328    lock_intent: LockIntent,
329    /// Set of collection ids the active `(tenant, role)` scope is
330    /// allowed to observe. Computed at frame-build time via the
331    /// `AuthStore` visibility cache and consumed by `AuthorizedSearch`
332    /// to gate SEARCH SIMILAR / SEARCH CONTEXT candidate sets before
333    /// scoring (issue #119). `None` when no auth store is wired
334    /// (embedded test mode) — AI search refuses on `None`.
335    visible_collections: Option<HashSet<String>>,
336    /// Per-owner buffer arena for query-result row chunks (#885). Owned
337    /// by the frame because the frame already owns the query lifecycle;
338    /// lent to the row-streaming path (`execute_runtime_table_query_in`)
339    /// so chunk buffers are reused across the statement's chunk-fetches
340    /// instead of allocated fresh per chunk. Reclaimed when the frame
341    /// drops at statement end — no `thread_local!` scratch, which would
342    /// be unsound under tokio's work-stealing runtime.
343    row_arena: Rc<RefCell<super::query_exec::RowBufferArena>>,
344}
345
346pub(super) struct StatementFrameGuards {
347    _tx_local_guard: TxLocalTenantGuard,
348    _config_snapshot_guard: ConfigSnapshotGuard,
349    _secret_store_guard: SecretStoreGuard,
350    _kv_store_guard: KvStoreGuard,
351    _snapshot_guard: CurrentSnapshotGuard,
352}
353
354pub(super) struct PreparedStatement {
355    pub(super) expr: QueryExpr,
356    pub(super) mode: QueryMode,
357}
358
359/// What a `StatementExecutionFrame` is built from.
360///
361/// The frame derives the statement snapshot (including any `AS OF`
362/// floor), the coarse privilege class, the lock intent, the volatility
363/// flag, and the result-cache key from its input. Feeding it anything
364/// other than the statement itself makes every one of those describe a
365/// different statement than the one that runs, so the input is named
366/// explicitly here rather than being "some string".
367#[derive(Clone, Copy)]
368pub(super) enum StatementIdentity<'a> {
369    /// The statement's SQL text. All five derivations are exact.
370    Text(&'a str),
371    /// A pre-parsed expression with no accompanying text — the nested
372    /// in-statement callers (CTE prelude, materialized-view refresh,
373    /// inline graph TVF) hold an expression the outer statement already
374    /// parsed.
375    ///
376    /// Text-derived facts are not guessed from a kind label: there is no
377    /// stable cache key (a label would collide across every statement of
378    /// the same kind), and the coarse privilege / lock short-circuits are
379    /// left at `None` so the deep grant engine stays the gate. `AS OF` is
380    /// rejected up front by `build` because it cannot be resolved without
381    /// the text.
382    Expr(&'a QueryExpr),
383}
384
385impl<'a> StatementIdentity<'a> {
386    /// SQL text when the frame was built from text, `""` otherwise. Used
387    /// for the text-derived classifications, all of which degrade to the
388    /// deliberately inert `unknown` / `None` classes on the expr path.
389    fn text(&self) -> &'a str {
390        match self {
391            Self::Text(query) => query,
392            Self::Expr(_) => "",
393        }
394    }
395}
396
397impl StatementExecutionFrame {
398    pub(super) fn build(
399        runtime: &RedDBRuntime,
400        identity_source: StatementIdentity<'_>,
401    ) -> RedDBResult<Self> {
402        let query = identity_source.text();
403        if let StatementIdentity::Expr(expr) = identity_source {
404            reject_unresolvable_as_of(expr)?;
405        }
406        let conn_id = current_connection_id();
407        let tx_local_tenant = runtime.inner.transaction_state.local_tenant(conn_id);
408        let tx_context = runtime.inner.transaction_state.context(conn_id);
409        let own_xids = own_transaction_xids(tx_context.as_ref());
410        let serializable_reader = tx_context
411            .as_ref()
412            .filter(|ctx| {
413                ctx.isolation == crate::storage::transaction::IsolationLevel::Serializable
414            })
415            .map(|ctx| ctx.xid);
416        let (snapshot, as_of_floor, snapshot_retention) =
417            runtime.statement_snapshot(query, identity_source)?;
418        let requires_index_fallback = as_of_floor.is_some() || tx_context.is_some();
419        let (cache_key, is_volatile_query) = match identity_source {
420            StatementIdentity::Text(_) => (
421                result_cache_key(query),
422                query_has_volatile_builtin(query) || query_is_ask_statement(query),
423            ),
424            // No text means no stable key, so the frame opts out of the
425            // result cache on both sides: an empty key is never read from
426            // or written to, and `is_volatile_query` is set so
427            // `should_cache_result` refuses regardless of the key.
428            StatementIdentity::Expr(_) => (String::new(), true),
429        };
430        let cache_safe = runtime.result_cache_safe(conn_id);
431        // Capture identity + effective scope under the same
432        // thread-local view that the cache key was built from, so
433        // the Interface and the cache key agree on what "this
434        // statement" means.
435        let effective_scope = current_tenant();
436        let identity = current_auth_identity();
437
438        // Coarse classification of the statement, computed once from
439        // the SQL prefix so downstream callers don't re-derive it
440        // from the parsed `QueryExpr` at every privilege / lock site.
441        // On the expr path `query` is empty, so both classify as the
442        // inert `None` class: the coarse gate can only deny, never
443        // allow (see `check_query_privilege`), so declining to
444        // short-circuit leaves the deep grant engine as the gate.
445        let required_privilege = classify_privilege(query);
446        let lock_intent = classify_lock_intent(query);
447
448        // Issue #119: resolve the visible-collections set for the
449        // active (tenant, role) scope. Only meaningful when an auth
450        // store is wired *and* an identity was captured — embedded
451        // anonymous callers fall back to `None`, and AI search call
452        // sites refuse on `None`.
453        let visible_collections = match (runtime.inner.auth_store.read().clone(), identity.as_ref())
454        {
455            (Some(store), Some((principal, role))) => {
456                let collections = runtime.inner.db.store().list_collections();
457                Some(store.visible_collections_for_scope(
458                    effective_scope.as_deref(),
459                    *role,
460                    principal,
461                    &collections,
462                ))
463            }
464            _ => None,
465        };
466
467        Ok(Self {
468            tx_local_tenant,
469            snapshot,
470            snapshot_retention: RefCell::new(Some(snapshot_retention)),
471            own_xids,
472            serializable_reader,
473            cache_key,
474            is_volatile_query,
475            cache_safe,
476            effective_scope,
477            identity,
478            as_of_floor,
479            requires_index_fallback,
480            required_privilege,
481            lock_intent,
482            visible_collections,
483            row_arena: Rc::new(RefCell::new(super::query_exec::RowBufferArena::new())),
484        })
485    }
486
487    /// Lend the frame's per-owner row-buffer arena (#885) to the
488    /// row-streaming path. Returns a cloned `Rc` handle; the frame remains
489    /// the owner and the arena is reclaimed when the frame drops at
490    /// statement end.
491    pub(super) fn row_arena(&self) -> Rc<RefCell<super::query_exec::RowBufferArena>> {
492        Rc::clone(&self.row_arena)
493    }
494
495    pub(super) fn install(&self, runtime: &RedDBRuntime) -> StatementFrameGuards {
496        StatementFrameGuards {
497            _tx_local_guard: TxLocalTenantGuard::install(self.tx_local_tenant.clone()),
498            _config_snapshot_guard: ConfigSnapshotGuard::install(
499                Arc::clone(&runtime.inner.db),
500                runtime.inner.auth_store.read().clone(),
501            ),
502            _secret_store_guard: SecretStoreGuard::install(runtime),
503            _kv_store_guard: KvStoreGuard::install(runtime.inner.auth_store.read().clone()),
504            _snapshot_guard: CurrentSnapshotGuard::install(SnapshotContext {
505                snapshot: self.snapshot.clone(),
506                manager: runtime.inner.transaction_state.snapshot_manager(),
507                own_xids: self.own_xids.clone(),
508                requires_index_fallback: self.requires_index_fallback,
509                serializable_reader: self.serializable_reader,
510            }),
511        }
512    }
513
514    pub(super) fn cache_key(&self) -> &str {
515        &self.cache_key
516    }
517
518    pub(super) fn can_read_result_cache(&self) -> bool {
519        // Delegates to the `ReadFrame` Interface so the volatile +
520        // active-tx safety decision lives in exactly one place.
521        <Self as ReadFrame>::should_cache_result(self)
522    }
523
524    pub(super) fn should_write_result_cache(&self, result: &RuntimeQueryResult) -> bool {
525        // Cache-safety (volatile builtin, active-tx writes) comes from
526        // the Interface; the rest are write-side payload heuristics
527        // (statement shape, result size) that aren't part of the
528        // safety contract.
529        <Self as ReadFrame>::should_cache_result(self)
530            && result.statement_type == "select"
531            && result.engine != "vault"
532            && result.engine != "runtime-rank"
533            // `QUEUE READ` is a stateful read: a delayed message
534            // (issue #722) becomes deliverable over time without a
535            // producer push to invalidate the cache, so a cached empty
536            // result would hide it. Skip caching entirely.
537            && result.statement != "queue_group_read"
538            // SCRUB is a stateful read too: the background form advances
539            // a pacing cursor per tick (a cached tick would replay the
540            // summary and never advance), and even a full pass must
541            // re-read the file to see new corruption. Never cache.
542            && result.engine != "runtime-scrub"
543            && result.result.pre_serialized_json.is_none()
544            // Graph-analytics TVF output (issue #802) is deterministic and
545            // expensive to recompute, so it is cached at any row count. The
546            // ≤5-row heuristic only bounds payload size for ordinary SELECTs.
547            && (is_graph_tvf_engine(result.engine) || result.result.records.len() <= 5)
548    }
549
550    pub(super) fn read_result_cache(&self, runtime: &RedDBRuntime) -> Option<RuntimeQueryResult> {
551        if runtime.resource_limits().read_execution_limits_enabled() {
552            return None;
553        }
554        if self.can_read_result_cache() {
555            runtime
556                .get_result_cache_entry(self.cache_key())
557                .map(|mut result| {
558                    if let Some(vector) = result.result.stats.vector.as_mut() {
559                        vector.cache_hit = true;
560                    }
561                    result
562                })
563        } else {
564            None
565        }
566    }
567
568    pub(super) fn write_result_cache(
569        &self,
570        runtime: &RedDBRuntime,
571        result: &RuntimeQueryResult,
572        scopes: HashSet<String>,
573    ) {
574        if self.should_write_result_cache(result) {
575            runtime.put_result_cache_entry(
576                self.cache_key(),
577                RuntimeResultCacheEntry {
578                    result: result.clone(),
579                    cached_at: std::time::Instant::now(),
580                    scopes,
581                },
582            );
583        }
584    }
585
586    pub(super) fn prepare_cte(&self, query: &str) -> RedDBResult<Option<QueryExpr>> {
587        // Detected via cheap prefix check so non-CTE queries skip the
588        // full parse here. CTE-bearing queries bypass the plan cache
589        // and result cache (rare workload — perf optimization is a
590        // follow-up). Inlining substitutes every CTE reference with
591        // its body as a subquery in FROM, after which the existing
592        // subquery-in-FROM machinery handles execution. Recursive
593        // CTEs are rejected explicitly until fixpoint execution wires
594        // through the runtime.
595        if !has_with_prefix(query) {
596            return Ok(None);
597        }
598        let parsed =
599            reddb_rql::parser::parse(query).map_err(|err| RedDBError::Query(err.to_string()))?;
600        if parsed.with_clause.is_some() {
601            let rewritten = crate::storage::query::executors::inline_ctes(parsed)
602                .map_err(|err| RedDBError::Query(err.to_string()))?;
603            return Ok(Some(rewritten));
604        }
605        // No WITH after parse (the prefix matched something else like
606        // `WITHIN` that already routed elsewhere) — fall through to
607        // the normal path with the original query.
608        Ok(None)
609    }
610
611    pub(super) fn prepare_statement(
612        &self,
613        runtime: &RedDBRuntime,
614        query: &str,
615    ) -> RedDBResult<PreparedStatement> {
616        let mode = detect_mode(query);
617        if matches!(mode, QueryMode::Unknown) {
618            return Err(RedDBError::Query("unable to detect query mode".to_string()));
619        }
620
621        // ── Plan cache: reuse only exact-query ASTs ──
622        //
623        // DML statements (INSERT/UPDATE/DELETE) almost always have unique literal
624        // values, so caching them burns CPU on eviction bookkeeping (Vec::remove(0)
625        // shifts the entire LRU list) with zero hit rate. Skip the cache entirely
626        // Plan cache applies to statements whose shape can be
627        // normalised + rebound (`UPDATE t SET x=? WHERE _entity_id=?`
628        // reuses the same plan across thousands of varying literals).
629        // INSERT is still bypassed — its shape changes per column set
630        // and bulk paths don't go through here anyway.
631        let first_word = query
632            .trim()
633            .split_ascii_whitespace()
634            .next()
635            .unwrap_or("")
636            .to_ascii_uppercase();
637        let is_insert = first_word == "INSERT";
638        // #1370 — volatile queries ($config / $secret resolve mutable runtime
639        // state at execution time) must bypass the plan cache too. A cached
640        // optimized plan drops the live `$config` resolution, so a later
641        // `SET CONFIG` would be ignored and the query would serve a stale value.
642        // Re-parse fresh every time so the resolver runs against current state.
643        let bypass_plan_cache = is_insert || self.is_volatile_query;
644
645        // Fused normalize+extract: one byte-scan produces both the
646        // cache_key AND the literal bindings. Saves a second Lexer
647        // pass over the query text on every cache hit — dominant
648        // cost on tight UPDATE loops that hit the same shape
649        // thousands of times with varying literals.
650        let (cache_key, prescan_binds) = if bypass_plan_cache {
651            (String::new(), Vec::new())
652        } else {
653            crate::storage::query::planner::cache_key::normalize_and_extract(query)
654        };
655
656        let expr = if bypass_plan_cache {
657            // Bypass plan cache for INSERT — shape varies per query.
658            parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?
659        } else {
660            // ── Hot path: read lock only (no writer serialization on cache hits) ──
661            //
662            // peek() is a non-mutating probe: no LRU promotion, no touch().
663            // This lets concurrent readers proceed without blocking each other.
664            // On hit we bind literals if needed and return immediately.
665            // Only on miss do we drop to a write lock to parse + insert.
666            let hit = {
667                let plan_cache = runtime.inner.query_cache.read();
668                plan_cache.peek(&cache_key).map(|cached| {
669                    let parameter_count = cached.parameter_count;
670                    let optimized = cached.plan.optimized.clone();
671                    let exact_query = cached.exact_query.clone();
672                    (parameter_count, optimized, exact_query)
673                })
674            };
675
676            if let Some((parameter_count, optimized, exact_query)) = hit {
677                if parameter_count > 0 {
678                    // Shape hit: use the binds extracted during normalise.
679                    let shape_binds = prescan_binds.clone();
680                    if let Some(bound) =
681                        crate::storage::query::planner::shape::bind_parameterized_query(
682                            &optimized,
683                            &shape_binds,
684                            parameter_count,
685                        )
686                    {
687                        bound
688                    } else {
689                        // A failed bind leaves placeholders in the cached AST,
690                        // even for the same exact SQL. Never execute it unbound.
691                        parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?
692                    }
693                } else {
694                    // No parameters means either there truly are no literals,
695                    // or this statement type does not participate in shape
696                    // parameterization (for example graph/queue commands).
697                    // Reusing a normalized-cache hit across a different exact
698                    // query can therefore leak stale literals into execution.
699                    if exact_query.as_deref() == Some(query) {
700                        optimized
701                    } else {
702                        parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?
703                    }
704                }
705            } else {
706                // Cache miss — parse, parameterize, store.
707                let parsed =
708                    parse_multi(query).map_err(|err| RedDBError::Query(err.to_string()))?;
709                let (cached_expr, parameter_count) = if let Some(prepared) =
710                    crate::storage::query::planner::shape::parameterize_query_expr(&parsed)
711                {
712                    (prepared.shape, prepared.parameter_count)
713                } else {
714                    (parsed.clone(), 0)
715                };
716                {
717                    let mut pc = runtime.inner.query_cache.write();
718                    let plan = crate::storage::query::planner::QueryPlan::new(
719                        parsed.clone(),
720                        cached_expr,
721                        Default::default(),
722                    );
723                    pc.insert(
724                        cache_key.clone(),
725                        crate::storage::query::planner::CachedPlan::new(plan)
726                            .with_shape_key(cache_key.clone())
727                            .with_exact_query(query.to_string())
728                            .with_parameter_count(parameter_count),
729                    );
730                }
731                parsed
732            }
733        };
734
735        // Phase 5 PG parity: substitute any registered view name that
736        // appears in the expression with its stored body. Runs after
737        // parse and before dispatch so the SQL entrypoint gets the
738        // same view resolution `execute_query_expr` already does.
739        let expr = runtime.rewrite_view_refs(expr);
740
741        Ok(PreparedStatement { expr, mode })
742    }
743
744    pub(super) fn check_query_privilege(
745        &self,
746        runtime: &RedDBRuntime,
747        expr: &QueryExpr,
748    ) -> RedDBResult<()> {
749        // Frame-level coarse gate. We consult `required_privilege()`
750        // (computed once at frame-build) against the captured identity
751        // before the deep grant engine walks the parsed expression.
752        // The coarse gate cannot ALLOW anything the grant engine would
753        // deny — it only short-circuits the obvious "Role::Read tries
754        // INSERT" case so a downstream caller never has to redo this
755        // check inline. `Privilege::None` (transaction control / SET /
756        // SHOW) flows through unchanged; the grant engine treats those
757        // as bypass too.
758        if let Some((username, role)) = <Self as ReadFrame>::identity(self) {
759            let needed = <Self as ReadFrame>::required_privilege(self);
760            if !needed.is_satisfied_by(role) {
761                // Issue #205 — when the deep grant engine *also*
762                // denies, we treat this as an ordinary permission
763                // failure. But when an Admin-only statement reaches
764                // this gate without an auth_store wired (so the deep
765                // engine can't double-check), the coarse rejection is
766                // the only line of defence — emit an OperatorEvent so
767                // the operator notices an Admin-class statement was
768                // attempted with insufficient role.
769                if matches!(needed, Privilege::Admin) && runtime.inner.auth_store.read().is_none() {
770                    crate::telemetry::operator_event::OperatorEvent::AuthBypass {
771                        principal: username.to_string(),
772                        resource: format!("statement requiring {needed:?}"),
773                        detail: format!(
774                            "auth_store not wired; coarse gate is sole defence (role={role:?})"
775                        ),
776                    }
777                    .emit_global();
778                }
779                return Err(RedDBError::Query(format!(
780                    "permission denied: principal=`{username}` role=`{role:?}` lacks {needed:?} privilege"
781                )));
782            }
783        }
784        runtime
785            .check_query_privilege(expr)
786            .map_err(|err| RedDBError::Query(format!("permission denied: {err}")))
787    }
788
789    pub(super) fn prepare_dispatch(
790        &self,
791        runtime: &RedDBRuntime,
792        expr: &QueryExpr,
793    ) -> RedDBResult<Option<crate::runtime::locking::LockerGuard>> {
794        runtime.validate_model_operations_before_auth(expr)?;
795        self.check_query_privilege(runtime, expr)?;
796        self.release_snapshot_for_maintenance(expr);
797        self.acquire_intent_locks(runtime, expr)
798    }
799
800    pub(super) fn release_snapshot_for_maintenance(&self, expr: &QueryExpr) {
801        if matches!(
802            expr,
803            QueryExpr::MaintenanceCommand(reddb_rql::ast::MaintenanceCommand::Vacuum { .. })
804        ) {
805            // VACUUM inspects physical versions rather than reading its own
806            // statement snapshot. Its temporary pin would otherwise inhibit
807            // reclamation, especially with unused reserved autocommit xids.
808            // Outer/nested reader frames keep their independent pins.
809            self.snapshot_retention.borrow_mut().take();
810        }
811    }
812
813    pub(super) fn acquire_intent_locks(
814        &self,
815        runtime: &RedDBRuntime,
816        expr: &QueryExpr,
817    ) -> RedDBResult<Option<crate::runtime::locking::LockerGuard>> {
818        use crate::runtime::lock_manager::LockMode;
819        use crate::runtime::locking::{LockerGuard, Resource};
820
821        if !runtime.config_bool("concurrency.locking.enabled", true) {
822            return Ok(None);
823        }
824        // Frame-level short-circuit: if the statement carries no lock
825        // intent (transaction control, SET, SHOW), skip the lock
826        // manager entirely instead of letting `intent_lock_modes_for`
827        // walk the parsed expression to reach the same conclusion.
828        if matches!(<Self as ReadFrame>::lock_intent(self), LockIntent::None) {
829            return Ok(None);
830        }
831        let Some((global_mode, coll_mode)) = intent_lock_modes_for(expr) else {
832            return Ok(None);
833        };
834        // A failed acquire (timeout, deadlock) used to be discarded and the
835        // statement ran unlocked (#2376). Fail it instead; the client retries.
836        let acquire = |guard: &mut LockerGuard, resource: Resource, mode: LockMode| {
837            guard.acquire(resource.clone(), mode).map_err(|err| {
838                RedDBError::Query(format!(
839                    "could not acquire {mode:?} lock on {resource:?}: {err}; retry the statement"
840                ))
841            })
842        };
843        let mut guard = LockerGuard::new(runtime.inner.lock_manager.clone());
844        acquire(&mut guard, Resource::Global, global_mode)?;
845        // DDL takes X on its own collection, but every DDL also rewrites
846        // shared catalog state the collection lock does not cover: the
847        // system collections, the physical metadata and a full-store
848        // `persist()`. Serialise all DDL on the catalog (#2376).
849        if coll_mode == LockMode::Exclusive {
850            acquire(&mut guard, Resource::Catalog, LockMode::Exclusive)?;
851        }
852        for collection in collections_referenced(expr) {
853            acquire(&mut guard, Resource::Collection(collection), coll_mode)?;
854        }
855        Ok(Some(guard))
856    }
857}
858
859impl ReadFrame for StatementExecutionFrame {
860    fn snapshot_context(&self, runtime: &RedDBRuntime) -> SnapshotContext {
861        SnapshotContext {
862            snapshot: self.snapshot.clone(),
863            manager: runtime.inner.transaction_state.snapshot_manager(),
864            own_xids: self.own_xids.clone(),
865            requires_index_fallback: self.requires_index_fallback,
866            serializable_reader: self.serializable_reader,
867        }
868    }
869
870    fn effective_scope(&self) -> Option<&str> {
871        self.effective_scope.as_deref()
872    }
873
874    fn identity(&self) -> Option<(&str, Role)> {
875        self.identity.as_ref().map(|(u, r)| (u.as_str(), *r))
876    }
877
878    fn snapshot(&self) -> &Snapshot {
879        &self.snapshot
880    }
881
882    fn as_of_floor(&self) -> Option<Xid> {
883        self.as_of_floor
884    }
885
886    fn cache_key(&self) -> &str {
887        &self.cache_key
888    }
889
890    fn should_cache_result(&self) -> bool {
891        !self.is_volatile_query && self.cache_safe
892    }
893
894    fn required_privilege(&self) -> Privilege {
895        self.required_privilege
896    }
897
898    fn lock_intent(&self) -> LockIntent {
899        self.lock_intent
900    }
901
902    fn visible_collections(&self) -> Option<&HashSet<String>> {
903        self.visible_collections.as_ref()
904    }
905}
906
907/// Lightweight `ReadFrame` carrier used by AI command entry points
908/// (`SEARCH SIMILAR`, `SEARCH CONTEXT`, `ASK`).
909///
910/// Issue #119 calls this struct `EffectiveScope`. It bundles the
911/// `(tenant, structured principal, identity projection, role,
912/// visible_collections, snapshot)` tuple so
913/// every AI runtime entry can pass *one* value to `AuthorizedSearch`
914/// instead of re-reading thread-locals at every call site.
915///
916/// Built via `RedDBRuntime::ai_scope()` which sources tenant + identity
917/// from the per-statement thread-locals (identical to how
918/// `StatementExecutionFrame::build` derives them) and resolves
919/// `visible_collections` via the `AuthStore` cache.
920pub struct EffectiveScope {
921    pub(crate) captured_snapshot: Option<SnapshotContext>,
922    pub(crate) tenant: Option<String>,
923    pub(crate) identity: Option<(String, Role)>,
924    pub(crate) auth_principal: Option<crate::auth::UserId>,
925    pub(crate) snapshot: Snapshot,
926    pub(crate) visible_collections: Option<HashSet<String>>,
927}
928
929impl EffectiveScope {
930    /// Capability check used by the AI runtime (`runtime/ai/ner.rs`)
931    /// to gate LLM-backed NER calls behind `ai:ner:read`.
932    ///
933    /// Placeholder for now: always returns `false`. The auth engine's
934    /// capability matrix is future work; until it lands, every routed
935    /// LLM-NER call denies at the gate and `extract_tokens_routed`'s
936    /// heuristic fallback fires (see `ask_pipeline::extract_tokens_routed`).
937    /// Documented in code so the wire-up is a one-line change once
938    /// the auth engine learns capabilities.
939    pub fn has_capability(&self, _capability: &str) -> bool {
940        false
941    }
942}
943
944impl ReadFrame for EffectiveScope {
945    fn snapshot_context(&self, runtime: &RedDBRuntime) -> SnapshotContext {
946        self.captured_snapshot
947            .clone()
948            .unwrap_or_else(|| SnapshotContext {
949                snapshot: self.snapshot.clone(),
950                manager: runtime.inner.transaction_state.snapshot_manager(),
951                own_xids: HashSet::new(),
952                requires_index_fallback: true,
953                serializable_reader: None,
954            })
955    }
956
957    fn effective_scope(&self) -> Option<&str> {
958        self.tenant.as_deref()
959    }
960    fn identity(&self) -> Option<(&str, Role)> {
961        self.identity.as_ref().map(|(u, r)| (u.as_str(), *r))
962    }
963    fn snapshot(&self) -> &Snapshot {
964        &self.snapshot
965    }
966    fn as_of_floor(&self) -> Option<Xid> {
967        None
968    }
969    fn cache_key(&self) -> &str {
970        ""
971    }
972    fn should_cache_result(&self) -> bool {
973        false
974    }
975    fn required_privilege(&self) -> Privilege {
976        Privilege::Read
977    }
978    fn lock_intent(&self) -> LockIntent {
979        LockIntent::Shared
980    }
981    fn visible_collections(&self) -> Option<&HashSet<String>> {
982        self.visible_collections.as_ref()
983    }
984}
985
986fn own_transaction_xids(
987    ctx: Option<&crate::storage::transaction::snapshot::TxnContext>,
988) -> HashSet<Xid> {
989    let mut set = HashSet::new();
990    if let Some(ctx) = ctx {
991        set.insert(ctx.xid);
992        for (_, sub) in &ctx.savepoints {
993            set.insert(*sub);
994        }
995        for sub in &ctx.released_sub_xids {
996            set.insert(*sub);
997        }
998    }
999    set
1000}
1001
1002impl RedDBRuntime {
1003    /// Build the AI command `EffectiveScope` from the current
1004    /// statement thread-locals + auth store.
1005    ///
1006    /// Returns `None` for embedded callers (no auth store, no
1007    /// identity) — `AuthorizedSearch` treats `None` as deny-default.
1008    pub(crate) fn ai_scope(&self) -> EffectiveScope {
1009        let tenant = super::impl_core::current_tenant();
1010        let identity = super::impl_core::current_auth_identity();
1011        let auth_principal = super::execution_context::current_auth_principal();
1012        let captured_snapshot = super::execution_context::capture_current_snapshot();
1013        let snapshot = captured_snapshot.as_ref().map_or_else(
1014            || self.current_snapshot(),
1015            |context| context.snapshot.clone(),
1016        );
1017        let visible_collections = match (self.inner.auth_store.read().clone(), identity.as_ref()) {
1018            (Some(store), Some((principal, role))) => {
1019                let collections = self.inner.db.store().list_collections();
1020                Some(store.visible_collections_for_scope(
1021                    tenant.as_deref(),
1022                    *role,
1023                    principal,
1024                    &collections,
1025                ))
1026            }
1027            _ => None,
1028        };
1029        EffectiveScope {
1030            captured_snapshot,
1031            tenant,
1032            identity,
1033            auth_principal,
1034            snapshot,
1035            visible_collections,
1036        }
1037    }
1038}
1039
1040/// Test fixtures for callers that need to drive `ReadFrame` without
1041/// booting a runtime. Lives behind `cfg(test)` and `pub(crate)` so it
1042/// only leaks across module boundaries inside the crate.
1043#[cfg(test)]
1044pub(crate) mod test_support {
1045    use super::{LockIntent, Privilege, ReadFrame};
1046    use crate::auth::Role;
1047    use crate::storage::transaction::snapshot::{Snapshot, Xid};
1048    use std::collections::HashSet;
1049
1050    /// A `ReadFrame` impl with hand-set fields. Used by
1051    /// `authorized_search` tests to assert the deny-default and
1052    /// scope-trim behaviour without going through frame construction.
1053    pub(crate) struct FakeReadFrame {
1054        pub tenant: Option<String>,
1055        pub identity: Option<(String, Role)>,
1056        pub snapshot: Snapshot,
1057        pub visible: Option<HashSet<String>>,
1058    }
1059
1060    impl FakeReadFrame {
1061        pub(crate) fn without_scope() -> Self {
1062            Self {
1063                tenant: None,
1064                identity: None,
1065                snapshot: Snapshot {
1066                    xid: 0,
1067                    in_progress: HashSet::new(),
1068                },
1069                visible: None,
1070            }
1071        }
1072
1073        pub(crate) fn with_visible(visible: HashSet<String>) -> Self {
1074            Self {
1075                tenant: Some("acme".to_string()),
1076                identity: Some(("alice".to_string(), Role::Read)),
1077                snapshot: Snapshot {
1078                    xid: 0,
1079                    in_progress: HashSet::new(),
1080                },
1081                visible: Some(visible),
1082            }
1083        }
1084    }
1085
1086    impl ReadFrame for FakeReadFrame {
1087        fn effective_scope(&self) -> Option<&str> {
1088            self.tenant.as_deref()
1089        }
1090        fn identity(&self) -> Option<(&str, Role)> {
1091            self.identity.as_ref().map(|(u, r)| (u.as_str(), *r))
1092        }
1093        fn snapshot(&self) -> &Snapshot {
1094            &self.snapshot
1095        }
1096        fn as_of_floor(&self) -> Option<Xid> {
1097            None
1098        }
1099        fn cache_key(&self) -> &str {
1100            ""
1101        }
1102        fn should_cache_result(&self) -> bool {
1103            false
1104        }
1105        fn required_privilege(&self) -> Privilege {
1106            Privilege::Read
1107        }
1108        fn lock_intent(&self) -> LockIntent {
1109            LockIntent::Shared
1110        }
1111        fn visible_collections(&self) -> Option<&HashSet<String>> {
1112            self.visible.as_ref()
1113        }
1114    }
1115}
1116
1117impl RedDBRuntime {
1118    /// Resolve the snapshot for the current statement, returning
1119    /// the snapshot itself and (when AS OF is in effect) the
1120    /// resolved xid floor. The floor is the same xid carried inside
1121    /// `Snapshot.xid` for AS OF reads — exposing it separately lets
1122    /// the `ReadFrame` Interface tell "live read" from "historical
1123    /// read" without inferring from `in_progress.is_empty()`.
1124    ///
1125    /// A live read nested inside an already-installed frame inherits that
1126    /// frame's snapshot instead of minting a fresh one — but only when it
1127    /// is genuinely *part of* that statement, which is exactly what
1128    /// [`StatementIdentity::Expr`] means. A CTE prelude, a
1129    /// materialized-view refresh, or an inline graph TVF is one statement
1130    /// and must observe one snapshot; re-minting under autocommit / Read
1131    /// Committed takes a new high-water mark, so the nodes and edges of a
1132    /// single graph TVF could otherwise be materialized under two
1133    /// different snapshots.
1134    ///
1135    /// A nested `Text` statement is a different thing entirely: a driver
1136    /// loop issuing statement after statement through `execute_query`.
1137    /// Those are sequential autocommit statements and each must see its
1138    /// predecessors' commits. Inheriting there froze the loop's view —
1139    /// `apply_batched` (`impl_migrations.rs`) drives `APPLY MIGRATION …
1140    /// BATCH N ROWS` by re-running the body with `LIMIT N` until fewer
1141    /// than `N` rows are affected, so a frozen snapshot re-selected the
1142    /// same rows forever and the migration never converged. Twenty-five
1143    /// rows at `BATCH 7` should finish in four iterations; it hung
1144    /// unbounded instead.
1145    ///
1146    /// `AS OF` still wins over both — it names its own snapshot explicitly.
1147    fn statement_snapshot(
1148        &self,
1149        query: &str,
1150        identity: StatementIdentity<'_>,
1151    ) -> RedDBResult<(Snapshot, Option<Xid>, SnapshotRetentionGuard)> {
1152        match peek_top_level_as_of_with_table(query) {
1153            Some((spec, Some(table))) => {
1154                if !table.starts_with("red_")
1155                    && !table.starts_with("red.")
1156                    && !self.vcs_is_versioned(&table)?
1157                {
1158                    return Err(RedDBError::InvalidConfig(format!(
1159                        "AS OF requires a versioned collection — \
1160                         `{table}` has not opted in. \
1161                         Call vcs.set_versioned(\"{table}\", true) first."
1162                    )));
1163                }
1164                let xid = self.vcs_resolve_as_of(spec)?;
1165                let (snapshot, retention) = self.snapshot_manager().retain_snapshot(Snapshot {
1166                    xid,
1167                    in_progress: HashSet::new(),
1168                });
1169                Ok((snapshot, Some(xid), retention))
1170            }
1171            Some((spec, None)) => {
1172                let xid = self.vcs_resolve_as_of(spec)?;
1173                let (snapshot, retention) = self.snapshot_manager().retain_snapshot(Snapshot {
1174                    xid,
1175                    in_progress: HashSet::new(),
1176                });
1177                Ok((snapshot, Some(xid), retention))
1178            }
1179            None => match (identity, capture_current_snapshot()) {
1180                (StatementIdentity::Expr(_), Some(outer)) => {
1181                    let (snapshot, retention) =
1182                        self.snapshot_manager().retain_snapshot(outer.snapshot);
1183                    Ok((snapshot, None, retention))
1184                }
1185                _ => {
1186                    let (snapshot, retention) = self
1187                        .inner
1188                        .transaction_state
1189                        .retained_current_snapshot(current_connection_id());
1190                    Ok((snapshot, None, retention))
1191                }
1192            },
1193        }
1194    }
1195
1196    fn result_cache_safe(&self, conn_id: u64) -> bool {
1197        let has_active_xids = self
1198            .inner
1199            .transaction_state
1200            .snapshot_manager()
1201            .oldest_active_xid()
1202            .is_some();
1203        let in_own_tx = self.inner.transaction_state.in_transaction(conn_id);
1204        !has_active_xids && !in_own_tx
1205    }
1206}
1207
1208/// Whether a result's `engine` tag is one of the graph-analytics TVF
1209/// executors (issue #802). Graph-collection (`louvain(g)`) and inline
1210/// (`louvain(nodes => …, edges => …)`) forms both produce deterministic
1211/// algorithm output that is cached regardless of row count.
1212fn is_graph_tvf_engine(engine: &str) -> bool {
1213    matches!(engine, "runtime-graph-tvf" | "runtime-graph-tvf-inline")
1214}
1215
1216fn result_cache_key(query: &str) -> String {
1217    let tenant = current_tenant().unwrap_or_default();
1218    let auth = current_auth_identity()
1219        .map(|(user, role)| format!("{}|{:?}", user, role))
1220        .unwrap_or_default();
1221    if tenant.is_empty() && auth.is_empty() {
1222        query.to_string()
1223    } else {
1224        format!("{query}\u{001e}{tenant}\u{001e}{auth}")
1225    }
1226}
1227
1228#[cfg(test)]
1229mod tests {
1230    use super::*;
1231    use crate::api::RedDBOptions;
1232    use crate::runtime::impl_core::{
1233        clear_current_auth_identity, clear_current_tenant, set_current_auth_identity,
1234        set_current_tenant,
1235    };
1236    use crate::runtime::RedDBRuntime;
1237
1238    fn fresh_runtime() -> RedDBRuntime {
1239        RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("in-memory runtime")
1240    }
1241
1242    enum HeldFrame {
1243        Ordinary,
1244        Nested,
1245        Historical,
1246    }
1247
1248    fn verify_frame_survives_vacuum(delete: bool, kind: HeldFrame) {
1249        use crate::runtime::mvcc::{clear_current_connection_id, set_current_connection_id};
1250
1251        reset_thread_locals();
1252        clear_current_connection_id();
1253        let runtime = fresh_runtime();
1254        let execute = |connection, sql: &str| {
1255            set_current_connection_id(connection);
1256            let result = runtime.execute_query(sql);
1257            clear_current_connection_id();
1258            result.expect("valid frame retention fixture statement")
1259        };
1260        execute(1, "CREATE TABLE frame_rows (id TEXT, n INTEGER)");
1261        execute(1, "INSERT INTO frame_rows (id, n) VALUES ('row', 1)");
1262        let historical_xid = runtime.current_snapshot().xid;
1263        if matches!(kind, HeldFrame::Historical) {
1264            runtime
1265                .vcs_set_versioned("frame_rows", true)
1266                .expect("enable historical reads");
1267        }
1268        execute(2, "BEGIN");
1269        let query = if matches!(kind, HeldFrame::Historical) {
1270            format!("SELECT id, n FROM frame_rows AS OF SNAPSHOT {historical_xid}")
1271        } else {
1272            "SELECT id, n FROM frame_rows".to_string()
1273        };
1274        let frame = StatementExecutionFrame::build(&runtime, StatementIdentity::Text(&query))
1275            .expect("frame captures its snapshot");
1276        let frame = if matches!(kind, HeldFrame::Nested) {
1277            let guards = frame.install(&runtime);
1278            let expr = QueryExpr::Table(reddb_rql::ast::TableQuery::new("frame_rows"));
1279            let nested = StatementExecutionFrame::build(&runtime, StatementIdentity::Expr(&expr))
1280                .expect("nested frame inherits the protected snapshot");
1281            drop(guards);
1282            drop(frame);
1283            nested
1284        } else {
1285            frame
1286        };
1287        // Pause after capture and before install/scan: GC must also protect
1288        // this gap, rather than starting retention only at TLS installation.
1289        execute(
1290            2,
1291            if delete {
1292                "DELETE FROM frame_rows WHERE id = 'row'"
1293            } else {
1294                "UPDATE frame_rows SET n = 2 WHERE id = 'row'"
1295            },
1296        );
1297        execute(2, "COMMIT");
1298        execute(1, "VACUUM frame_rows");
1299        {
1300            let _guards = frame.install(&runtime);
1301            let records = super::super::record_search::scan_runtime_table_source_records_limited(
1302                &runtime.db(),
1303                "frame_rows",
1304                None,
1305            )
1306            .expect("captured frame scan succeeds after vacuum");
1307            assert_eq!(
1308                records.len(),
1309                1,
1310                "vacuum reclaimed an active frame's visible row"
1311            );
1312            assert_eq!(
1313                records[0]
1314                    .get("n")
1315                    .expect("selected value exists")
1316                    .to_string(),
1317                "1"
1318            );
1319        }
1320        drop(frame);
1321        execute(1, "VACUUM frame_rows");
1322        let physical = runtime
1323            .db()
1324            .store()
1325            .get_collection("frame_rows")
1326            .expect("fixture collection exists")
1327            .query_all(|_| true)
1328            .len();
1329        assert_eq!(
1330            physical,
1331            usize::from(!delete),
1332            "completed frame leaked its retention pin"
1333        );
1334        clear_current_connection_id();
1335    }
1336
1337    #[test]
1338    fn autocommit_frame_retains_update_until_scan_completes() {
1339        verify_frame_survives_vacuum(false, HeldFrame::Ordinary);
1340    }
1341
1342    #[test]
1343    fn autocommit_frame_retains_delete_until_scan_completes() {
1344        verify_frame_survives_vacuum(true, HeldFrame::Ordinary);
1345    }
1346
1347    #[test]
1348    fn nested_frame_retains_history_after_parent_frame_drops() {
1349        verify_frame_survives_vacuum(false, HeldFrame::Nested);
1350    }
1351
1352    #[test]
1353    fn historical_frame_retains_history_until_scan_completes() {
1354        verify_frame_survives_vacuum(true, HeldFrame::Historical);
1355    }
1356
1357    /// Ensure thread-local state from a prior test can't leak into
1358    /// the next one — tests in the same binary share the thread.
1359    fn reset_thread_locals() {
1360        clear_current_tenant();
1361        clear_current_auth_identity();
1362    }
1363
1364    #[test]
1365    fn autocommit_select_takes_live_snapshot() {
1366        reset_thread_locals();
1367        let rt = fresh_runtime();
1368        let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text("SELECT 1"))
1369            .expect("frame builds for SELECT 1");
1370
1371        // The inclusive bound excludes the next unallocated writer xid.
1372        // A pristine manager therefore has a valid empty snapshot at zero.
1373        let f: &dyn ReadFrame = &frame;
1374        assert!(f.as_of_floor().is_none(), "live read has no AS OF floor");
1375        assert_eq!(f.snapshot().xid, rt.snapshot_manager().peek_next_xid() - 1);
1376        assert!(!f.snapshot().sees(rt.snapshot_manager().peek_next_xid(), 0));
1377    }
1378
1379    #[test]
1380    fn autocommit_snapshot_does_not_lose_rows_after_index_publication() {
1381        reset_thread_locals();
1382        let rt = fresh_runtime();
1383        rt.execute_query("CREATE TABLE frame_index_race (id INT PRIMARY KEY, status TEXT)")
1384            .expect("create fixture");
1385        rt.execute_query("INSERT INTO frame_index_race (id, status) VALUES (1, 'old'), (2, 'old')")
1386            .expect("insert fixture");
1387        rt.execute_query("CREATE INDEX frame_status_hash ON frame_index_race (status) USING HASH")
1388            .expect("create index");
1389        let sql = "SELECT id FROM frame_index_race WHERE status = 'old'";
1390        let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text(sql))
1391            .expect("capture autocommit snapshot");
1392        rt.execute_query("UPDATE frame_index_race SET status = 'new' WHERE id = 1")
1393            .expect("publish changed index entry");
1394        let reddb_rql::ast::QueryExpr::Table(query) =
1395            reddb_rql::modes::parse_multi(sql).expect("parse")
1396        else {
1397            panic!("expected table query");
1398        };
1399        let _guards = frame.install(&rt);
1400        let records =
1401            super::super::query_exec::table::execute_runtime_canonical_table_query_indexed(
1402                &rt.db(),
1403                &query,
1404                Some(rt.index_store_ref()),
1405            )
1406            .expect("read captured snapshot");
1407        let mut ids = records
1408            .iter()
1409            .map(|record| record.get("id").expect("id").to_string())
1410            .collect::<Vec<_>>();
1411        ids.sort();
1412        assert_eq!(
1413            ids,
1414            ["1", "2"],
1415            "current index omitted a visible historical row"
1416        );
1417    }
1418
1419    #[test]
1420    fn captured_dml_snapshot_finds_rows_after_index_publication() {
1421        reset_thread_locals();
1422        let rt = fresh_runtime();
1423        rt.execute_query("CREATE TABLE frame_dml_race (id INT PRIMARY KEY, status TEXT)")
1424            .expect("create fixture");
1425        rt.execute_query("INSERT INTO frame_dml_race (id, status) VALUES (1, 'old'), (2, 'old')")
1426            .expect("insert fixture");
1427        rt.execute_query("CREATE INDEX frame_dml_status ON frame_dml_race (status) USING HASH")
1428            .expect("create index");
1429        let sql = "DELETE FROM frame_dml_race WHERE status = 'old'";
1430        let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text(sql))
1431            .expect("capture DML snapshot");
1432        rt.execute_query("UPDATE frame_dml_race SET status = 'new' WHERE id = 1")
1433            .expect("publish new indexed value");
1434        let reddb_rql::ast::QueryExpr::Table(query) =
1435            reddb_rql::modes::parse_multi("SELECT id FROM frame_dml_race WHERE status = 'old'")
1436                .expect("parse filter")
1437        else {
1438            panic!("expected table query")
1439        };
1440        let filter = reddb_rql::sql_lowering::effective_table_filter(&query).expect("filter");
1441        let _guards = frame.install(&rt);
1442        let ids = super::super::dml_target_scan::DmlTargetScan::new(
1443            &rt,
1444            "frame_dml_race",
1445            Some(&filter),
1446            None,
1447        )
1448        .find_target_ids()
1449        .expect("find DML targets");
1450        assert_eq!(
1451            ids.len(),
1452            2,
1453            "DML target scan must discover both snapshot-visible rows"
1454        );
1455    }
1456
1457    #[test]
1458    fn captured_snapshot_survives_indexed_update_and_delete_for_range_queries() {
1459        reset_thread_locals();
1460        let rt = fresh_runtime();
1461        rt.execute_query("CREATE TABLE frame_range_race (id INT PRIMARY KEY, score INT)")
1462            .expect("create fixture");
1463        rt.execute_query("INSERT INTO frame_range_race (id, score) VALUES (1, 10), (2, 20)")
1464            .expect("insert fixture");
1465        rt.execute_query("CREATE INDEX frame_score_range ON frame_range_race (score)")
1466            .expect("create index");
1467        let sql = "SELECT id FROM frame_range_race WHERE score >= 10 AND score <= 20 ORDER BY id";
1468        let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text(sql))
1469            .expect("capture read snapshot");
1470        rt.execute_query("UPDATE frame_range_race SET score = 99 WHERE id = 1")
1471            .expect("update row");
1472        rt.execute_query("DELETE FROM frame_range_race WHERE id = 2")
1473            .expect("delete row");
1474        let reddb_rql::ast::QueryExpr::Table(query) =
1475            reddb_rql::modes::parse_multi(sql).expect("parse")
1476        else {
1477            panic!("expected table query");
1478        };
1479        let _guards = frame.install(&rt);
1480        let records =
1481            super::super::query_exec::table::execute_runtime_canonical_table_query_indexed(
1482                &rt.db(),
1483                &query,
1484                Some(rt.index_store_ref()),
1485            )
1486            .expect("read captured snapshot");
1487        let ids = records
1488            .iter()
1489            .map(|record| record.get("id").expect("id").to_string())
1490            .collect::<Vec<_>>();
1491        assert_eq!(ids, ["1", "2"]);
1492    }
1493
1494    #[test]
1495    fn dml_target_scan_does_not_retry_inside_stored_function_budget() {
1496        use std::sync::atomic::{AtomicUsize, Ordering};
1497        use std::sync::Arc;
1498        reset_thread_locals();
1499        let rt = fresh_runtime();
1500        rt.execute_query("CREATE TABLE dml_budget_guard (id INT, status TEXT)")
1501            .expect("create fixture");
1502        rt.execute_query("INSERT INTO dml_budget_guard (id, status) VALUES (1, 'old')")
1503            .expect("insert fixture");
1504        rt.execute_query("CREATE INDEX dml_budget_status ON dml_budget_guard (status) USING HASH")
1505            .expect("create index");
1506        let frame = StatementExecutionFrame::build(
1507            &rt,
1508            StatementIdentity::Text("DELETE FROM dml_budget_guard WHERE status = 'old'"),
1509        )
1510        .expect("capture snapshot");
1511        let reddb_rql::ast::QueryExpr::Table(query) =
1512            reddb_rql::modes::parse_multi("SELECT id FROM dml_budget_guard WHERE status = 'old'")
1513                .expect("parse filter")
1514        else {
1515            panic!("expected table query")
1516        };
1517        let filter = reddb_rql::sql_lowering::effective_table_filter(&query).expect("filter");
1518        let calls = Arc::new(AtomicUsize::new(0));
1519        let observed = Arc::clone(&calls);
1520        *rt.index_store_ref().mutation_hook.lock() = Some(Arc::new(move |collection, phase| {
1521            if collection == "dml_budget_guard"
1522                && phase == super::super::index_store::MutationTestPhase::DmlIndexLookupStarted
1523            {
1524                observed.fetch_add(1, Ordering::SeqCst);
1525            }
1526        }));
1527        let _budget = super::super::function_budget::Scope::enter(1_000, 10_000)
1528            .expect("stored function budget");
1529        let _guards = frame.install(&rt);
1530        let ids = super::super::dml_target_scan::DmlTargetScan::new(
1531            &rt,
1532            "dml_budget_guard",
1533            Some(&filter),
1534            None,
1535        )
1536        .find_target_ids()
1537        .expect("find DML targets");
1538        assert_eq!(ids.len(), 1);
1539        assert_eq!(
1540            calls.load(Ordering::SeqCst),
1541            0,
1542            "bounded stored functions must not retry indexed discovery"
1543        );
1544        *rt.index_store_ref().mutation_hook.lock() = None;
1545    }
1546
1547    #[test]
1548    fn dml_index_lookup_retries_when_publication_races_target_selection() {
1549        use std::sync::{mpsc, Arc, Mutex};
1550        use std::time::Duration;
1551        reset_thread_locals();
1552        let rt = fresh_runtime();
1553        rt.execute_query("CREATE TABLE dml_lookup_race (id INT PRIMARY KEY, status TEXT)")
1554            .expect("create fixture");
1555        rt.execute_query("INSERT INTO dml_lookup_race (id, status) VALUES (1, 'old'), (2, 'old')")
1556            .expect("insert fixture");
1557        rt.execute_query("CREATE INDEX dml_lookup_status ON dml_lookup_race (status) USING HASH")
1558            .expect("create index");
1559        let frame = StatementExecutionFrame::build(
1560            &rt,
1561            StatementIdentity::Text("DELETE FROM dml_lookup_race WHERE status = 'old'"),
1562        )
1563        .expect("capture snapshot");
1564        let reddb_rql::ast::QueryExpr::Table(query) =
1565            reddb_rql::modes::parse_multi("SELECT id FROM dml_lookup_race WHERE status = 'old'")
1566                .expect("parse filter")
1567        else {
1568            panic!("expected table query")
1569        };
1570        let filter = reddb_rql::sql_lowering::effective_table_filter(&query).expect("filter");
1571        let (started_tx, started_rx) = mpsc::channel();
1572        let (published_tx, published_rx) = mpsc::channel();
1573        let published_rx = Mutex::new(published_rx);
1574        let first = std::sync::atomic::AtomicBool::new(true);
1575        *rt.index_store_ref().mutation_hook.lock() = Some(Arc::new(move |collection, phase| {
1576            if collection == "dml_lookup_race"
1577                && phase == super::super::index_store::MutationTestPhase::DmlIndexLookupStarted
1578                && first.swap(false, std::sync::atomic::Ordering::SeqCst)
1579            {
1580                started_tx.send(()).expect("notify writer");
1581                published_rx
1582                    .lock()
1583                    .unwrap()
1584                    .recv_timeout(Duration::from_secs(10))
1585                    .expect("writer published");
1586            }
1587        }));
1588        std::thread::scope(|scope| {
1589            let writer_rt = &rt;
1590            let writer = scope.spawn(move || {
1591                started_rx
1592                    .recv_timeout(Duration::from_secs(10))
1593                    .expect("lookup started");
1594                writer_rt
1595                    .execute_query("UPDATE dml_lookup_race SET status = 'new' WHERE id = 1")
1596                    .expect("publish update");
1597                published_tx.send(()).expect("release lookup");
1598            });
1599            let limits = super::super::resource_limits::ResourceLimits {
1600                max_query_duration: Some(Duration::from_secs(30)),
1601                ..Default::default()
1602            };
1603            let slots = std::sync::atomic::AtomicU64::new(0);
1604            let _budget = super::super::query_budget::Scope::enter(&limits, &slots)
1605                .expect("query deadline scope");
1606            let _guards = frame.install(&rt);
1607            let ids = super::super::dml_target_scan::DmlTargetScan::new(
1608                &rt,
1609                "dml_lookup_race",
1610                Some(&filter),
1611                None,
1612            )
1613            .find_target_ids()
1614            .expect("find DML targets");
1615            writer.join().expect("writer thread");
1616            assert_eq!(
1617                ids.len(),
1618                2,
1619                "racing publication must not omit a snapshot-visible row"
1620            );
1621        });
1622        *rt.index_store_ref().mutation_hook.lock() = None;
1623    }
1624
1625    #[test]
1626    fn replica_replay_preserves_snapshots_across_insert_update_and_delete() {
1627        use crate::replication::cdc::{change_record_from_entity, ChangeOperation};
1628        use crate::replication::logical::{ApplyMode, LogicalChangeApplier};
1629        reset_thread_locals();
1630        let source = fresh_runtime();
1631        let replica = fresh_runtime();
1632        for rt in [&source, &replica] {
1633            rt.execute_query("CREATE TABLE replica_snapshot (id INT, status TEXT)")
1634                .expect("schema");
1635        }
1636        replica
1637            .execute_query(
1638                "CREATE INDEX replica_snapshot_status ON replica_snapshot (status) USING HASH",
1639            )
1640            .expect("index");
1641        let sql = "SELECT id FROM replica_snapshot WHERE status = 'old'";
1642        let capture = || {
1643            StatementExecutionFrame::build(&replica, StatementIdentity::Text(sql)).expect("capture")
1644        };
1645        let read = |frame: &StatementExecutionFrame| {
1646            let reddb_rql::ast::QueryExpr::Table(query) =
1647                reddb_rql::modes::parse_multi(sql).expect("parse")
1648            else {
1649                panic!("table")
1650            };
1651            let _guards = frame.install(&replica);
1652            super::super::query_exec::table::execute_runtime_canonical_table_query_indexed(
1653                &replica.db(),
1654                &query,
1655                Some(replica.index_store_ref()),
1656            )
1657            .expect("snapshot read")
1658            .len()
1659        };
1660        let empty = capture();
1661        source
1662            .execute_query("INSERT INTO replica_snapshot (id, status) VALUES (1, 'old')")
1663            .expect("insert");
1664        let applier = LogicalChangeApplier::new(0);
1665        let replay = |lsn, op| {
1666            let entity = source
1667                .db()
1668                .store()
1669                .get_collection("replica_snapshot")
1670                .unwrap()
1671                .query_all(|entity| entity.xmax == 0)
1672                .pop()
1673                .unwrap();
1674            applier
1675                .apply(
1676                    &replica.db(),
1677                    &change_record_from_entity(
1678                        lsn,
1679                        1,
1680                        op,
1681                        "replica_snapshot",
1682                        "table",
1683                        &entity,
1684                        12,
1685                        None,
1686                    ),
1687                    ApplyMode::Replica,
1688                )
1689                .expect("replay");
1690        };
1691        replay(1, ChangeOperation::Insert);
1692        assert_eq!(
1693            read(&empty),
1694            0,
1695            "replay must not backdate an insert into an earlier snapshot"
1696        );
1697        let inserted = capture();
1698        source
1699            .execute_query("UPDATE replica_snapshot SET status = 'new' WHERE id = 1")
1700            .expect("update");
1701        replay(2, ChangeOperation::Update);
1702        assert_eq!(read(&inserted), 1, "old version remains visible");
1703        assert_eq!(read(&capture()), 0, "fresh snapshot uses the updated index");
1704        // A snapshot of all rows must retain the deleted version as well.
1705        let all_sql = "SELECT id FROM replica_snapshot";
1706        let before_delete =
1707            StatementExecutionFrame::build(&replica, StatementIdentity::Text(all_sql)).unwrap();
1708        replay(3, ChangeOperation::Delete);
1709        let reddb_rql::ast::QueryExpr::Table(query) =
1710            reddb_rql::modes::parse_multi(all_sql).unwrap()
1711        else {
1712            panic!("table")
1713        };
1714        {
1715            let _guards = before_delete.install(&replica);
1716            assert_eq!(
1717                super::super::query_exec::table::execute_runtime_canonical_table_query_indexed(
1718                    &replica.db(),
1719                    &query,
1720                    Some(replica.index_store_ref())
1721                )
1722                .unwrap()
1723                .len(),
1724                1
1725            );
1726        }
1727        assert_eq!(
1728            replica.execute_query(all_sql).unwrap().result.records.len(),
1729            0
1730        );
1731    }
1732
1733    #[test]
1734    fn autocommit_index_reads_keep_old_rows_while_another_transaction_is_open() {
1735        use crate::runtime::mvcc::{clear_current_connection_id, set_current_connection_id};
1736        reset_thread_locals();
1737        let rt = fresh_runtime();
1738        let execute = |connection, sql: &str| {
1739            set_current_connection_id(connection);
1740            let result = rt.execute_query(sql);
1741            clear_current_connection_id();
1742            result.expect("statement")
1743        };
1744        execute(1, "CREATE TABLE active_writer_rows (id INT, status TEXT)");
1745        execute(
1746            1,
1747            "INSERT INTO active_writer_rows (id, status) VALUES (1, 'old')",
1748        );
1749        execute(
1750            1,
1751            "CREATE INDEX active_writer_status ON active_writer_rows (status) USING HASH",
1752        );
1753        execute(2, "BEGIN");
1754        execute(
1755            2,
1756            "UPDATE active_writer_rows SET status = 'new' WHERE id = 1",
1757        );
1758        assert_eq!(
1759            execute(1, "SELECT id FROM active_writer_rows WHERE status = 'old'")
1760                .result
1761                .records
1762                .len(),
1763            1
1764        );
1765        assert_eq!(
1766            execute(1, "SELECT id FROM active_writer_rows WHERE status = 'new'")
1767                .result
1768                .records
1769                .len(),
1770            0
1771        );
1772        execute(2, "ROLLBACK");
1773        assert_eq!(
1774            execute(1, "SELECT id FROM active_writer_rows WHERE status = 'old'")
1775                .result
1776                .records
1777                .len(),
1778            1
1779        );
1780    }
1781
1782    #[test]
1783    fn frame_captures_identity_and_scope() {
1784        reset_thread_locals();
1785        set_current_tenant("acme".to_string());
1786        set_current_auth_identity("alice".to_string(), Role::Write);
1787
1788        let rt = fresh_runtime();
1789        let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text("SELECT 1"))
1790            .expect("frame builds");
1791        let f: &dyn ReadFrame = &frame;
1792
1793        assert_eq!(f.effective_scope(), Some("acme"));
1794        let id = f.identity().expect("identity captured");
1795        assert_eq!(id.0, "alice");
1796        assert!(matches!(id.1, Role::Write));
1797
1798        // Cache key mixes scope + identity so two callers under
1799        // different tenants never share a cache slot.
1800        assert!(
1801            f.cache_key().contains("acme") && f.cache_key().contains("alice"),
1802            "cache key folds in scope + identity, got {:?}",
1803            f.cache_key()
1804        );
1805
1806        reset_thread_locals();
1807    }
1808
1809    #[test]
1810    fn as_of_rejects_non_versioned_user_collection() {
1811        reset_thread_locals();
1812        let rt = fresh_runtime();
1813
1814        // `not_versioned` is a plain user collection — the frame
1815        // builder must reject AS OF until the caller opts in via
1816        // `vcs.set_versioned`.
1817        let err = match StatementExecutionFrame::build(
1818            &rt,
1819            StatementIdentity::Text("SELECT * FROM not_versioned AS OF COMMIT 'deadbeef'"),
1820        ) {
1821            Err(e) => e,
1822            Ok(_) => panic!("AS OF on non-versioned user collection rejected"),
1823        };
1824
1825        let msg = format!("{err}");
1826        assert!(
1827            msg.contains("AS OF requires a versioned collection"),
1828            "expected AS OF rejection, got: {msg}"
1829        );
1830    }
1831
1832    /// End-to-end proof that the SELECT path consumes a `ReadFrame`.
1833    ///
1834    /// Sets a tenant + identity via the public thread-local API the
1835    /// runtime uses for ambient scope, drives a real `SELECT` through
1836    /// `execute_query`, then inspects the result cache that the SELECT
1837    /// path populates via `frame.cache_key()`. The key only carries
1838    /// the tenant + identity *because* it was built through the frame —
1839    /// reverting the wiring to inline `current_tenant()` /
1840    /// `current_auth_identity()` reads would still pass this test, but
1841    /// dropping the frame entirely (so the SELECT path stopped touching
1842    /// `cache_key`) would break it.
1843    #[test]
1844    fn select_path_routes_through_frame_cache_key() {
1845        reset_thread_locals();
1846        set_current_tenant("acme".to_string());
1847        set_current_auth_identity("alice".to_string(), Role::Read);
1848
1849        let rt = fresh_runtime();
1850        let result = rt
1851            .execute_query("SELECT 1")
1852            .expect("SELECT 1 executes under tenant=acme/identity=alice");
1853        assert_eq!(result.statement_type, "select");
1854
1855        // The textual SELECT path builds a frame and
1856        // writes its result through `frame.cache_key()`. That key folds
1857        // tenant + identity in via `result_cache_key`, so finding "acme"
1858        // and "alice" inside any cached key proves the frame was the
1859        // seam used.
1860        let cache = rt.inner.result_cache.read();
1861        let any_keyed_with_scope = cache
1862            .0
1863            .keys()
1864            .any(|k| k.contains("acme") && k.contains("alice"));
1865        assert!(
1866            any_keyed_with_scope,
1867            "expected at least one result-cache key carrying tenant+identity, \
1868             got keys: {:?}",
1869            cache.0.keys().collect::<Vec<_>>()
1870        );
1871
1872        reset_thread_locals();
1873    }
1874
1875    /// A SELECT that calls a volatile builtin (here:
1876    /// `pg_advisory_unlock`, the volatile token the runtime currently
1877    /// recognises in `query_has_volatile_builtin`) must NOT populate
1878    /// the result cache. Any caller hitting the cache after this would
1879    /// see a stale answer for an inherently-volatile query, so the
1880    /// SELECT path gates writes through `frame.should_cache_result()`.
1881    ///
1882    /// Deletion test: removing `ReadFrame::should_cache_result`, or
1883    /// reverting the SELECT path to skip its safety gate, would let
1884    /// the result cache silently absorb this statement and break the
1885    /// assertion below.
1886    #[test]
1887    fn volatile_select_does_not_populate_result_cache() {
1888        reset_thread_locals();
1889        let rt = fresh_runtime();
1890
1891        // Frame-level invariant: the volatile-builtin signal collapses
1892        // `should_cache_result` to false even for an autocommit /
1893        // out-of-tx connection.
1894        let frame = StatementExecutionFrame::build(
1895            &rt,
1896            StatementIdentity::Text("SELECT pg_advisory_unlock(1)"),
1897        )
1898        .expect("frame");
1899        let f: &dyn ReadFrame = &frame;
1900        assert!(
1901            !f.should_cache_result(),
1902            "volatile builtin must disable result-cache safety"
1903        );
1904
1905        // End-to-end: drive the volatile SELECT through `execute_query`
1906        // and confirm no entry was stamped under its cache key. Other
1907        // entries from prior tests sharing the binary may exist, so we
1908        // assert specifically on this query's key.
1909        let _ = rt
1910            .execute_query("SELECT pg_advisory_unlock(1)")
1911            .expect("volatile SELECT executes");
1912        let cache = rt.inner.result_cache.read();
1913        let key = result_cache_key("SELECT pg_advisory_unlock(1)");
1914        assert!(
1915            !cache.0.contains_key(&key),
1916            "volatile SELECT must not populate result cache, found key {key:?} in {:?}",
1917            cache.0.keys().collect::<Vec<_>>()
1918        );
1919
1920        reset_thread_locals();
1921    }
1922
1923    #[test]
1924    fn blob_cache_backend_populates_blob_path_without_legacy_write() {
1925        reset_thread_locals();
1926        let rt = fresh_runtime();
1927        rt.inner
1928            .db
1929            .store()
1930            .set_config_tree("runtime.result_cache.backend", &crate::json!("blob_cache"));
1931
1932        let result = rt.execute_query("SELECT 1").expect("SELECT 1 executes");
1933        assert_eq!(result.statement_type, "select");
1934
1935        let key = result_cache_key("SELECT 1");
1936        assert!(
1937            rt.inner
1938                .result_blob_cache
1939                .get("runtime.result_cache", &key)
1940                .is_some(),
1941            "blob backend should stamp the Blob Cache path"
1942        );
1943        assert!(rt.inner.result_blob_entries.read().0.contains_key(&key));
1944        assert!(
1945            !rt.inner.result_cache.read().0.contains_key(&key),
1946            "blob backend should not write the legacy map"
1947        );
1948    }
1949
1950    #[test]
1951    fn blob_cache_backend_keeps_volatile_select_out_of_blob_path() {
1952        reset_thread_locals();
1953        let rt = fresh_runtime();
1954        rt.inner
1955            .db
1956            .store()
1957            .set_config_tree("runtime.result_cache.backend", &crate::json!("blob_cache"));
1958
1959        let _ = rt
1960            .execute_query("SELECT pg_advisory_unlock(1)")
1961            .expect("volatile SELECT executes");
1962        let key = result_cache_key("SELECT pg_advisory_unlock(1)");
1963        assert!(
1964            rt.inner
1965                .result_blob_cache
1966                .get("runtime.result_cache", &key)
1967                .is_none(),
1968            "volatile SELECT must not populate blob result cache"
1969        );
1970        assert!(!rt.inner.result_blob_entries.read().0.contains_key(&key));
1971    }
1972
1973    #[test]
1974    fn shadow_backend_dual_writes_and_reports_no_divergence_on_equal_results() {
1975        reset_thread_locals();
1976        let rt = fresh_runtime();
1977        rt.inner
1978            .db
1979            .store()
1980            .set_config_tree("runtime.result_cache.backend", &crate::json!("shadow"));
1981
1982        let first = rt.execute_query("SELECT 1").expect("first SELECT");
1983        let second = rt.execute_query("SELECT 1").expect("cached SELECT");
1984        assert_eq!(first.result.len(), second.result.len());
1985
1986        let key = result_cache_key("SELECT 1");
1987        assert!(rt.inner.result_cache.read().0.contains_key(&key));
1988        assert!(rt.inner.result_blob_entries.read().0.contains_key(&key));
1989        assert_eq!(rt.result_cache_shadow_divergences(), 0);
1990        assert_eq!(
1991            crate::runtime::METRIC_CACHE_SHADOW_DIVERGENCE_TOTAL,
1992            "cache_shadow_divergence_total"
1993        );
1994    }
1995
1996    #[test]
1997    fn as_of_on_red_collection_records_floor() {
1998        reset_thread_locals();
1999        let rt = fresh_runtime();
2000
2001        // `red_*` collections always allow AS OF. The frame should
2002        // resolve to a concrete xid and surface it via the Interface.
2003        let frame = StatementExecutionFrame::build(
2004            &rt,
2005            StatementIdentity::Text("SELECT * FROM red_commits AS OF SNAPSHOT 1"),
2006        )
2007        .expect("AS OF SNAPSHOT 1 on red_commits resolves");
2008
2009        let f: &dyn ReadFrame = &frame;
2010        assert_eq!(
2011            f.as_of_floor(),
2012            Some(1),
2013            "AS OF SNAPSHOT 1 records xid=1 as the floor"
2014        );
2015        assert_eq!(f.snapshot().xid, 1);
2016        assert!(
2017            f.snapshot().in_progress.is_empty(),
2018            "historical reads have no in-progress set"
2019        );
2020    }
2021
2022    /// The frame classifies common SQL prefixes into the coarse
2023    /// `Privilege` / `LockIntent` buckets at build time. This test
2024    /// pins the mapping so a regression that silently re-routes
2025    /// (e.g. INSERT classified as Read) surfaces here, not at a
2026    /// downstream privilege gate.
2027    #[test]
2028    fn frame_classifies_privilege_and_lock_intent_from_prefix() {
2029        reset_thread_locals();
2030        let rt = fresh_runtime();
2031
2032        let cases = [
2033            ("SELECT 1", Privilege::Read, LockIntent::Shared),
2034            ("LIST KV settings", Privilege::Read, LockIntent::Shared),
2035            (
2036                "KV GET settings.feature",
2037                Privilege::Read,
2038                LockIntent::Shared,
2039            ),
2040            ("VAULT LIST secrets", Privilege::Read, LockIntent::Shared),
2041            (
2042                "INSERT INTO t (id) VALUES (1)",
2043                Privilege::Write,
2044                LockIntent::Exclusive,
2045            ),
2046            (
2047                "KV PUT settings.feature = 'on'",
2048                Privilege::Write,
2049                LockIntent::Exclusive,
2050            ),
2051            (
2052                "VAULT PUT secrets.api = 'x'",
2053                Privilege::Write,
2054                LockIntent::Exclusive,
2055            ),
2056            (
2057                "UPDATE t SET x = 1 WHERE id = 1",
2058                Privilege::Write,
2059                LockIntent::Exclusive,
2060            ),
2061            (
2062                "DELETE FROM t WHERE id = 1",
2063                Privilege::Write,
2064                LockIntent::Exclusive,
2065            ),
2066            (
2067                "CREATE TABLE foo (id INT)",
2068                Privilege::Write,
2069                LockIntent::Exclusive,
2070            ),
2071            ("BEGIN", Privilege::None, LockIntent::None),
2072            ("COMMIT", Privilege::None, LockIntent::None),
2073            ("SET timezone = 'UTC'", Privilege::None, LockIntent::None),
2074        ];
2075
2076        for (q, want_priv, want_lock) in cases {
2077            let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text(q))
2078                .unwrap_or_else(|e| panic!("frame builds for {q:?}: {e}"));
2079            let f: &dyn ReadFrame = &frame;
2080            assert_eq!(f.required_privilege(), want_priv, "privilege for {q:?}");
2081            assert_eq!(f.lock_intent(), want_lock, "lock intent for {q:?}");
2082        }
2083    }
2084
2085    /// Deletion-test for `ReadFrame::required_privilege`: a SELECT
2086    /// driven through `execute_query` under an identity whose role
2087    /// doesn't satisfy the frame's coarse `Read` privilege gets
2088    /// denied with the frame's signal.
2089    ///
2090    /// We test the gate by classifying an INSERT (which the frame
2091    /// reports as `Privilege::Write`) under `Role::Read` — the only
2092    /// pair the legacy fallback would also reject, but here the
2093    /// rejection comes through `frame.check_query_privilege` BEFORE
2094    /// the parsed-expression walker runs. Removing
2095    /// `required_privilege` (or the `is_satisfied_by` consult inside
2096    /// `check_query_privilege`) would force the deny path back to the
2097    /// inline `RedDBRuntime::check_query_privilege` walker — but the
2098    /// auth_store gate up there is bypassed when no auth_store is
2099    /// wired (embedded test mode), so this test would FLIP from
2100    /// denied to permitted and break the assertion below.
2101    #[test]
2102    fn insert_under_read_role_denied_via_frame_privilege() {
2103        reset_thread_locals();
2104        set_current_auth_identity("alice".to_string(), Role::Read);
2105
2106        let rt = fresh_runtime();
2107        // Bypass parser by reaching into the frame directly: the
2108        // frame derives privilege from the SQL prefix without
2109        // needing an auth_store wired up. Driving end-to-end via
2110        // `execute_query` would also reject (no table `t`), but for
2111        // a different reason — we want to pin the privilege seam.
2112        let frame = StatementExecutionFrame::build(
2113            &rt,
2114            StatementIdentity::Text("INSERT INTO t (id) VALUES (1)"),
2115        )
2116        .expect("frame builds for INSERT");
2117        let f: &dyn ReadFrame = &frame;
2118        assert_eq!(
2119            f.required_privilege(),
2120            Privilege::Write,
2121            "INSERT classified as Write"
2122        );
2123        let id = f.identity().expect("identity captured");
2124        assert!(
2125            !f.required_privilege().is_satisfied_by(id.1),
2126            "Role::Read does not satisfy Privilege::Write — frame must deny"
2127        );
2128
2129        // End-to-end: the frame's `check_query_privilege` sees the
2130        // (Read role, Write privilege) mismatch and denies before
2131        // dispatch. We drive a synthetic `QueryExpr::Table` because
2132        // the SELECT/INSERT parser would happen to also fail, and we
2133        // want the failure to come from the privilege seam.
2134        use reddb_rql::ast::{QueryExpr, TableQuery};
2135        let expr = QueryExpr::Table(TableQuery::new("t"));
2136        let err = frame
2137            .check_query_privilege(&rt, &expr)
2138            .expect_err("denied via frame's coarse privilege gate");
2139        let msg = format!("{err}");
2140        assert!(
2141            msg.contains("permission denied") && msg.contains("Write"),
2142            "expected frame-level Write deny, got: {msg}"
2143        );
2144
2145        reset_thread_locals();
2146    }
2147
2148    /// End-to-end proof that the frame-owned row-buffer arena (#885) is
2149    /// wired into the SELECT path and produces observable results
2150    /// byte-identical to the per-request-allocation baseline.
2151    ///
2152    /// A table with more rows than the streaming high-water mark
2153    /// (`DEFAULT_HIGH_WATER_MARK`) forces the `execute_runtime_table_query_in`
2154    /// path to assemble many chunks, each leasing/recycling the frame
2155    /// arena's single chunk buffer. Driving it through `execute_query`
2156    /// (which builds a `StatementExecutionFrame` and lends its arena)
2157    /// must return every inserted row, in order — exactly what the
2158    /// allocate-per-chunk path returned. A bug in the arena wiring
2159    /// (dropped rows, bled rows, mis-ordering) would surface here.
2160    #[test]
2161    fn large_select_through_frame_arena_returns_all_rows_in_order() {
2162        reset_thread_locals();
2163        let rt = fresh_runtime();
2164        rt.execute_query("CREATE TABLE big (id INT)")
2165            .expect("create table");
2166
2167        // > DEFAULT_HIGH_WATER_MARK (1024) rows so the streaming channel
2168        // spans multiple chunks and the arena buffer is reused.
2169        const N: usize = 2_500;
2170        for start in (0..N).step_by(250) {
2171            let end = (start + 250).min(N);
2172            let values = (start..end)
2173                .map(|i| format!("({i})"))
2174                .collect::<Vec<_>>()
2175                .join(", ");
2176            rt.execute_query(&format!("INSERT INTO big (id) VALUES {values}"))
2177                .unwrap_or_else(|err| panic!("insert rows {start}..{end}: {err:?}"));
2178        }
2179
2180        let result = rt
2181            .execute_query("SELECT id FROM big ORDER BY id")
2182            .expect("large SELECT executes through the frame arena path");
2183        assert_eq!(result.statement_type, "select");
2184        assert_eq!(
2185            result.result.records.len(),
2186            N,
2187            "every inserted row streams back through the arena-backed channel"
2188        );
2189        for (i, record) in result.result.records.iter().enumerate() {
2190            assert_eq!(
2191                record.get("id"),
2192                Some(&reddb_types::Value::Integer(i as i64)),
2193                "row {i} is byte-identical to the per-request-allocation baseline"
2194            );
2195        }
2196
2197        reset_thread_locals();
2198    }
2199
2200    /// Transport adapters may decode their wire-specific parameter value
2201    /// shapes, but SQL parsing/binding must stay behind the runtime's
2202    /// statement entrypoint. This pins the deeper seam introduced for
2203    /// parameterized query execution. HTTP, JSON-RPC, RedWire, and gRPC hand
2204    /// a `QueryRequest` to the Request module, as does PG wire through the
2205    /// shared prepared registry. All paths install a real
2206    /// `StatementExecutionFrame` before dispatch.
2207    ///
2208    /// The Request transports reach the runtime entrypoint one layer deeper:
2209    /// `QueryRequestExecutor` calls `execute_query_with_params` on their
2210    /// behalf. Their delegation target is the Request seam, so that is what
2211    /// is pinned here — the "no binding in the adapter" half is unchanged
2212    /// for all.
2213    #[test]
2214    fn parameterized_transport_adapters_delegate_binding_to_runtime() {
2215        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
2216        let adapters = [
2217            ("src/server/handlers_query.rs", "QueryRequestExecutor"),
2218            ("src/rpc_stdio.rs", "QueryRequestExecutor"),
2219            ("src/wire/redwire/session.rs", "QueryRequestExecutor"),
2220            ("src/wire/postgres/server.rs", "QueryRequestExecutor"),
2221            ("src/grpc.rs", "QueryRequestExecutor"),
2222        ];
2223
2224        for (relative, delegation) in adapters {
2225            let path = manifest_dir.join(relative);
2226            let text = std::fs::read_to_string(&path)
2227                .unwrap_or_else(|err| panic!("read {}: {err}", path.display()));
2228            assert!(
2229                text.contains(delegation),
2230                "{relative} should delegate parameterized query execution through {delegation}"
2231            );
2232            assert!(
2233                !text.contains("user_params::bind"),
2234                "{relative} must not bind SQL params in the transport adapter"
2235            );
2236        }
2237
2238        // The Request module is the only place RedWire's delegation may
2239        // bottom out: it must still reach the runtime entrypoint itself.
2240        let seam = std::fs::read_to_string(manifest_dir.join("src/runtime/query_request.rs"))
2241            .expect("read src/runtime/query_request.rs");
2242        assert!(
2243            seam.contains("execute_query_with_params"),
2244            "the Request module must execute text SQL through the runtime entrypoint"
2245        );
2246    }
2247
2248    /// Deletion-test for `ReadFrame::lock_intent`: a transaction
2249    /// control statement carries `LockIntent::None` and the
2250    /// `acquire_intent_locks` path returns `None` without consulting
2251    /// `intent_lock_modes_for`. Removing the method (or its consult
2252    /// site in `acquire_intent_locks`) would force the lock-mode
2253    /// helper to walk a fabricated parsed expression to reach the
2254    /// same conclusion — but the assertion that no guard is allocated
2255    /// for a `BEGIN` frame would still hold, so we additionally pin
2256    /// the classifier mapping above to make the deletion observable.
2257    #[test]
2258    fn control_statement_skips_intent_locks_via_frame() {
2259        reset_thread_locals();
2260        let rt = fresh_runtime();
2261
2262        let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text("BEGIN"))
2263            .expect("frame builds for BEGIN");
2264        let f: &dyn ReadFrame = &frame;
2265        assert_eq!(f.lock_intent(), LockIntent::None);
2266
2267        // Drive `acquire_intent_locks` against a fabricated SELECT
2268        // expression that WOULD normally yield `(IS, IS)`; the frame's
2269        // `lock_intent() == None` short-circuit must still suppress
2270        // the guard.
2271        use reddb_rql::ast::{QueryExpr, TableQuery};
2272        let expr = QueryExpr::Table(TableQuery::new("t"));
2273        let guard = frame
2274            .acquire_intent_locks(&rt, &expr)
2275            .expect("no lock is attempted");
2276        assert!(
2277            guard.is_none(),
2278            "BEGIN frame's lock_intent=None must short-circuit lock acquisition"
2279        );
2280    }
2281
2282    /// Nested sub-execution inside one statement (CTE prelude,
2283    /// materialized-view refresh, inline graph TVF) must read the
2284    /// enclosing statement's snapshot. Autocommit is the case that used
2285    /// to break: `current_snapshot()` mints a fresh high-water mark per
2286    /// call, so an inline graph TVF could materialize its nodes and its
2287    /// edges under two different snapshots within one statement.
2288    #[test]
2289    fn nested_frame_inherits_outer_statement_snapshot() {
2290        reset_thread_locals();
2291        let rt = fresh_runtime();
2292        rt.execute_query("CREATE TABLE nested_snap (id INT)")
2293            .expect("create table");
2294
2295        let outer = StatementExecutionFrame::build(
2296            &rt,
2297            StatementIdentity::Text("SELECT * FROM nested_snap"),
2298        )
2299        .expect("outer frame builds");
2300        let _outer_guards = outer.install(&rt);
2301
2302        // A write between the two builds moves the high-water mark, so a
2303        // re-minting nested frame would land on a different xid.
2304        rt.execute_query("INSERT INTO nested_snap (id) VALUES (1)")
2305            .expect("concurrent insert");
2306
2307        let expr = QueryExpr::Table(reddb_rql::ast::TableQuery::new("nested_snap"));
2308        let nested = StatementExecutionFrame::build(&rt, StatementIdentity::Expr(&expr))
2309            .expect("nested frame builds");
2310
2311        assert_eq!(
2312            ReadFrame::snapshot(&nested).xid,
2313            ReadFrame::snapshot(&outer).xid,
2314            "nested execution must inherit the outer statement's snapshot"
2315        );
2316    }
2317
2318    /// The other side of the same rule, and the one that matters more:
2319    /// a nested statement built from *text* is a statement in its own
2320    /// right, not part of the enclosing one, so it must mint a fresh
2321    /// snapshot and observe everything committed before it.
2322    ///
2323    /// `apply_batched` (`impl_migrations.rs`) is the load-bearing caller:
2324    /// it drives `APPLY MIGRATION … BATCH N ROWS` by re-running the body
2325    /// with `LIMIT N` through `execute_query` until fewer than `N` rows
2326    /// are affected. Inheriting the outer `APPLY MIGRATION` statement's
2327    /// snapshot froze that loop's view, so every iteration re-selected
2328    /// the same rows and the migration never converged — 25 rows at
2329    /// `BATCH 7` hung instead of finishing in four iterations.
2330    #[test]
2331    fn nested_text_statement_sees_commits_made_since_the_outer_frame() {
2332        reset_thread_locals();
2333        let rt = fresh_runtime();
2334        rt.execute_query("CREATE TABLE nested_text_snap (id INT)")
2335            .expect("create table");
2336
2337        let outer = StatementExecutionFrame::build(
2338            &rt,
2339            StatementIdentity::Text("SELECT * FROM nested_text_snap"),
2340        )
2341        .expect("outer frame builds");
2342        let _outer_guards = outer.install(&rt);
2343
2344        rt.execute_query("INSERT INTO nested_text_snap (id) VALUES (1)")
2345            .expect("concurrent insert");
2346
2347        let nested = StatementExecutionFrame::build(
2348            &rt,
2349            StatementIdentity::Text("SELECT * FROM nested_text_snap"),
2350        )
2351        .expect("nested frame builds");
2352
2353        assert_ne!(
2354            ReadFrame::snapshot(&nested).xid,
2355            ReadFrame::snapshot(&outer).xid,
2356            "a nested text statement must mint a fresh snapshot, not inherit \
2357             the enclosing statement's frozen view"
2358        );
2359    }
2360
2361    /// A frame with no statement text must not silently answer an
2362    /// `AS OF` read with live data. `TableQuery::as_of` is consumed
2363    /// nowhere in the runtime or scan path, so the only correct
2364    /// behaviours are "resolve it" (text path) or "refuse".
2365    #[test]
2366    fn expr_frame_rejects_as_of_instead_of_reading_live() {
2367        reset_thread_locals();
2368        let rt = fresh_runtime();
2369
2370        let mut table = reddb_rql::ast::TableQuery::new("red_commits");
2371        table.as_of = Some(reddb_rql::ast::AsOfClause::Snapshot(1));
2372        let expr = QueryExpr::Table(table);
2373
2374        let err = match StatementExecutionFrame::build(&rt, StatementIdentity::Expr(&expr)) {
2375            Err(err) => err,
2376            Ok(_) => panic!("AS OF without statement text must be refused, not read live"),
2377        };
2378        let msg = format!("{err}");
2379        assert!(
2380            msg.contains("AS OF cannot be resolved from a pre-parsed expression"),
2381            "expected a loud AS OF refusal, got: {msg}"
2382        );
2383    }
2384
2385    /// The expression path has no stable text key, so it must opt out of
2386    /// the result cache entirely. A kind label (`"table"`, `"insert"`)
2387    /// would key every statement of the same kind to one cache slot.
2388    #[test]
2389    fn expr_frame_opts_out_of_the_result_cache() {
2390        reset_thread_locals();
2391        let rt = fresh_runtime();
2392
2393        let expr = QueryExpr::Table(reddb_rql::ast::TableQuery::new("t"));
2394        let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Expr(&expr))
2395            .expect("expr frame builds");
2396
2397        assert!(frame.cache_key().is_empty(), "expr frame has no cache key");
2398        assert!(
2399            !frame.can_read_result_cache(),
2400            "expr frame must never serve from the result cache"
2401        );
2402    }
2403}