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, identity, role, visible_collections, snapshot)` tuple so
912/// every AI runtime entry can pass *one* value to `AuthorizedSearch`
913/// instead of re-reading thread-locals at every call site.
914///
915/// Built via `RedDBRuntime::ai_scope()` which sources tenant + identity
916/// from the per-statement thread-locals (identical to how
917/// `StatementExecutionFrame::build` derives them) and resolves
918/// `visible_collections` via the `AuthStore` cache.
919pub struct EffectiveScope {
920 pub(crate) captured_snapshot: Option<SnapshotContext>,
921 pub(crate) tenant: Option<String>,
922 pub(crate) identity: Option<(String, Role)>,
923 pub(crate) snapshot: Snapshot,
924 pub(crate) visible_collections: Option<HashSet<String>>,
925}
926
927impl EffectiveScope {
928 /// Capability check used by the AI runtime (`runtime/ai/ner.rs`)
929 /// to gate LLM-backed NER calls behind `ai:ner:read`.
930 ///
931 /// Placeholder for now: always returns `false`. The auth engine's
932 /// capability matrix is future work; until it lands, every routed
933 /// LLM-NER call denies at the gate and `extract_tokens_routed`'s
934 /// heuristic fallback fires (see `ask_pipeline::extract_tokens_routed`).
935 /// Documented in code so the wire-up is a one-line change once
936 /// the auth engine learns capabilities.
937 pub fn has_capability(&self, _capability: &str) -> bool {
938 false
939 }
940}
941
942impl ReadFrame for EffectiveScope {
943 fn snapshot_context(&self, runtime: &RedDBRuntime) -> SnapshotContext {
944 self.captured_snapshot
945 .clone()
946 .unwrap_or_else(|| SnapshotContext {
947 snapshot: self.snapshot.clone(),
948 manager: runtime.inner.transaction_state.snapshot_manager(),
949 own_xids: HashSet::new(),
950 requires_index_fallback: true,
951 serializable_reader: None,
952 })
953 }
954
955 fn effective_scope(&self) -> Option<&str> {
956 self.tenant.as_deref()
957 }
958 fn identity(&self) -> Option<(&str, Role)> {
959 self.identity.as_ref().map(|(u, r)| (u.as_str(), *r))
960 }
961 fn snapshot(&self) -> &Snapshot {
962 &self.snapshot
963 }
964 fn as_of_floor(&self) -> Option<Xid> {
965 None
966 }
967 fn cache_key(&self) -> &str {
968 ""
969 }
970 fn should_cache_result(&self) -> bool {
971 false
972 }
973 fn required_privilege(&self) -> Privilege {
974 Privilege::Read
975 }
976 fn lock_intent(&self) -> LockIntent {
977 LockIntent::Shared
978 }
979 fn visible_collections(&self) -> Option<&HashSet<String>> {
980 self.visible_collections.as_ref()
981 }
982}
983
984fn own_transaction_xids(
985 ctx: Option<&crate::storage::transaction::snapshot::TxnContext>,
986) -> HashSet<Xid> {
987 let mut set = HashSet::new();
988 if let Some(ctx) = ctx {
989 set.insert(ctx.xid);
990 for (_, sub) in &ctx.savepoints {
991 set.insert(*sub);
992 }
993 for sub in &ctx.released_sub_xids {
994 set.insert(*sub);
995 }
996 }
997 set
998}
999
1000impl RedDBRuntime {
1001 /// Build the AI command `EffectiveScope` from the current
1002 /// statement thread-locals + auth store.
1003 ///
1004 /// Returns `None` for embedded callers (no auth store, no
1005 /// identity) — `AuthorizedSearch` treats `None` as deny-default.
1006 pub(crate) fn ai_scope(&self) -> EffectiveScope {
1007 let tenant = super::impl_core::current_tenant();
1008 let identity = super::impl_core::current_auth_identity();
1009 let captured_snapshot = super::execution_context::capture_current_snapshot();
1010 let snapshot = captured_snapshot.as_ref().map_or_else(
1011 || self.current_snapshot(),
1012 |context| context.snapshot.clone(),
1013 );
1014 let visible_collections = match (self.inner.auth_store.read().clone(), identity.as_ref()) {
1015 (Some(store), Some((principal, role))) => {
1016 let collections = self.inner.db.store().list_collections();
1017 Some(store.visible_collections_for_scope(
1018 tenant.as_deref(),
1019 *role,
1020 principal,
1021 &collections,
1022 ))
1023 }
1024 _ => None,
1025 };
1026 EffectiveScope {
1027 captured_snapshot,
1028 tenant,
1029 identity,
1030 snapshot,
1031 visible_collections,
1032 }
1033 }
1034}
1035
1036/// Test fixtures for callers that need to drive `ReadFrame` without
1037/// booting a runtime. Lives behind `cfg(test)` and `pub(crate)` so it
1038/// only leaks across module boundaries inside the crate.
1039#[cfg(test)]
1040pub(crate) mod test_support {
1041 use super::{LockIntent, Privilege, ReadFrame};
1042 use crate::auth::Role;
1043 use crate::storage::transaction::snapshot::{Snapshot, Xid};
1044 use std::collections::HashSet;
1045
1046 /// A `ReadFrame` impl with hand-set fields. Used by
1047 /// `authorized_search` tests to assert the deny-default and
1048 /// scope-trim behaviour without going through frame construction.
1049 pub(crate) struct FakeReadFrame {
1050 pub tenant: Option<String>,
1051 pub identity: Option<(String, Role)>,
1052 pub snapshot: Snapshot,
1053 pub visible: Option<HashSet<String>>,
1054 }
1055
1056 impl FakeReadFrame {
1057 pub(crate) fn without_scope() -> Self {
1058 Self {
1059 tenant: None,
1060 identity: None,
1061 snapshot: Snapshot {
1062 xid: 0,
1063 in_progress: HashSet::new(),
1064 },
1065 visible: None,
1066 }
1067 }
1068
1069 pub(crate) fn with_visible(visible: HashSet<String>) -> Self {
1070 Self {
1071 tenant: Some("acme".to_string()),
1072 identity: Some(("alice".to_string(), Role::Read)),
1073 snapshot: Snapshot {
1074 xid: 0,
1075 in_progress: HashSet::new(),
1076 },
1077 visible: Some(visible),
1078 }
1079 }
1080 }
1081
1082 impl ReadFrame for FakeReadFrame {
1083 fn effective_scope(&self) -> Option<&str> {
1084 self.tenant.as_deref()
1085 }
1086 fn identity(&self) -> Option<(&str, Role)> {
1087 self.identity.as_ref().map(|(u, r)| (u.as_str(), *r))
1088 }
1089 fn snapshot(&self) -> &Snapshot {
1090 &self.snapshot
1091 }
1092 fn as_of_floor(&self) -> Option<Xid> {
1093 None
1094 }
1095 fn cache_key(&self) -> &str {
1096 ""
1097 }
1098 fn should_cache_result(&self) -> bool {
1099 false
1100 }
1101 fn required_privilege(&self) -> Privilege {
1102 Privilege::Read
1103 }
1104 fn lock_intent(&self) -> LockIntent {
1105 LockIntent::Shared
1106 }
1107 fn visible_collections(&self) -> Option<&HashSet<String>> {
1108 self.visible.as_ref()
1109 }
1110 }
1111}
1112
1113impl RedDBRuntime {
1114 /// Resolve the snapshot for the current statement, returning
1115 /// the snapshot itself and (when AS OF is in effect) the
1116 /// resolved xid floor. The floor is the same xid carried inside
1117 /// `Snapshot.xid` for AS OF reads — exposing it separately lets
1118 /// the `ReadFrame` Interface tell "live read" from "historical
1119 /// read" without inferring from `in_progress.is_empty()`.
1120 ///
1121 /// A live read nested inside an already-installed frame inherits that
1122 /// frame's snapshot instead of minting a fresh one — but only when it
1123 /// is genuinely *part of* that statement, which is exactly what
1124 /// [`StatementIdentity::Expr`] means. A CTE prelude, a
1125 /// materialized-view refresh, or an inline graph TVF is one statement
1126 /// and must observe one snapshot; re-minting under autocommit / Read
1127 /// Committed takes a new high-water mark, so the nodes and edges of a
1128 /// single graph TVF could otherwise be materialized under two
1129 /// different snapshots.
1130 ///
1131 /// A nested `Text` statement is a different thing entirely: a driver
1132 /// loop issuing statement after statement through `execute_query`.
1133 /// Those are sequential autocommit statements and each must see its
1134 /// predecessors' commits. Inheriting there froze the loop's view —
1135 /// `apply_batched` (`impl_migrations.rs`) drives `APPLY MIGRATION …
1136 /// BATCH N ROWS` by re-running the body with `LIMIT N` until fewer
1137 /// than `N` rows are affected, so a frozen snapshot re-selected the
1138 /// same rows forever and the migration never converged. Twenty-five
1139 /// rows at `BATCH 7` should finish in four iterations; it hung
1140 /// unbounded instead.
1141 ///
1142 /// `AS OF` still wins over both — it names its own snapshot explicitly.
1143 fn statement_snapshot(
1144 &self,
1145 query: &str,
1146 identity: StatementIdentity<'_>,
1147 ) -> RedDBResult<(Snapshot, Option<Xid>, SnapshotRetentionGuard)> {
1148 match peek_top_level_as_of_with_table(query) {
1149 Some((spec, Some(table))) => {
1150 if !table.starts_with("red_") && !self.vcs_is_versioned(&table)? {
1151 return Err(RedDBError::InvalidConfig(format!(
1152 "AS OF requires a versioned collection — \
1153 `{table}` has not opted in. \
1154 Call vcs.set_versioned(\"{table}\", true) first."
1155 )));
1156 }
1157 let xid = self.vcs_resolve_as_of(spec)?;
1158 let (snapshot, retention) = self.snapshot_manager().retain_snapshot(Snapshot {
1159 xid,
1160 in_progress: HashSet::new(),
1161 });
1162 Ok((snapshot, Some(xid), retention))
1163 }
1164 Some((spec, None)) => {
1165 let xid = self.vcs_resolve_as_of(spec)?;
1166 let (snapshot, retention) = self.snapshot_manager().retain_snapshot(Snapshot {
1167 xid,
1168 in_progress: HashSet::new(),
1169 });
1170 Ok((snapshot, Some(xid), retention))
1171 }
1172 None => match (identity, capture_current_snapshot()) {
1173 (StatementIdentity::Expr(_), Some(outer)) => {
1174 let (snapshot, retention) =
1175 self.snapshot_manager().retain_snapshot(outer.snapshot);
1176 Ok((snapshot, None, retention))
1177 }
1178 _ => {
1179 let (snapshot, retention) = self
1180 .inner
1181 .transaction_state
1182 .retained_current_snapshot(current_connection_id());
1183 Ok((snapshot, None, retention))
1184 }
1185 },
1186 }
1187 }
1188
1189 fn result_cache_safe(&self, conn_id: u64) -> bool {
1190 let has_active_xids = self
1191 .inner
1192 .transaction_state
1193 .snapshot_manager()
1194 .oldest_active_xid()
1195 .is_some();
1196 let in_own_tx = self.inner.transaction_state.in_transaction(conn_id);
1197 !has_active_xids && !in_own_tx
1198 }
1199}
1200
1201/// Whether a result's `engine` tag is one of the graph-analytics TVF
1202/// executors (issue #802). Graph-collection (`louvain(g)`) and inline
1203/// (`louvain(nodes => …, edges => …)`) forms both produce deterministic
1204/// algorithm output that is cached regardless of row count.
1205fn is_graph_tvf_engine(engine: &str) -> bool {
1206 matches!(engine, "runtime-graph-tvf" | "runtime-graph-tvf-inline")
1207}
1208
1209fn result_cache_key(query: &str) -> String {
1210 let tenant = current_tenant().unwrap_or_default();
1211 let auth = current_auth_identity()
1212 .map(|(user, role)| format!("{}|{:?}", user, role))
1213 .unwrap_or_default();
1214 if tenant.is_empty() && auth.is_empty() {
1215 query.to_string()
1216 } else {
1217 format!("{query}\u{001e}{tenant}\u{001e}{auth}")
1218 }
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223 use super::*;
1224 use crate::api::RedDBOptions;
1225 use crate::runtime::impl_core::{
1226 clear_current_auth_identity, clear_current_tenant, set_current_auth_identity,
1227 set_current_tenant,
1228 };
1229 use crate::runtime::RedDBRuntime;
1230
1231 fn fresh_runtime() -> RedDBRuntime {
1232 RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("in-memory runtime")
1233 }
1234
1235 enum HeldFrame {
1236 Ordinary,
1237 Nested,
1238 Historical,
1239 }
1240
1241 fn verify_frame_survives_vacuum(delete: bool, kind: HeldFrame) {
1242 use crate::runtime::mvcc::{clear_current_connection_id, set_current_connection_id};
1243
1244 reset_thread_locals();
1245 clear_current_connection_id();
1246 let runtime = fresh_runtime();
1247 let execute = |connection, sql: &str| {
1248 set_current_connection_id(connection);
1249 let result = runtime.execute_query(sql);
1250 clear_current_connection_id();
1251 result.expect("valid frame retention fixture statement")
1252 };
1253 execute(1, "CREATE TABLE frame_rows (id TEXT, n INTEGER)");
1254 execute(1, "INSERT INTO frame_rows (id, n) VALUES ('row', 1)");
1255 let historical_xid = runtime.current_snapshot().xid;
1256 if matches!(kind, HeldFrame::Historical) {
1257 runtime
1258 .vcs_set_versioned("frame_rows", true)
1259 .expect("enable historical reads");
1260 }
1261 execute(2, "BEGIN");
1262 let query = if matches!(kind, HeldFrame::Historical) {
1263 format!("SELECT id, n FROM frame_rows AS OF SNAPSHOT {historical_xid}")
1264 } else {
1265 "SELECT id, n FROM frame_rows".to_string()
1266 };
1267 let frame = StatementExecutionFrame::build(&runtime, StatementIdentity::Text(&query))
1268 .expect("frame captures its snapshot");
1269 let frame = if matches!(kind, HeldFrame::Nested) {
1270 let guards = frame.install(&runtime);
1271 let expr = QueryExpr::Table(reddb_rql::ast::TableQuery::new("frame_rows"));
1272 let nested = StatementExecutionFrame::build(&runtime, StatementIdentity::Expr(&expr))
1273 .expect("nested frame inherits the protected snapshot");
1274 drop(guards);
1275 drop(frame);
1276 nested
1277 } else {
1278 frame
1279 };
1280 // Pause after capture and before install/scan: GC must also protect
1281 // this gap, rather than starting retention only at TLS installation.
1282 execute(
1283 2,
1284 if delete {
1285 "DELETE FROM frame_rows WHERE id = 'row'"
1286 } else {
1287 "UPDATE frame_rows SET n = 2 WHERE id = 'row'"
1288 },
1289 );
1290 execute(2, "COMMIT");
1291 execute(1, "VACUUM frame_rows");
1292 {
1293 let _guards = frame.install(&runtime);
1294 let records = super::super::record_search::scan_runtime_table_source_records_limited(
1295 &runtime.db(),
1296 "frame_rows",
1297 None,
1298 )
1299 .expect("captured frame scan succeeds after vacuum");
1300 assert_eq!(
1301 records.len(),
1302 1,
1303 "vacuum reclaimed an active frame's visible row"
1304 );
1305 assert_eq!(
1306 records[0]
1307 .get("n")
1308 .expect("selected value exists")
1309 .to_string(),
1310 "1"
1311 );
1312 }
1313 drop(frame);
1314 execute(1, "VACUUM frame_rows");
1315 let physical = runtime
1316 .db()
1317 .store()
1318 .get_collection("frame_rows")
1319 .expect("fixture collection exists")
1320 .query_all(|_| true)
1321 .len();
1322 assert_eq!(
1323 physical,
1324 usize::from(!delete),
1325 "completed frame leaked its retention pin"
1326 );
1327 clear_current_connection_id();
1328 }
1329
1330 #[test]
1331 fn autocommit_frame_retains_update_until_scan_completes() {
1332 verify_frame_survives_vacuum(false, HeldFrame::Ordinary);
1333 }
1334
1335 #[test]
1336 fn autocommit_frame_retains_delete_until_scan_completes() {
1337 verify_frame_survives_vacuum(true, HeldFrame::Ordinary);
1338 }
1339
1340 #[test]
1341 fn nested_frame_retains_history_after_parent_frame_drops() {
1342 verify_frame_survives_vacuum(false, HeldFrame::Nested);
1343 }
1344
1345 #[test]
1346 fn historical_frame_retains_history_until_scan_completes() {
1347 verify_frame_survives_vacuum(true, HeldFrame::Historical);
1348 }
1349
1350 /// Ensure thread-local state from a prior test can't leak into
1351 /// the next one — tests in the same binary share the thread.
1352 fn reset_thread_locals() {
1353 clear_current_tenant();
1354 clear_current_auth_identity();
1355 }
1356
1357 #[test]
1358 fn autocommit_select_takes_live_snapshot() {
1359 reset_thread_locals();
1360 let rt = fresh_runtime();
1361 let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text("SELECT 1"))
1362 .expect("frame builds for SELECT 1");
1363
1364 // The inclusive bound excludes the next unallocated writer xid.
1365 // A pristine manager therefore has a valid empty snapshot at zero.
1366 let f: &dyn ReadFrame = &frame;
1367 assert!(f.as_of_floor().is_none(), "live read has no AS OF floor");
1368 assert_eq!(f.snapshot().xid, rt.snapshot_manager().peek_next_xid() - 1);
1369 assert!(!f.snapshot().sees(rt.snapshot_manager().peek_next_xid(), 0));
1370 }
1371
1372 #[test]
1373 fn frame_captures_identity_and_scope() {
1374 reset_thread_locals();
1375 set_current_tenant("acme".to_string());
1376 set_current_auth_identity("alice".to_string(), Role::Write);
1377
1378 let rt = fresh_runtime();
1379 let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text("SELECT 1"))
1380 .expect("frame builds");
1381 let f: &dyn ReadFrame = &frame;
1382
1383 assert_eq!(f.effective_scope(), Some("acme"));
1384 let id = f.identity().expect("identity captured");
1385 assert_eq!(id.0, "alice");
1386 assert!(matches!(id.1, Role::Write));
1387
1388 // Cache key mixes scope + identity so two callers under
1389 // different tenants never share a cache slot.
1390 assert!(
1391 f.cache_key().contains("acme") && f.cache_key().contains("alice"),
1392 "cache key folds in scope + identity, got {:?}",
1393 f.cache_key()
1394 );
1395
1396 reset_thread_locals();
1397 }
1398
1399 #[test]
1400 fn as_of_rejects_non_versioned_user_collection() {
1401 reset_thread_locals();
1402 let rt = fresh_runtime();
1403
1404 // `not_versioned` is a plain user collection — the frame
1405 // builder must reject AS OF until the caller opts in via
1406 // `vcs.set_versioned`.
1407 let err = match StatementExecutionFrame::build(
1408 &rt,
1409 StatementIdentity::Text("SELECT * FROM not_versioned AS OF COMMIT 'deadbeef'"),
1410 ) {
1411 Err(e) => e,
1412 Ok(_) => panic!("AS OF on non-versioned user collection rejected"),
1413 };
1414
1415 let msg = format!("{err}");
1416 assert!(
1417 msg.contains("AS OF requires a versioned collection"),
1418 "expected AS OF rejection, got: {msg}"
1419 );
1420 }
1421
1422 /// End-to-end proof that the SELECT path consumes a `ReadFrame`.
1423 ///
1424 /// Sets a tenant + identity via the public thread-local API the
1425 /// runtime uses for ambient scope, drives a real `SELECT` through
1426 /// `execute_query`, then inspects the result cache that the SELECT
1427 /// path populates via `frame.cache_key()`. The key only carries
1428 /// the tenant + identity *because* it was built through the frame —
1429 /// reverting the wiring to inline `current_tenant()` /
1430 /// `current_auth_identity()` reads would still pass this test, but
1431 /// dropping the frame entirely (so the SELECT path stopped touching
1432 /// `cache_key`) would break it.
1433 #[test]
1434 fn select_path_routes_through_frame_cache_key() {
1435 reset_thread_locals();
1436 set_current_tenant("acme".to_string());
1437 set_current_auth_identity("alice".to_string(), Role::Read);
1438
1439 let rt = fresh_runtime();
1440 let result = rt
1441 .execute_query("SELECT 1")
1442 .expect("SELECT 1 executes under tenant=acme/identity=alice");
1443 assert_eq!(result.statement_type, "select");
1444
1445 // The textual SELECT path builds a frame and
1446 // writes its result through `frame.cache_key()`. That key folds
1447 // tenant + identity in via `result_cache_key`, so finding "acme"
1448 // and "alice" inside any cached key proves the frame was the
1449 // seam used.
1450 let cache = rt.inner.result_cache.read();
1451 let any_keyed_with_scope = cache
1452 .0
1453 .keys()
1454 .any(|k| k.contains("acme") && k.contains("alice"));
1455 assert!(
1456 any_keyed_with_scope,
1457 "expected at least one result-cache key carrying tenant+identity, \
1458 got keys: {:?}",
1459 cache.0.keys().collect::<Vec<_>>()
1460 );
1461
1462 reset_thread_locals();
1463 }
1464
1465 /// A SELECT that calls a volatile builtin (here:
1466 /// `pg_advisory_unlock`, the volatile token the runtime currently
1467 /// recognises in `query_has_volatile_builtin`) must NOT populate
1468 /// the result cache. Any caller hitting the cache after this would
1469 /// see a stale answer for an inherently-volatile query, so the
1470 /// SELECT path gates writes through `frame.should_cache_result()`.
1471 ///
1472 /// Deletion test: removing `ReadFrame::should_cache_result`, or
1473 /// reverting the SELECT path to skip its safety gate, would let
1474 /// the result cache silently absorb this statement and break the
1475 /// assertion below.
1476 #[test]
1477 fn volatile_select_does_not_populate_result_cache() {
1478 reset_thread_locals();
1479 let rt = fresh_runtime();
1480
1481 // Frame-level invariant: the volatile-builtin signal collapses
1482 // `should_cache_result` to false even for an autocommit /
1483 // out-of-tx connection.
1484 let frame = StatementExecutionFrame::build(
1485 &rt,
1486 StatementIdentity::Text("SELECT pg_advisory_unlock(1)"),
1487 )
1488 .expect("frame");
1489 let f: &dyn ReadFrame = &frame;
1490 assert!(
1491 !f.should_cache_result(),
1492 "volatile builtin must disable result-cache safety"
1493 );
1494
1495 // End-to-end: drive the volatile SELECT through `execute_query`
1496 // and confirm no entry was stamped under its cache key. Other
1497 // entries from prior tests sharing the binary may exist, so we
1498 // assert specifically on this query's key.
1499 let _ = rt
1500 .execute_query("SELECT pg_advisory_unlock(1)")
1501 .expect("volatile SELECT executes");
1502 let cache = rt.inner.result_cache.read();
1503 let key = result_cache_key("SELECT pg_advisory_unlock(1)");
1504 assert!(
1505 !cache.0.contains_key(&key),
1506 "volatile SELECT must not populate result cache, found key {key:?} in {:?}",
1507 cache.0.keys().collect::<Vec<_>>()
1508 );
1509
1510 reset_thread_locals();
1511 }
1512
1513 #[test]
1514 fn blob_cache_backend_populates_blob_path_without_legacy_write() {
1515 reset_thread_locals();
1516 let rt = fresh_runtime();
1517 rt.inner
1518 .db
1519 .store()
1520 .set_config_tree("runtime.result_cache.backend", &crate::json!("blob_cache"));
1521
1522 let result = rt.execute_query("SELECT 1").expect("SELECT 1 executes");
1523 assert_eq!(result.statement_type, "select");
1524
1525 let key = result_cache_key("SELECT 1");
1526 assert!(
1527 rt.inner
1528 .result_blob_cache
1529 .get("runtime.result_cache", &key)
1530 .is_some(),
1531 "blob backend should stamp the Blob Cache path"
1532 );
1533 assert!(rt.inner.result_blob_entries.read().0.contains_key(&key));
1534 assert!(
1535 !rt.inner.result_cache.read().0.contains_key(&key),
1536 "blob backend should not write the legacy map"
1537 );
1538 }
1539
1540 #[test]
1541 fn blob_cache_backend_keeps_volatile_select_out_of_blob_path() {
1542 reset_thread_locals();
1543 let rt = fresh_runtime();
1544 rt.inner
1545 .db
1546 .store()
1547 .set_config_tree("runtime.result_cache.backend", &crate::json!("blob_cache"));
1548
1549 let _ = rt
1550 .execute_query("SELECT pg_advisory_unlock(1)")
1551 .expect("volatile SELECT executes");
1552 let key = result_cache_key("SELECT pg_advisory_unlock(1)");
1553 assert!(
1554 rt.inner
1555 .result_blob_cache
1556 .get("runtime.result_cache", &key)
1557 .is_none(),
1558 "volatile SELECT must not populate blob result cache"
1559 );
1560 assert!(!rt.inner.result_blob_entries.read().0.contains_key(&key));
1561 }
1562
1563 #[test]
1564 fn shadow_backend_dual_writes_and_reports_no_divergence_on_equal_results() {
1565 reset_thread_locals();
1566 let rt = fresh_runtime();
1567 rt.inner
1568 .db
1569 .store()
1570 .set_config_tree("runtime.result_cache.backend", &crate::json!("shadow"));
1571
1572 let first = rt.execute_query("SELECT 1").expect("first SELECT");
1573 let second = rt.execute_query("SELECT 1").expect("cached SELECT");
1574 assert_eq!(first.result.len(), second.result.len());
1575
1576 let key = result_cache_key("SELECT 1");
1577 assert!(rt.inner.result_cache.read().0.contains_key(&key));
1578 assert!(rt.inner.result_blob_entries.read().0.contains_key(&key));
1579 assert_eq!(rt.result_cache_shadow_divergences(), 0);
1580 assert_eq!(
1581 crate::runtime::METRIC_CACHE_SHADOW_DIVERGENCE_TOTAL,
1582 "cache_shadow_divergence_total"
1583 );
1584 }
1585
1586 #[test]
1587 fn as_of_on_red_collection_records_floor() {
1588 reset_thread_locals();
1589 let rt = fresh_runtime();
1590
1591 // `red_*` collections always allow AS OF. The frame should
1592 // resolve to a concrete xid and surface it via the Interface.
1593 let frame = StatementExecutionFrame::build(
1594 &rt,
1595 StatementIdentity::Text("SELECT * FROM red_commits AS OF SNAPSHOT 1"),
1596 )
1597 .expect("AS OF SNAPSHOT 1 on red_commits resolves");
1598
1599 let f: &dyn ReadFrame = &frame;
1600 assert_eq!(
1601 f.as_of_floor(),
1602 Some(1),
1603 "AS OF SNAPSHOT 1 records xid=1 as the floor"
1604 );
1605 assert_eq!(f.snapshot().xid, 1);
1606 assert!(
1607 f.snapshot().in_progress.is_empty(),
1608 "historical reads have no in-progress set"
1609 );
1610 }
1611
1612 /// The frame classifies common SQL prefixes into the coarse
1613 /// `Privilege` / `LockIntent` buckets at build time. This test
1614 /// pins the mapping so a regression that silently re-routes
1615 /// (e.g. INSERT classified as Read) surfaces here, not at a
1616 /// downstream privilege gate.
1617 #[test]
1618 fn frame_classifies_privilege_and_lock_intent_from_prefix() {
1619 reset_thread_locals();
1620 let rt = fresh_runtime();
1621
1622 let cases = [
1623 ("SELECT 1", Privilege::Read, LockIntent::Shared),
1624 ("LIST KV settings", Privilege::Read, LockIntent::Shared),
1625 (
1626 "KV GET settings.feature",
1627 Privilege::Read,
1628 LockIntent::Shared,
1629 ),
1630 ("VAULT LIST secrets", Privilege::Read, LockIntent::Shared),
1631 (
1632 "INSERT INTO t (id) VALUES (1)",
1633 Privilege::Write,
1634 LockIntent::Exclusive,
1635 ),
1636 (
1637 "KV PUT settings.feature = 'on'",
1638 Privilege::Write,
1639 LockIntent::Exclusive,
1640 ),
1641 (
1642 "VAULT PUT secrets.api = 'x'",
1643 Privilege::Write,
1644 LockIntent::Exclusive,
1645 ),
1646 (
1647 "UPDATE t SET x = 1 WHERE id = 1",
1648 Privilege::Write,
1649 LockIntent::Exclusive,
1650 ),
1651 (
1652 "DELETE FROM t WHERE id = 1",
1653 Privilege::Write,
1654 LockIntent::Exclusive,
1655 ),
1656 (
1657 "CREATE TABLE foo (id INT)",
1658 Privilege::Write,
1659 LockIntent::Exclusive,
1660 ),
1661 ("BEGIN", Privilege::None, LockIntent::None),
1662 ("COMMIT", Privilege::None, LockIntent::None),
1663 ("SET timezone = 'UTC'", Privilege::None, LockIntent::None),
1664 ];
1665
1666 for (q, want_priv, want_lock) in cases {
1667 let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text(q))
1668 .unwrap_or_else(|e| panic!("frame builds for {q:?}: {e}"));
1669 let f: &dyn ReadFrame = &frame;
1670 assert_eq!(f.required_privilege(), want_priv, "privilege for {q:?}");
1671 assert_eq!(f.lock_intent(), want_lock, "lock intent for {q:?}");
1672 }
1673 }
1674
1675 /// Deletion-test for `ReadFrame::required_privilege`: a SELECT
1676 /// driven through `execute_query` under an identity whose role
1677 /// doesn't satisfy the frame's coarse `Read` privilege gets
1678 /// denied with the frame's signal.
1679 ///
1680 /// We test the gate by classifying an INSERT (which the frame
1681 /// reports as `Privilege::Write`) under `Role::Read` — the only
1682 /// pair the legacy fallback would also reject, but here the
1683 /// rejection comes through `frame.check_query_privilege` BEFORE
1684 /// the parsed-expression walker runs. Removing
1685 /// `required_privilege` (or the `is_satisfied_by` consult inside
1686 /// `check_query_privilege`) would force the deny path back to the
1687 /// inline `RedDBRuntime::check_query_privilege` walker — but the
1688 /// auth_store gate up there is bypassed when no auth_store is
1689 /// wired (embedded test mode), so this test would FLIP from
1690 /// denied to permitted and break the assertion below.
1691 #[test]
1692 fn insert_under_read_role_denied_via_frame_privilege() {
1693 reset_thread_locals();
1694 set_current_auth_identity("alice".to_string(), Role::Read);
1695
1696 let rt = fresh_runtime();
1697 // Bypass parser by reaching into the frame directly: the
1698 // frame derives privilege from the SQL prefix without
1699 // needing an auth_store wired up. Driving end-to-end via
1700 // `execute_query` would also reject (no table `t`), but for
1701 // a different reason — we want to pin the privilege seam.
1702 let frame = StatementExecutionFrame::build(
1703 &rt,
1704 StatementIdentity::Text("INSERT INTO t (id) VALUES (1)"),
1705 )
1706 .expect("frame builds for INSERT");
1707 let f: &dyn ReadFrame = &frame;
1708 assert_eq!(
1709 f.required_privilege(),
1710 Privilege::Write,
1711 "INSERT classified as Write"
1712 );
1713 let id = f.identity().expect("identity captured");
1714 assert!(
1715 !f.required_privilege().is_satisfied_by(id.1),
1716 "Role::Read does not satisfy Privilege::Write — frame must deny"
1717 );
1718
1719 // End-to-end: the frame's `check_query_privilege` sees the
1720 // (Read role, Write privilege) mismatch and denies before
1721 // dispatch. We drive a synthetic `QueryExpr::Table` because
1722 // the SELECT/INSERT parser would happen to also fail, and we
1723 // want the failure to come from the privilege seam.
1724 use reddb_rql::ast::{QueryExpr, TableQuery};
1725 let expr = QueryExpr::Table(TableQuery::new("t"));
1726 let err = frame
1727 .check_query_privilege(&rt, &expr)
1728 .expect_err("denied via frame's coarse privilege gate");
1729 let msg = format!("{err}");
1730 assert!(
1731 msg.contains("permission denied") && msg.contains("Write"),
1732 "expected frame-level Write deny, got: {msg}"
1733 );
1734
1735 reset_thread_locals();
1736 }
1737
1738 /// End-to-end proof that the frame-owned row-buffer arena (#885) is
1739 /// wired into the SELECT path and produces observable results
1740 /// byte-identical to the per-request-allocation baseline.
1741 ///
1742 /// A table with more rows than the streaming high-water mark
1743 /// (`DEFAULT_HIGH_WATER_MARK`) forces the `execute_runtime_table_query_in`
1744 /// path to assemble many chunks, each leasing/recycling the frame
1745 /// arena's single chunk buffer. Driving it through `execute_query`
1746 /// (which builds a `StatementExecutionFrame` and lends its arena)
1747 /// must return every inserted row, in order — exactly what the
1748 /// allocate-per-chunk path returned. A bug in the arena wiring
1749 /// (dropped rows, bled rows, mis-ordering) would surface here.
1750 #[test]
1751 fn large_select_through_frame_arena_returns_all_rows_in_order() {
1752 reset_thread_locals();
1753 let rt = fresh_runtime();
1754 rt.execute_query("CREATE TABLE big (id INT)")
1755 .expect("create table");
1756
1757 // > DEFAULT_HIGH_WATER_MARK (1024) rows so the streaming channel
1758 // spans multiple chunks and the arena buffer is reused.
1759 const N: usize = 2_500;
1760 for start in (0..N).step_by(250) {
1761 let end = (start + 250).min(N);
1762 let values = (start..end)
1763 .map(|i| format!("({i})"))
1764 .collect::<Vec<_>>()
1765 .join(", ");
1766 rt.execute_query(&format!("INSERT INTO big (id) VALUES {values}"))
1767 .unwrap_or_else(|err| panic!("insert rows {start}..{end}: {err:?}"));
1768 }
1769
1770 let result = rt
1771 .execute_query("SELECT id FROM big ORDER BY id")
1772 .expect("large SELECT executes through the frame arena path");
1773 assert_eq!(result.statement_type, "select");
1774 assert_eq!(
1775 result.result.records.len(),
1776 N,
1777 "every inserted row streams back through the arena-backed channel"
1778 );
1779 for (i, record) in result.result.records.iter().enumerate() {
1780 assert_eq!(
1781 record.get("id"),
1782 Some(&reddb_types::Value::Integer(i as i64)),
1783 "row {i} is byte-identical to the per-request-allocation baseline"
1784 );
1785 }
1786
1787 reset_thread_locals();
1788 }
1789
1790 /// Transport adapters may decode their wire-specific parameter value
1791 /// shapes, but SQL parsing/binding must stay behind the runtime's
1792 /// statement entrypoint. This pins the deeper seam introduced for
1793 /// parameterized query execution. HTTP, JSON-RPC, RedWire, and gRPC hand
1794 /// a `QueryRequest` to the Request module, as does PG wire through the
1795 /// shared prepared registry. All paths install a real
1796 /// `StatementExecutionFrame` before dispatch.
1797 ///
1798 /// The Request transports reach the runtime entrypoint one layer deeper:
1799 /// `QueryRequestExecutor` calls `execute_query_with_params` on their
1800 /// behalf. Their delegation target is the Request seam, so that is what
1801 /// is pinned here — the "no binding in the adapter" half is unchanged
1802 /// for all.
1803 #[test]
1804 fn parameterized_transport_adapters_delegate_binding_to_runtime() {
1805 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
1806 let adapters = [
1807 ("src/server/handlers_query.rs", "QueryRequestExecutor"),
1808 ("src/rpc_stdio.rs", "QueryRequestExecutor"),
1809 ("src/wire/redwire/session.rs", "QueryRequestExecutor"),
1810 ("src/wire/postgres/server.rs", "QueryRequestExecutor"),
1811 ("src/grpc.rs", "QueryRequestExecutor"),
1812 ];
1813
1814 for (relative, delegation) in adapters {
1815 let path = manifest_dir.join(relative);
1816 let text = std::fs::read_to_string(&path)
1817 .unwrap_or_else(|err| panic!("read {}: {err}", path.display()));
1818 assert!(
1819 text.contains(delegation),
1820 "{relative} should delegate parameterized query execution through {delegation}"
1821 );
1822 assert!(
1823 !text.contains("user_params::bind"),
1824 "{relative} must not bind SQL params in the transport adapter"
1825 );
1826 }
1827
1828 // The Request module is the only place RedWire's delegation may
1829 // bottom out: it must still reach the runtime entrypoint itself.
1830 let seam = std::fs::read_to_string(manifest_dir.join("src/runtime/query_request.rs"))
1831 .expect("read src/runtime/query_request.rs");
1832 assert!(
1833 seam.contains("execute_query_with_params"),
1834 "the Request module must execute text SQL through the runtime entrypoint"
1835 );
1836 }
1837
1838 /// Deletion-test for `ReadFrame::lock_intent`: a transaction
1839 /// control statement carries `LockIntent::None` and the
1840 /// `acquire_intent_locks` path returns `None` without consulting
1841 /// `intent_lock_modes_for`. Removing the method (or its consult
1842 /// site in `acquire_intent_locks`) would force the lock-mode
1843 /// helper to walk a fabricated parsed expression to reach the
1844 /// same conclusion — but the assertion that no guard is allocated
1845 /// for a `BEGIN` frame would still hold, so we additionally pin
1846 /// the classifier mapping above to make the deletion observable.
1847 #[test]
1848 fn control_statement_skips_intent_locks_via_frame() {
1849 reset_thread_locals();
1850 let rt = fresh_runtime();
1851
1852 let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Text("BEGIN"))
1853 .expect("frame builds for BEGIN");
1854 let f: &dyn ReadFrame = &frame;
1855 assert_eq!(f.lock_intent(), LockIntent::None);
1856
1857 // Drive `acquire_intent_locks` against a fabricated SELECT
1858 // expression that WOULD normally yield `(IS, IS)`; the frame's
1859 // `lock_intent() == None` short-circuit must still suppress
1860 // the guard.
1861 use reddb_rql::ast::{QueryExpr, TableQuery};
1862 let expr = QueryExpr::Table(TableQuery::new("t"));
1863 let guard = frame
1864 .acquire_intent_locks(&rt, &expr)
1865 .expect("no lock is attempted");
1866 assert!(
1867 guard.is_none(),
1868 "BEGIN frame's lock_intent=None must short-circuit lock acquisition"
1869 );
1870 }
1871
1872 /// Nested sub-execution inside one statement (CTE prelude,
1873 /// materialized-view refresh, inline graph TVF) must read the
1874 /// enclosing statement's snapshot. Autocommit is the case that used
1875 /// to break: `current_snapshot()` mints a fresh high-water mark per
1876 /// call, so an inline graph TVF could materialize its nodes and its
1877 /// edges under two different snapshots within one statement.
1878 #[test]
1879 fn nested_frame_inherits_outer_statement_snapshot() {
1880 reset_thread_locals();
1881 let rt = fresh_runtime();
1882 rt.execute_query("CREATE TABLE nested_snap (id INT)")
1883 .expect("create table");
1884
1885 let outer = StatementExecutionFrame::build(
1886 &rt,
1887 StatementIdentity::Text("SELECT * FROM nested_snap"),
1888 )
1889 .expect("outer frame builds");
1890 let _outer_guards = outer.install(&rt);
1891
1892 // A write between the two builds moves the high-water mark, so a
1893 // re-minting nested frame would land on a different xid.
1894 rt.execute_query("INSERT INTO nested_snap (id) VALUES (1)")
1895 .expect("concurrent insert");
1896
1897 let expr = QueryExpr::Table(reddb_rql::ast::TableQuery::new("nested_snap"));
1898 let nested = StatementExecutionFrame::build(&rt, StatementIdentity::Expr(&expr))
1899 .expect("nested frame builds");
1900
1901 assert_eq!(
1902 ReadFrame::snapshot(&nested).xid,
1903 ReadFrame::snapshot(&outer).xid,
1904 "nested execution must inherit the outer statement's snapshot"
1905 );
1906 }
1907
1908 /// The other side of the same rule, and the one that matters more:
1909 /// a nested statement built from *text* is a statement in its own
1910 /// right, not part of the enclosing one, so it must mint a fresh
1911 /// snapshot and observe everything committed before it.
1912 ///
1913 /// `apply_batched` (`impl_migrations.rs`) is the load-bearing caller:
1914 /// it drives `APPLY MIGRATION … BATCH N ROWS` by re-running the body
1915 /// with `LIMIT N` through `execute_query` until fewer than `N` rows
1916 /// are affected. Inheriting the outer `APPLY MIGRATION` statement's
1917 /// snapshot froze that loop's view, so every iteration re-selected
1918 /// the same rows and the migration never converged — 25 rows at
1919 /// `BATCH 7` hung instead of finishing in four iterations.
1920 #[test]
1921 fn nested_text_statement_sees_commits_made_since_the_outer_frame() {
1922 reset_thread_locals();
1923 let rt = fresh_runtime();
1924 rt.execute_query("CREATE TABLE nested_text_snap (id INT)")
1925 .expect("create table");
1926
1927 let outer = StatementExecutionFrame::build(
1928 &rt,
1929 StatementIdentity::Text("SELECT * FROM nested_text_snap"),
1930 )
1931 .expect("outer frame builds");
1932 let _outer_guards = outer.install(&rt);
1933
1934 rt.execute_query("INSERT INTO nested_text_snap (id) VALUES (1)")
1935 .expect("concurrent insert");
1936
1937 let nested = StatementExecutionFrame::build(
1938 &rt,
1939 StatementIdentity::Text("SELECT * FROM nested_text_snap"),
1940 )
1941 .expect("nested frame builds");
1942
1943 assert_ne!(
1944 ReadFrame::snapshot(&nested).xid,
1945 ReadFrame::snapshot(&outer).xid,
1946 "a nested text statement must mint a fresh snapshot, not inherit \
1947 the enclosing statement's frozen view"
1948 );
1949 }
1950
1951 /// A frame with no statement text must not silently answer an
1952 /// `AS OF` read with live data. `TableQuery::as_of` is consumed
1953 /// nowhere in the runtime or scan path, so the only correct
1954 /// behaviours are "resolve it" (text path) or "refuse".
1955 #[test]
1956 fn expr_frame_rejects_as_of_instead_of_reading_live() {
1957 reset_thread_locals();
1958 let rt = fresh_runtime();
1959
1960 let mut table = reddb_rql::ast::TableQuery::new("red_commits");
1961 table.as_of = Some(reddb_rql::ast::AsOfClause::Snapshot(1));
1962 let expr = QueryExpr::Table(table);
1963
1964 let err = match StatementExecutionFrame::build(&rt, StatementIdentity::Expr(&expr)) {
1965 Err(err) => err,
1966 Ok(_) => panic!("AS OF without statement text must be refused, not read live"),
1967 };
1968 let msg = format!("{err}");
1969 assert!(
1970 msg.contains("AS OF cannot be resolved from a pre-parsed expression"),
1971 "expected a loud AS OF refusal, got: {msg}"
1972 );
1973 }
1974
1975 /// The expression path has no stable text key, so it must opt out of
1976 /// the result cache entirely. A kind label (`"table"`, `"insert"`)
1977 /// would key every statement of the same kind to one cache slot.
1978 #[test]
1979 fn expr_frame_opts_out_of_the_result_cache() {
1980 reset_thread_locals();
1981 let rt = fresh_runtime();
1982
1983 let expr = QueryExpr::Table(reddb_rql::ast::TableQuery::new("t"));
1984 let frame = StatementExecutionFrame::build(&rt, StatementIdentity::Expr(&expr))
1985 .expect("expr frame builds");
1986
1987 assert!(frame.cache_key().is_empty(), "expr frame has no cache key");
1988 assert!(
1989 !frame.can_read_result_cache(),
1990 "expr frame must never serve from the result cache"
1991 );
1992 }
1993}