Skip to main content

radixdb_executor/
semantic_cache.rs

1// Copyright 2026 RadixDB Contributors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Semantic Query Caching with Predicate Subsumption
16//!
17//! This module implements intelligent query result caching that goes beyond simple
18//! string matching. It detects when a new query's results can be computed by filtering
19//! cached results from a previous query, without re-executing against storage.
20//!
21//! # Key Insight
22//!
23//! If Query A's predicate P_A is LESS RESTRICTIVE than Query B's predicate P_B,
24//! then B's results are a SUBSET of A's results:
25//!
26//! ```text
27//! Query A: SELECT * FROM orders WHERE amount > 100  (cached: 500 rows)
28//! Query B: SELECT * FROM orders WHERE amount > 150  (new query)
29//!
30//! Since amount > 150 is STRICTER than amount > 100,
31//! B's results ⊆ A's results
32//!
33//! Instead of scanning storage: Filter A's cached 500 rows → B's results
34//! ```
35//!
36//! # Supported Subsumption Patterns
37//!
38//! 1. **Numeric Range Tightening:**
39//!    - `col > 100` (cached) → `col > 150` (new): Filter cached
40//!    - `col < 500` (cached) → `col < 300` (new): Filter cached
41//!    - `col BETWEEN 100 AND 500` (cached) → `col BETWEEN 200 AND 400` (new): Filter cached
42//!
43//! 2. **Equality Subset:**
44//!    - `col IN (1,2,3,4,5)` (cached) → `col IN (2,3)` (new): Filter cached
45//!
46//! 3. **AND Conjunction Strengthening:**
47//!    - `A` (cached) → `A AND B` (new): Filter cached
48//!    - `A AND B` (cached) → `A AND B AND C` (new): Filter cached
49//!
50//! # Not Supported (Triggers Re-execution)
51//!
52//! - OR predicates (may expand result set)
53//! - Different tables
54//! - Different column sets
55//! - Non-comparable predicates (LIKE, function calls)
56//!
57//! # Transaction Isolation Considerations
58//!
59//! **Important:** The semantic cache is currently global and does not account for
60//! MVCC transaction isolation. This means:
61//!
62//! - Cache entries are shared across all transactions
63//! - A transaction might see cached results from another transaction's read
64//! - Cache invalidation on DML ensures committed changes are reflected
65//!
66//! This is safe for:
67//! - Single-connection usage
68//! - Read-only workloads
69//! - Scenarios where eventual consistency is acceptable
70//!
71//! For strict serializable isolation with concurrent writes, consider:
72//! - Disabling the cache during critical transactions
73//! - Using explicit cache invalidation between operations
74//!
75//! Future enhancement: Per-transaction cache scoping with timestamp-based invalidation
76
77use radixdb_core::time_compat::Instant;
78use rustc_hash::FxHasher;
79use std::borrow::Cow;
80use std::hash::{Hash, Hasher};
81use std::sync::atomic::{AtomicU64, Ordering};
82use std::sync::RwLock;
83use std::time::Duration;
84
85use radixdb_core::{CompactArc, StringMap};
86use radixdb_core::{Result, Row, Value, ValueSet};
87
88/// Convert to lowercase without allocation if already lowercase.
89/// Returns Cow::Borrowed for already-lowercase strings (zero allocation).
90#[inline]
91fn to_lowercase_cow(s: &str) -> Cow<'_, str> {
92    if s.bytes().all(|b| !b.is_ascii_uppercase()) {
93        Cow::Borrowed(s)
94    } else {
95        Cow::Owned(s.to_lowercase())
96    }
97}
98use radixdb_functions::FunctionRegistry;
99use radixdb_sql::ast::{Expression, InfixOperator};
100
101use super::expression::ExpressionEval;
102use super::utils::{expressions_equivalent, extract_and_conditions, extract_column_name};
103
104/// Maximum number of cached query results per table+column combination.
105///
106/// This limits memory usage by bounding how many distinct query patterns
107/// can be cached for each table. When this limit is reached, the least
108/// recently used (LRU) entries are evicted.
109///
110/// Default: 64 entries
111pub const DEFAULT_SEMANTIC_CACHE_SIZE: usize = 64;
112
113/// Time-to-live for cached results in seconds.
114///
115/// Cached query results are automatically evicted after this duration
116/// to prevent serving stale data. This provides a safety net beyond
117/// explicit invalidation on DML operations.
118///
119/// Default: 300 seconds (5 minutes)
120pub const DEFAULT_CACHE_TTL_SECS: u64 = 300;
121
122/// Maximum number of rows to cache per query result.
123///
124/// Query results exceeding this threshold are not cached to prevent
125/// memory bloat. This is particularly important for queries that may
126/// return large result sets.
127///
128/// Default: 100,000 rows
129pub const DEFAULT_MAX_CACHED_ROWS: usize = 100_000;
130
131/// Global maximum total rows across all cache entries.
132///
133/// This prevents unbounded memory growth when many tables/column patterns
134/// are cached. When exceeded, entries are evicted using LRU across all
135/// tables until under the limit.
136///
137/// Default: 1,000,000 rows (approximately 100-500MB depending on row size)
138pub const DEFAULT_MAX_GLOBAL_CACHED_ROWS: usize = 1_000_000;
139
140/// Maximum estimated retained bytes across all semantic-cache entries.
141pub const DEFAULT_MAX_GLOBAL_CACHED_BYTES: usize = 256 * 1024 * 1024;
142
143/// Fingerprint for a cacheable query pattern
144#[derive(Debug, Clone, PartialEq, Eq, Hash)]
145pub struct QueryFingerprint {
146    /// Table name (lowercase)
147    pub table_name: String,
148    /// Selected columns (sorted for comparison)
149    pub columns: Vec<String>,
150    /// Hash of the predicate structure (not values)
151    pub predicate_structure_hash: u64,
152}
153
154impl QueryFingerprint {
155    /// Create a fingerprint for a simple table scan
156    pub fn new(table_name: &str, columns: Vec<String>) -> Self {
157        Self {
158            table_name: table_name.to_lowercase(),
159            columns,
160            predicate_structure_hash: 0,
161        }
162    }
163
164    /// Create a fingerprint with predicate structure
165    pub fn with_predicate(table_name: &str, columns: Vec<String>, predicate: &Expression) -> Self {
166        Self {
167            table_name: table_name.to_lowercase(),
168            columns,
169            predicate_structure_hash: hash_predicate_structure(predicate),
170        }
171    }
172}
173
174/// Cached query result with metadata
175#[derive(Debug, Clone)]
176pub struct CachedResult {
177    /// The fingerprint identifying this query pattern
178    pub fingerprint: QueryFingerprint,
179    /// Column names in order
180    pub column_names: Vec<String>,
181    /// Cached rows (Arc for zero-copy sharing on cache hits)
182    pub rows: CompactArc<Vec<Row>>,
183    /// The original WHERE predicate (for subsumption checking)
184    pub predicate: Option<Expression>,
185    /// When this entry was cached
186    pub cached_at: Instant,
187    /// Last access time (for LRU eviction)
188    pub last_accessed: Instant,
189    /// Access count
190    pub access_count: u64,
191    /// Conservative estimate of memory retained by the cached rows.
192    pub estimated_bytes: usize,
193}
194
195impl CachedResult {
196    /// Create a new cached result
197    pub fn new(
198        fingerprint: QueryFingerprint,
199        column_names: Vec<String>,
200        rows: Vec<Row>,
201        predicate: Option<Expression>,
202    ) -> Self {
203        let now = Instant::now();
204        let estimated_bytes = estimate_cached_rows_bytes(&rows);
205        Self {
206            fingerprint,
207            column_names,
208            rows: CompactArc::new(rows), // Wrap in CompactArc for zero-copy sharing
209            predicate,
210            cached_at: now,
211            last_accessed: now,
212            access_count: 1,
213            estimated_bytes,
214        }
215    }
216
217    /// Create a new cached result with pre-wrapped Arc (avoids clone)
218    ///
219    /// Use this when the caller already has a `CompactArc<Vec<Row>>` to avoid
220    /// an extra allocation and copy.
221    pub fn new_with_arc(
222        fingerprint: QueryFingerprint,
223        column_names: Vec<String>,
224        rows: CompactArc<Vec<Row>>,
225        predicate: Option<Expression>,
226    ) -> Self {
227        let now = Instant::now();
228        let estimated_bytes = estimate_cached_rows_bytes(&rows);
229        Self {
230            fingerprint,
231            column_names,
232            rows, // Use Arc directly - no additional wrapping
233            predicate,
234            cached_at: now,
235            last_accessed: now,
236            access_count: 1,
237            estimated_bytes,
238        }
239    }
240
241    /// Check if this cached result has expired
242    pub fn is_expired(&self, ttl: Duration) -> bool {
243        self.cached_at.elapsed() > ttl
244    }
245
246    /// Check if this cached result has expired, using a pre-computed `now` instant.
247    /// Avoids repeated `Instant::now()` calls (each is a syscall on macOS).
248    #[inline]
249    pub fn is_expired_at(&self, ttl: Duration, now: Instant) -> bool {
250        now.duration_since(self.cached_at) > ttl
251    }
252
253    /// Record an access to this cached result
254    pub fn record_access(&mut self) {
255        self.last_accessed = Instant::now();
256        self.access_count += 1;
257    }
258}
259
260/// Subsumption relationship between predicates
261#[derive(Debug, Clone)]
262pub enum SubsumptionResult {
263    /// New predicate is stricter - can filter cached results
264    Subsumed {
265        /// The additional filter to apply to cached rows
266        filter: Box<Expression>,
267    },
268    /// Predicates are identical - use cached results directly
269    Identical,
270    /// Cannot determine subsumption - must re-execute
271    NoSubsumption,
272}
273
274/// Semantic Query Cache
275///
276/// Intelligently caches query results and detects when new queries
277/// can be answered by filtering cached results.
278pub struct SemanticCache {
279    /// Cached results: table_name -> (column_key -> `Vec<CachedResult>`)
280    /// Nested structure enables O(1) table invalidation
281    cache: RwLock<StringMap<StringMap<Vec<CachedResult>>>>,
282    /// Maximum cache size per table+column combination
283    max_size: usize,
284    /// Cache TTL
285    ttl: Duration,
286    /// Maximum rows to cache per query
287    max_rows: usize,
288    /// Maximum total rows across all cache entries (prevents unbounded growth)
289    max_global_rows: usize,
290    /// Maximum estimated retained bytes across all entries.
291    max_global_bytes: usize,
292    /// Current total row count across all entries (for global limit enforcement)
293    global_row_count: AtomicU64,
294    /// Current estimated retained bytes across all entries.
295    global_byte_count: AtomicU64,
296    /// Changes whenever data/schema invalidation makes in-flight results stale.
297    generation: AtomicU64,
298    /// Statistics (lock-free atomics)
299    stats: SemanticCacheStats,
300}
301
302/// Statistics for the semantic cache (lock-free with atomics)
303#[derive(Debug, Default)]
304pub struct SemanticCacheStats {
305    /// Total cache hits (exact or subsumption)
306    pub hits: AtomicU64,
307    /// Exact match hits
308    pub exact_hits: AtomicU64,
309    /// Subsumption hits (filtered from cached)
310    pub subsumption_hits: AtomicU64,
311    /// Cache misses
312    pub misses: AtomicU64,
313    /// Entries evicted due to TTL
314    pub ttl_evictions: AtomicU64,
315    /// Entries evicted due to size limit
316    pub size_evictions: AtomicU64,
317    /// Lock acquisition failures (poisoned lock from panics)
318    /// If this is non-zero, a previous operation panicked while holding the lock
319    pub lock_failures: AtomicU64,
320}
321
322/// Snapshot of cache statistics (plain values for reading)
323#[derive(Debug, Clone, Default)]
324pub struct SemanticCacheStatsSnapshot {
325    /// Total cache hits (exact or subsumption)
326    pub hits: u64,
327    /// Exact match hits
328    pub exact_hits: u64,
329    /// Subsumption hits (filtered from cached)
330    pub subsumption_hits: u64,
331    /// Cache misses
332    pub misses: u64,
333    /// Entries evicted due to TTL
334    pub ttl_evictions: u64,
335    /// Entries evicted due to size limit
336    pub size_evictions: u64,
337    /// Lock acquisition failures (indicates previous panic)
338    pub lock_failures: u64,
339}
340
341/// Result of a cache lookup
342#[derive(Debug)]
343pub enum CacheLookupResult {
344    /// Exact match found (Arc for zero-copy sharing)
345    ExactHit(CompactArc<Vec<Row>>),
346    /// Subsumption match found - apply filter to get results
347    SubsumptionHit {
348        /// Rows to filter (Arc for zero-copy sharing)
349        rows: CompactArc<Vec<Row>>,
350        /// Filter predicate to apply
351        filter: Box<Expression>,
352        /// Column names for evaluation context
353        columns: Vec<String>,
354    },
355    /// No match found
356    Miss,
357}
358
359fn estimate_cached_rows_bytes(rows: &[Row]) -> usize {
360    rows.iter().fold(std::mem::size_of_val(rows), |total, row| {
361        row.iter().fold(
362            total.saturating_add(std::mem::size_of::<Row>()),
363            |row_total, value| {
364                let payload = match value {
365                    Value::Text(text) => text.len(),
366                    Value::Extension(bytes) => bytes.len(),
367                    _ => 0,
368                };
369                row_total
370                    .saturating_add(std::mem::size_of::<Value>())
371                    .saturating_add(payload)
372            },
373        )
374    })
375}
376
377impl SemanticCache {
378    /// Create a new semantic cache with default settings
379    pub fn new() -> Self {
380        Self::with_config(
381            DEFAULT_SEMANTIC_CACHE_SIZE,
382            Duration::from_secs(DEFAULT_CACHE_TTL_SECS),
383            DEFAULT_MAX_CACHED_ROWS,
384            DEFAULT_MAX_GLOBAL_CACHED_ROWS,
385        )
386    }
387
388    /// Create a semantic cache with custom configuration
389    pub fn with_config(
390        max_size: usize,
391        ttl: Duration,
392        max_rows: usize,
393        max_global_rows: usize,
394    ) -> Self {
395        Self::with_config_and_byte_limit(
396            max_size,
397            ttl,
398            max_rows,
399            max_global_rows,
400            DEFAULT_MAX_GLOBAL_CACHED_BYTES,
401        )
402    }
403
404    /// Create a semantic cache with explicit row and byte budgets.
405    pub fn with_config_and_byte_limit(
406        max_size: usize,
407        ttl: Duration,
408        max_rows: usize,
409        max_global_rows: usize,
410        max_global_bytes: usize,
411    ) -> Self {
412        Self {
413            cache: RwLock::new(StringMap::new()),
414            max_size,
415            ttl,
416            max_rows,
417            max_global_rows,
418            max_global_bytes,
419            global_row_count: AtomicU64::new(0),
420            global_byte_count: AtomicU64::new(0),
421            generation: AtomicU64::new(0),
422            stats: SemanticCacheStats::default(),
423        }
424    }
425
426    /// Look up a query in the cache
427    ///
428    /// Returns:
429    /// - `ExactHit` if an identical query is cached
430    /// - `SubsumptionHit` if a broader query is cached and can be filtered
431    /// - `Miss` if no usable cache entry exists
432    pub fn lookup(
433        &self,
434        table_name: &str,
435        columns: &[String],
436        predicate: Option<&Expression>,
437    ) -> CacheLookupResult {
438        let (table_key, column_key) = Self::cache_keys(table_name, columns);
439
440        // First pass: read-only search
441        let hit_info = {
442            let cache = match self.cache.read() {
443                Ok(c) => c,
444                Err(_) => {
445                    self.stats.lock_failures.fetch_add(1, Ordering::Relaxed);
446                    return CacheLookupResult::Miss;
447                }
448            };
449
450            // Navigate nested structure: table -> columns -> entries
451            let table_cache = match cache.get(&table_key) {
452                Some(tc) => tc,
453                None => {
454                    drop(cache);
455                    self.record_miss();
456                    return CacheLookupResult::Miss;
457                }
458            };
459
460            let entries = match table_cache.get(&column_key) {
461                Some(e) => e,
462                None => {
463                    drop(cache);
464                    self.record_miss();
465                    return CacheLookupResult::Miss;
466                }
467            };
468
469            // Try to find a usable cached result
470            // Store (index, predicate_hash) for TOCTOU-safe access update
471            let mut found = None;
472            let now = Instant::now();
473            for (idx, entry) in entries.iter().enumerate() {
474                // Skip expired entries
475                if entry.is_expired_at(self.ttl, now) {
476                    continue;
477                }
478
479                // Check if columns match
480                if entry.column_names != columns {
481                    continue;
482                }
483
484                // Check predicate relationship
485                match check_subsumption(entry.predicate.as_ref(), predicate) {
486                    SubsumptionResult::Identical => {
487                        let rows = entry.rows.clone();
488                        let hash = entry.fingerprint.predicate_structure_hash;
489                        found = Some((idx, hash, CacheLookupResult::ExactHit(rows)));
490                        break;
491                    }
492                    SubsumptionResult::Subsumed { filter } => {
493                        let rows = entry.rows.clone();
494                        let columns = entry.column_names.clone();
495                        let hash = entry.fingerprint.predicate_structure_hash;
496                        found = Some((
497                            idx,
498                            hash,
499                            CacheLookupResult::SubsumptionHit {
500                                rows,
501                                filter,
502                                columns,
503                            },
504                        ));
505                        break;
506                    }
507                    SubsumptionResult::NoSubsumption => {
508                        // Try next entry
509                        continue;
510                    }
511                }
512            }
513            found
514        }; // Read lock released here
515
516        match hit_info {
517            Some((idx, expected_hash, result)) => {
518                // Update access time with write lock
519                // TOCTOU safety: verify the entry's fingerprint hash matches
520                // If entry was evicted/replaced, skip update (benign miss)
521                if let Ok(mut cache) = self.cache.write() {
522                    if let Some(table_cache) = cache.get_mut(&table_key) {
523                        if let Some(entries) = table_cache.get_mut(&column_key) {
524                            if let Some(entry) = entries.get_mut(idx) {
525                                // Only update if it's still the same entry
526                                if entry.fingerprint.predicate_structure_hash == expected_hash {
527                                    entry.record_access();
528                                }
529                            }
530                        }
531                    }
532                }
533                // Record hit stats
534                match &result {
535                    CacheLookupResult::ExactHit(_) => self.record_exact_hit(),
536                    CacheLookupResult::SubsumptionHit { .. } => self.record_subsumption_hit(),
537                    CacheLookupResult::Miss => {}
538                }
539                result
540            }
541            None => {
542                self.record_miss();
543                CacheLookupResult::Miss
544            }
545        }
546    }
547
548    /// Insert a query result into the cache
549    #[cfg(test)]
550    pub(crate) fn insert(
551        &self,
552        table_name: &str,
553        columns: Vec<String>,
554        rows: Vec<Row>,
555        predicate: Option<Expression>,
556    ) {
557        let new_row_count = rows.len();
558
559        // Don't cache if too many rows in this single result
560        if new_row_count > self.max_rows {
561            return;
562        }
563
564        let (table_key, column_key) = Self::cache_keys(table_name, &columns);
565        let fingerprint = match &predicate {
566            Some(p) => QueryFingerprint::with_predicate(table_name, columns.clone(), p),
567            None => QueryFingerprint::new(table_name, columns.clone()),
568        };
569
570        let entry = CachedResult::new(fingerprint, columns, rows, predicate);
571        self.insert_entry(
572            entry,
573            new_row_count,
574            table_key,
575            column_key,
576            self.generation(),
577        );
578    }
579
580    /// Current invalidation generation for guarding an in-flight query.
581    pub fn generation(&self) -> u64 {
582        self.generation.load(Ordering::Acquire)
583    }
584
585    /// Insert only if no invalidation occurred since query execution began.
586    pub fn insert_if_generation(
587        &self,
588        expected_generation: u64,
589        table_name: &str,
590        columns: Vec<String>,
591        rows: Vec<Row>,
592        predicate: Option<Expression>,
593    ) {
594        if self.generation() != expected_generation || rows.len() > self.max_rows {
595            return;
596        }
597        let new_row_count = rows.len();
598        let (table_key, column_key) = Self::cache_keys(table_name, &columns);
599        let fingerprint = match &predicate {
600            Some(predicate) => {
601                QueryFingerprint::with_predicate(table_name, columns.clone(), predicate)
602            }
603            None => QueryFingerprint::new(table_name, columns.clone()),
604        };
605        let entry = CachedResult::new(fingerprint, columns, rows, predicate);
606        self.insert_entry(
607            entry,
608            new_row_count,
609            table_key,
610            column_key,
611            expected_generation,
612        );
613    }
614
615    /// Shared generation-fenced insert logic.
616    fn insert_entry(
617        &self,
618        entry: CachedResult,
619        new_row_count: usize,
620        table_key: String,
621        column_key: String,
622        expected_generation: u64,
623    ) {
624        if self.max_size == 0
625            || self.max_rows == 0
626            || self.max_global_rows == 0
627            || self.max_global_bytes == 0
628        {
629            return;
630        }
631        let new_byte_count = entry.estimated_bytes;
632        if new_row_count > self.max_global_rows || new_byte_count > self.max_global_bytes {
633            return;
634        }
635
636        let mut cache = match self.cache.write() {
637            Ok(c) => c,
638            Err(_) => {
639                self.stats.lock_failures.fetch_add(1, Ordering::Relaxed);
640                return;
641            }
642        };
643        if self.generation() != expected_generation {
644            return;
645        }
646
647        // Enforce both budgets across every table, including the table being
648        // inserted. Protecting that table allowed one wide table to grow
649        // without bound.
650        let current_global = self.global_row_count.load(Ordering::Relaxed) as usize;
651        let current_bytes = self.global_byte_count.load(Ordering::Relaxed) as usize;
652        let rows_to_free = (current_global + new_row_count).saturating_sub(self.max_global_rows);
653        let bytes_to_free = (current_bytes + new_byte_count).saturating_sub(self.max_global_bytes);
654        if rows_to_free > 0 || bytes_to_free > 0 {
655            self.evict_global_lru(&mut cache, rows_to_free, bytes_to_free);
656        }
657
658        // Navigate to nested entries: table -> columns -> entries
659        let table_cache = cache.entry(table_key).or_default();
660        let entries = table_cache.entry(column_key).or_default();
661
662        // Evict expired entries and track row count reduction
663        let mut rows_freed: usize = 0;
664        let mut bytes_freed: usize = 0;
665        let before_len = entries.len();
666        let now = Instant::now();
667        entries.retain(|e| {
668            if e.is_expired_at(self.ttl, now) {
669                rows_freed += e.rows.len();
670                bytes_freed += e.estimated_bytes;
671                false
672            } else {
673                true
674            }
675        });
676        let evicted = before_len - entries.len();
677        if evicted > 0 {
678            self.stats
679                .ttl_evictions
680                .fetch_add(evicted as u64, Ordering::Relaxed);
681        }
682
683        // Evict if at per-table capacity (LRU)
684        while entries.len() >= self.max_size {
685            if let Some((idx, _)) = entries
686                .iter()
687                .enumerate()
688                .min_by_key(|(_, e)| (e.last_accessed, e.access_count))
689            {
690                rows_freed += entries[idx].rows.len();
691                bytes_freed += entries[idx].estimated_bytes;
692                entries.remove(idx);
693                self.stats.size_evictions.fetch_add(1, Ordering::Relaxed);
694            } else {
695                break;
696            }
697        }
698
699        // Update global row count (subtract freed, add new)
700        if rows_freed > 0 {
701            self.global_row_count
702                .fetch_sub(rows_freed as u64, Ordering::Relaxed);
703        }
704        if bytes_freed > 0 {
705            self.global_byte_count
706                .fetch_sub(bytes_freed as u64, Ordering::Relaxed);
707        }
708
709        // Add the new entry and update global count
710        self.global_row_count
711            .fetch_add(new_row_count as u64, Ordering::Relaxed);
712        self.global_byte_count
713            .fetch_add(new_byte_count as u64, Ordering::Relaxed);
714        entries.push(entry);
715    }
716
717    /// Evict entries across all tables until both global budgets are satisfied.
718    fn evict_global_lru(
719        &self,
720        cache: &mut StringMap<StringMap<Vec<CachedResult>>>,
721        mut rows_to_free: usize,
722        mut bytes_to_free: usize,
723    ) {
724        while rows_to_free > 0 || bytes_to_free > 0 {
725            // Find the globally oldest entry across all tables
726            let mut oldest: Option<(String, String, usize, Instant, u64, usize, usize)> = None;
727
728            for (table_key, table_cache) in cache.iter() {
729                for (col_key, entries) in table_cache.iter() {
730                    for (idx, entry) in entries.iter().enumerate() {
731                        let dominated = match &oldest {
732                            None => true,
733                            Some((_, _, _, last_acc, acc_count, _, _)) => {
734                                (entry.last_accessed, entry.access_count) < (*last_acc, *acc_count)
735                            }
736                        };
737                        if dominated {
738                            oldest = Some((
739                                table_key.clone(),
740                                col_key.clone(),
741                                idx,
742                                entry.last_accessed,
743                                entry.access_count,
744                                entry.rows.len(),
745                                entry.estimated_bytes,
746                            ));
747                        }
748                    }
749                }
750            }
751
752            match oldest {
753                Some((table_key, col_key, idx, _, _, row_count, byte_count)) => {
754                    if let Some(table_cache) = cache.get_mut(&table_key) {
755                        if let Some(entries) = table_cache.get_mut(&col_key) {
756                            entries.remove(idx);
757                            self.global_row_count
758                                .fetch_sub(row_count as u64, Ordering::Relaxed);
759                            self.global_byte_count
760                                .fetch_sub(byte_count as u64, Ordering::Relaxed);
761                            self.stats.size_evictions.fetch_add(1, Ordering::Relaxed);
762                            rows_to_free = rows_to_free.saturating_sub(row_count);
763                            bytes_to_free = bytes_to_free.saturating_sub(byte_count);
764
765                            // Clean up empty structures
766                            if entries.is_empty() {
767                                table_cache.remove(&col_key);
768                            }
769                        }
770                        if table_cache.is_empty() {
771                            cache.remove(&table_key);
772                        }
773                    }
774                }
775                None => break, // No more entries to evict
776            }
777        }
778    }
779
780    /// Invalidate all cache entries for a table (O(1) operation)
781    pub fn invalidate_table(&self, table_name: &str) {
782        let table_key = to_lowercase_cow(table_name);
783        match self.cache.write() {
784            Ok(mut cache) => {
785                self.generation.fetch_add(1, Ordering::AcqRel);
786                // Count rows being removed for global tracking
787                if let Some(table_cache) = cache.get(table_key.as_ref()) {
788                    let rows_removed: usize = table_cache
789                        .values()
790                        .flat_map(|entries| entries.iter())
791                        .map(|e| e.rows.len())
792                        .sum();
793                    let bytes_removed: usize = table_cache
794                        .values()
795                        .flat_map(|entries| entries.iter())
796                        .map(|entry| entry.estimated_bytes)
797                        .sum();
798                    if rows_removed > 0 {
799                        self.global_row_count
800                            .fetch_sub(rows_removed as u64, Ordering::Relaxed);
801                    }
802                    if bytes_removed > 0 {
803                        self.global_byte_count
804                            .fetch_sub(bytes_removed as u64, Ordering::Relaxed);
805                    }
806                }
807                // O(1) removal: just remove the entire table entry
808                cache.remove(table_key.as_ref());
809            }
810            Err(_) => {
811                self.stats.lock_failures.fetch_add(1, Ordering::Relaxed);
812            }
813        }
814    }
815
816    /// Clear the entire cache and reset statistics
817    pub fn clear(&self) {
818        match self.cache.write() {
819            Ok(mut cache) => {
820                self.generation.fetch_add(1, Ordering::AcqRel);
821                cache.clear();
822            }
823            Err(_) => {
824                self.stats.lock_failures.fetch_add(1, Ordering::Relaxed);
825            }
826        }
827        // Reset global row count
828        self.global_row_count.store(0, Ordering::Relaxed);
829        self.global_byte_count.store(0, Ordering::Relaxed);
830        // Reset all atomic stats counters
831        self.stats.hits.store(0, Ordering::Relaxed);
832        self.stats.exact_hits.store(0, Ordering::Relaxed);
833        self.stats.subsumption_hits.store(0, Ordering::Relaxed);
834        self.stats.misses.store(0, Ordering::Relaxed);
835        self.stats.ttl_evictions.store(0, Ordering::Relaxed);
836        self.stats.size_evictions.store(0, Ordering::Relaxed);
837        // Note: lock_failures is NOT reset - it indicates historical panics
838    }
839
840    /// Get cache statistics as a snapshot
841    pub fn stats(&self) -> SemanticCacheStatsSnapshot {
842        SemanticCacheStatsSnapshot {
843            hits: self.stats.hits.load(Ordering::Relaxed),
844            exact_hits: self.stats.exact_hits.load(Ordering::Relaxed),
845            subsumption_hits: self.stats.subsumption_hits.load(Ordering::Relaxed),
846            misses: self.stats.misses.load(Ordering::Relaxed),
847            ttl_evictions: self.stats.ttl_evictions.load(Ordering::Relaxed),
848            size_evictions: self.stats.size_evictions.load(Ordering::Relaxed),
849            lock_failures: self.stats.lock_failures.load(Ordering::Relaxed),
850        }
851    }
852
853    /// Get the number of cached entries
854    pub fn size(&self) -> usize {
855        self.cache
856            .read()
857            .map(|c| {
858                // Sum entries across all tables and column combinations
859                c.values()
860                    .map(|table_cache| table_cache.values().map(|v| v.len()).sum::<usize>())
861                    .sum()
862            })
863            .unwrap_or(0)
864    }
865
866    /// Filter cached rows using a predicate
867    ///
868    /// This is used when a subsumption match is found to filter
869    /// the broader cached result down to the stricter query's result.
870    ///
871    /// CRITICAL: This function now returns Result to properly propagate compilation errors.
872    /// Previously, compilation failures silently returned unfiltered data which was incorrect.
873    pub fn filter_rows(
874        rows: Vec<Row>,
875        filter: &Expression,
876        columns: &[String],
877        _function_registry: &FunctionRegistry,
878    ) -> Result<Vec<Row>> {
879        let columns_vec: Vec<String> = columns.to_vec();
880        // CRITICAL: Propagate compilation errors instead of returning unfiltered data
881        let mut eval = ExpressionEval::compile(filter, &columns_vec)?;
882
883        let mut result = Vec::with_capacity(rows.len());
884        for row in rows {
885            if eval.eval_bool_checked(&row)? {
886                result.push(row);
887            }
888        }
889        Ok(result)
890    }
891
892    // Private helpers
893
894    /// Returns (table_key, column_key) for the nested cache structure
895    fn cache_keys(table_name: &str, columns: &[String]) -> (String, String) {
896        // Use null byte as delimiter for column key since it's invalid in SQL identifiers
897        // This prevents collision between columns like ["a,b", "c"] and ["a", "b,c"]
898        let table_key = table_name.to_lowercase();
899        let mut sorted_cols = columns.to_vec();
900        sorted_cols.sort();
901        let column_key = sorted_cols.join("\0");
902        (table_key, column_key)
903    }
904
905    fn record_exact_hit(&self) {
906        self.stats.hits.fetch_add(1, Ordering::Relaxed);
907        self.stats.exact_hits.fetch_add(1, Ordering::Relaxed);
908    }
909
910    fn record_subsumption_hit(&self) {
911        self.stats.hits.fetch_add(1, Ordering::Relaxed);
912        self.stats.subsumption_hits.fetch_add(1, Ordering::Relaxed);
913    }
914
915    fn record_miss(&self) {
916        self.stats.misses.fetch_add(1, Ordering::Relaxed);
917    }
918}
919
920impl Default for SemanticCache {
921    fn default() -> Self {
922        Self::new()
923    }
924}
925
926/// Hash the structure of a predicate (ignoring literal values)
927///
928/// This allows matching predicates like:
929/// - `col > 100` and `col > 200` (same structure, different values)
930///
931/// Uses FxHasher which is 2-5x faster than SipHash for small keys.
932fn hash_predicate_structure(expr: &Expression) -> u64 {
933    let mut hasher = FxHasher::default();
934    hash_expr_structure(expr, &mut hasher);
935    hasher.finish()
936}
937
938fn hash_expr_structure(expr: &Expression, hasher: &mut FxHasher) {
939    match expr {
940        Expression::Identifier(ident) => {
941            0u8.hash(hasher);
942            ident.value_lower.hash(hasher);
943        }
944        Expression::QualifiedIdentifier(qi) => {
945            1u8.hash(hasher);
946            qi.qualifier.value_lower.hash(hasher);
947            qi.name.value_lower.hash(hasher);
948        }
949        Expression::IntegerLiteral(_) => {
950            2u8.hash(hasher);
951        }
952        Expression::FloatLiteral(_) => {
953            3u8.hash(hasher);
954        }
955        Expression::StringLiteral(_) => {
956            4u8.hash(hasher);
957        }
958        Expression::BooleanLiteral(_) => {
959            5u8.hash(hasher);
960        }
961        Expression::NullLiteral(_) => {
962            6u8.hash(hasher);
963        }
964        Expression::Infix(infix) => {
965            7u8.hash(hasher);
966            // Use discriminant for hashing - avoids expensive Debug format allocation
967            std::mem::discriminant(&infix.op_type).hash(hasher);
968            hash_expr_structure(&infix.left, hasher);
969            hash_expr_structure(&infix.right, hasher);
970        }
971        Expression::Prefix(prefix) => {
972            8u8.hash(hasher);
973            prefix.operator.hash(hasher);
974            hash_expr_structure(&prefix.right, hasher);
975        }
976        Expression::Between(between) => {
977            9u8.hash(hasher);
978            hash_expr_structure(&between.expr, hasher);
979        }
980        Expression::In(in_expr) => {
981            10u8.hash(hasher);
982            hash_expr_structure(&in_expr.left, hasher);
983        }
984        Expression::FunctionCall(func) => {
985            11u8.hash(hasher);
986            func.function.to_lowercase().hash(hasher);
987            func.arguments.len().hash(hasher);
988        }
989        Expression::Case(_) => {
990            12u8.hash(hasher);
991        }
992        Expression::List(list) => {
993            13u8.hash(hasher);
994            list.elements.len().hash(hasher);
995        }
996        Expression::Window(win) => {
997            14u8.hash(hasher);
998            win.function.function.to_lowercase().hash(hasher);
999            win.function.arguments.len().hash(hasher);
1000            win.partition_by.len().hash(hasher);
1001            win.order_by.len().hash(hasher);
1002        }
1003        _ => {
1004            // Default case for other expressions
1005            255u8.hash(hasher);
1006        }
1007    }
1008}
1009
1010/// Check if new_predicate is subsumed by cached_predicate
1011///
1012/// Returns:
1013/// - `Identical` if predicates are semantically equivalent
1014/// - `Subsumed { filter }` if new_predicate is stricter than cached_predicate
1015/// - `NoSubsumption` if we can't determine a subsumption relationship
1016pub fn check_subsumption(
1017    cached_predicate: Option<&Expression>,
1018    new_predicate: Option<&Expression>,
1019) -> SubsumptionResult {
1020    match (cached_predicate, new_predicate) {
1021        // Both None - identical (full table scan)
1022        (None, None) => SubsumptionResult::Identical,
1023
1024        // Cached has predicate, new has none - new is broader (cannot use cache)
1025        (Some(_), None) => SubsumptionResult::NoSubsumption,
1026
1027        // Cached has no predicate (full scan), new has predicate
1028        // New is stricter - can filter cached full scan
1029        (None, Some(new_pred)) => SubsumptionResult::Subsumed {
1030            filter: Box::new(new_pred.clone()),
1031        },
1032
1033        // Both have predicates - analyze relationship
1034        (Some(cached), Some(new)) => check_predicate_subsumption(cached, new),
1035    }
1036}
1037
1038/// Analyze predicate subsumption for two predicates
1039fn check_predicate_subsumption(cached: &Expression, new: &Expression) -> SubsumptionResult {
1040    // First check if predicates are structurally identical
1041    if expressions_equivalent(cached, new) {
1042        return SubsumptionResult::Identical;
1043    }
1044
1045    // Check for range tightening: cached is broader range
1046    if let Some(result) = check_range_subsumption(cached, new) {
1047        return result;
1048    }
1049
1050    // Check for AND strengthening: new adds more conditions
1051    if let Some(result) = check_and_subsumption(cached, new) {
1052        return result;
1053    }
1054
1055    // Check for IN list subsumption: new has smaller IN list
1056    if let Some(result) = check_in_subsumption(cached, new) {
1057        return result;
1058    }
1059
1060    SubsumptionResult::NoSubsumption
1061}
1062
1063/// Check for numeric range subsumption
1064///
1065/// Examples:
1066/// - cached: `col > 100`, new: `col > 150` → Subsumed (new is stricter)
1067/// - cached: `col < 500`, new: `col < 300` → Subsumed (new is stricter)
1068fn check_range_subsumption(cached: &Expression, new: &Expression) -> Option<SubsumptionResult> {
1069    // Both must be infix comparisons
1070    let (cached_infix, new_infix) = match (cached, new) {
1071        (Expression::Infix(c), Expression::Infix(n)) => (c, n),
1072        _ => return None,
1073    };
1074
1075    // Must be comparing same column to a literal
1076    let cached_col = extract_column_name(&cached_infix.left)?;
1077    let new_col = extract_column_name(&new_infix.left)?;
1078
1079    // OPTIMIZATION: Use case-insensitive comparison instead of double to_lowercase()
1080    if !cached_col.eq_ignore_ascii_case(&new_col) {
1081        return None;
1082    }
1083
1084    let cached_val = extract_literal_value(&cached_infix.right)?;
1085    let new_val = extract_literal_value(&new_infix.right)?;
1086    let bound_order = new_val.cmp(&cached_val);
1087
1088    // Check operator relationship
1089    match (&cached_infix.op_type, &new_infix.op_type) {
1090        // Greater than: new > cached means new is stricter
1091        (
1092            InfixOperator::GreaterThan | InfixOperator::GreaterEqual,
1093            InfixOperator::GreaterThan | InfixOperator::GreaterEqual,
1094        ) => match bound_order {
1095            std::cmp::Ordering::Greater => Some(SubsumptionResult::Subsumed {
1096                filter: Box::new(new.clone()),
1097            }),
1098            std::cmp::Ordering::Less => None,
1099            std::cmp::Ordering::Equal => match (&cached_infix.op_type, &new_infix.op_type) {
1100                (InfixOperator::GreaterEqual, InfixOperator::GreaterThan) => {
1101                    Some(SubsumptionResult::Subsumed {
1102                        filter: Box::new(new.clone()),
1103                    })
1104                }
1105                (cached_op, new_op) if cached_op == new_op => Some(SubsumptionResult::Identical),
1106                _ => None,
1107            },
1108        },
1109
1110        // Less than: new < cached means new is stricter
1111        (
1112            InfixOperator::LessThan | InfixOperator::LessEqual,
1113            InfixOperator::LessThan | InfixOperator::LessEqual,
1114        ) => match bound_order {
1115            std::cmp::Ordering::Less => Some(SubsumptionResult::Subsumed {
1116                filter: Box::new(new.clone()),
1117            }),
1118            std::cmp::Ordering::Greater => None,
1119            std::cmp::Ordering::Equal => match (&cached_infix.op_type, &new_infix.op_type) {
1120                (InfixOperator::LessEqual, InfixOperator::LessThan) => {
1121                    Some(SubsumptionResult::Subsumed {
1122                        filter: Box::new(new.clone()),
1123                    })
1124                }
1125                (cached_op, new_op) if cached_op == new_op => Some(SubsumptionResult::Identical),
1126                _ => None,
1127            },
1128        },
1129
1130        // Equality: values must match for identity
1131        (InfixOperator::Equal, InfixOperator::Equal) => {
1132            if new_val == cached_val {
1133                Some(SubsumptionResult::Identical)
1134            } else {
1135                None
1136            }
1137        }
1138
1139        _ => None,
1140    }
1141}
1142
1143/// Check for AND conjunction subsumption
1144///
1145/// If new = cached AND extra_condition, then new is subsumed
1146fn check_and_subsumption(cached: &Expression, new: &Expression) -> Option<SubsumptionResult> {
1147    // New must be an AND
1148    let new_infix = match new {
1149        Expression::Infix(infix) if matches!(infix.op_type, InfixOperator::And) => infix,
1150        _ => return None,
1151    };
1152
1153    // Check if cached is equivalent to one side of the AND
1154    if expressions_equivalent(cached, &new_infix.left)
1155        || expressions_equivalent(cached, &new_infix.right)
1156    {
1157        // New is cached AND something_else → new is stricter
1158        return Some(SubsumptionResult::Subsumed {
1159            filter: Box::new(new.clone()),
1160        });
1161    }
1162
1163    // Check if cached is also an AND and new extends it
1164    if let Expression::Infix(cached_infix) = cached {
1165        if matches!(cached_infix.op_type, InfixOperator::And) {
1166            // Extract conditions from both
1167            let cached_conditions = extract_and_conditions(cached);
1168            let new_conditions = extract_and_conditions(new);
1169
1170            // New must contain all of cached's conditions
1171            let all_cached_present = cached_conditions.iter().all(|cc| {
1172                new_conditions
1173                    .iter()
1174                    .any(|nc| expressions_equivalent(cc, nc))
1175            });
1176
1177            if all_cached_present && new_conditions.len() > cached_conditions.len() {
1178                return Some(SubsumptionResult::Subsumed {
1179                    filter: Box::new(new.clone()),
1180                });
1181            }
1182        }
1183    }
1184
1185    None
1186}
1187
1188/// Check for IN list subsumption
1189///
1190/// If new IN list is subset of cached IN list, new is subsumed
1191fn check_in_subsumption(cached: &Expression, new: &Expression) -> Option<SubsumptionResult> {
1192    let (cached_in, new_in) = match (cached, new) {
1193        (Expression::In(c), Expression::In(n)) => (c, n),
1194        _ => return None,
1195    };
1196
1197    // NOT IN has the opposite set-containment direction and NULL-sensitive
1198    // three-valued semantics. Keep it fail-closed unless AST equality already
1199    // returned Identical above.
1200    if cached_in.not || new_in.not {
1201        return None;
1202    }
1203
1204    // Must be same column
1205    if !expressions_equivalent(&cached_in.left, &new_in.left) {
1206        return None;
1207    }
1208
1209    // Extract values from both IN lists
1210    let cached_values = extract_in_values(&cached_in.right)?;
1211    let new_values = extract_in_values(&new_in.right)?;
1212
1213    // Check if new is subset of cached
1214    let is_subset = new_values.iter().all(|value| cached_values.contains(value));
1215
1216    if is_subset {
1217        if new_values.len() == cached_values.len() {
1218            Some(SubsumptionResult::Identical)
1219        } else {
1220            Some(SubsumptionResult::Subsumed {
1221                filter: Box::new(new.clone()),
1222            })
1223        }
1224    } else {
1225        None
1226    }
1227}
1228
1229/// Extract values from an IN expression's right side
1230fn extract_in_values(expr: &Expression) -> Option<ValueSet> {
1231    match expr {
1232        Expression::List(list) => list.elements.iter().map(extract_literal_value).collect(),
1233        Expression::ExpressionList(list) => {
1234            list.expressions.iter().map(extract_literal_value).collect()
1235        }
1236        _ => None,
1237    }
1238}
1239
1240fn extract_literal_value(expr: &Expression) -> Option<Value> {
1241    match expr {
1242        Expression::IntegerLiteral(lit) => Some(Value::Integer(lit.value)),
1243        Expression::FloatLiteral(lit) => Some(Value::Float(lit.value)),
1244        Expression::StringLiteral(lit) => Some(Value::Text(lit.value.clone())),
1245        Expression::BooleanLiteral(lit) => Some(Value::Boolean(lit.value)),
1246        _ => None,
1247    }
1248}
1249
1250#[cfg(test)]
1251mod tests {
1252    use super::*;
1253    use radixdb_sql::ast::{
1254        FloatLiteral, Identifier, InExpression, InfixExpression, ListExpression,
1255    };
1256    use radixdb_sql::token::{Position, Token, TokenType};
1257
1258    fn parsed_where(sql: &str) -> Expression {
1259        let statements = radixdb_sql::parse_sql(sql).unwrap();
1260        let radixdb_sql::Statement::Select(select) = &statements[0] else {
1261            panic!("expected SELECT")
1262        };
1263        select.where_clause.as_deref().unwrap().clone()
1264    }
1265
1266    #[test]
1267    fn r5_l03_semantic_and_classification_identity_preserve_complete_ast_semantic() {
1268        let cases = [
1269            ("x > 100", "x >= 100"),
1270            ("x < 100", "x <= 100"),
1271            ("x IN (1)", "x NOT IN (1)"),
1272            ("x IN ('a')", "x IN ('b')"),
1273        ];
1274        for (cached, new) in cases {
1275            let cached = parsed_where(&format!("SELECT * FROM t WHERE {cached}"));
1276            let new = parsed_where(&format!("SELECT * FROM t WHERE {new}"));
1277            assert!(matches!(
1278                check_subsumption(Some(&cached), Some(&new)),
1279                SubsumptionResult::NoSubsumption
1280            ));
1281        }
1282
1283        let cached = parsed_where("SELECT * FROM t WHERE x IN (1, 1, 2)");
1284        let same_set = parsed_where("SELECT * FROM t WHERE x IN (2, 1)");
1285        assert!(matches!(
1286            check_subsumption(Some(&cached), Some(&same_set)),
1287            SubsumptionResult::Identical
1288        ));
1289    }
1290
1291    #[test]
1292    fn r5_l03_cache_budgets_and_lru_follow_runtime_usage_semantic() {
1293        let byte_bounded =
1294            SemanticCache::with_config_and_byte_limit(8, Duration::from_secs(60), 8, 8, 128);
1295        byte_bounded.insert(
1296            "wide",
1297            vec!["payload".to_string()],
1298            vec![Row::from_values(vec![Value::Text("x".repeat(1024).into())])],
1299            None,
1300        );
1301        assert!(matches!(
1302            byte_bounded.lookup("wide", &["payload".to_string()], None),
1303            CacheLookupResult::Miss
1304        ));
1305
1306        let row_bounded = SemanticCache::with_config_and_byte_limit(
1307            8,
1308            Duration::from_secs(60),
1309            8,
1310            2,
1311            1024 * 1024,
1312        );
1313        row_bounded.insert(
1314            "same_table",
1315            vec!["a".to_string()],
1316            vec![
1317                Row::from_values(vec![Value::Integer(1)]),
1318                Row::from_values(vec![Value::Integer(2)]),
1319            ],
1320            None,
1321        );
1322        row_bounded.insert(
1323            "same_table",
1324            vec!["b".to_string()],
1325            vec![
1326                Row::from_values(vec![Value::Integer(3)]),
1327                Row::from_values(vec![Value::Integer(4)]),
1328            ],
1329            None,
1330        );
1331        assert!(matches!(
1332            row_bounded.lookup("same_table", &["a".to_string()], None),
1333            CacheLookupResult::Miss
1334        ));
1335        assert!(matches!(
1336            row_bounded.lookup("same_table", &["b".to_string()], None),
1337            CacheLookupResult::ExactHit(_)
1338        ));
1339    }
1340
1341    fn make_token() -> Token {
1342        Token {
1343            token_type: TokenType::Integer,
1344            literal: "".into(),
1345            position: Position::new(0, 1, 1),
1346            quoted: false,
1347        }
1348    }
1349
1350    fn make_identifier(name: &str) -> Expression {
1351        Expression::Identifier(Identifier::new(make_token(), name.to_string()))
1352    }
1353
1354    fn make_int_literal(val: i64) -> Expression {
1355        Expression::IntegerLiteral(radixdb_sql::ast::IntegerLiteral {
1356            token: make_token(),
1357            value: val,
1358        })
1359    }
1360
1361    fn make_gt(col: &str, val: i64) -> Expression {
1362        Expression::Infix(InfixExpression::new(
1363            make_token(),
1364            Box::new(make_identifier(col)),
1365            ">".to_string(),
1366            Box::new(make_int_literal(val)),
1367        ))
1368    }
1369
1370    fn make_lt(col: &str, val: i64) -> Expression {
1371        Expression::Infix(InfixExpression::new(
1372            make_token(),
1373            Box::new(make_identifier(col)),
1374            "<".to_string(),
1375            Box::new(make_int_literal(val)),
1376        ))
1377    }
1378
1379    fn make_and(left: Expression, right: Expression) -> Expression {
1380        Expression::Infix(InfixExpression::new(
1381            make_token(),
1382            Box::new(left),
1383            "AND".to_string(),
1384            Box::new(right),
1385        ))
1386    }
1387
1388    fn make_in(col: &str, values: Vec<i64>) -> Expression {
1389        Expression::In(InExpression {
1390            token: make_token(),
1391            left: Box::new(make_identifier(col)),
1392            right: Box::new(Expression::List(Box::new(ListExpression {
1393                token: make_token(),
1394                elements: values.into_iter().map(make_int_literal).collect(),
1395            }))),
1396            not: false,
1397        })
1398    }
1399
1400    #[test]
1401    fn test_identical_predicates() {
1402        let pred1 = make_gt("amount", 100);
1403        let pred2 = make_gt("amount", 100);
1404
1405        match check_subsumption(Some(&pred1), Some(&pred2)) {
1406            SubsumptionResult::Identical => {}
1407            other => panic!("Expected Identical, got {:?}", other),
1408        }
1409    }
1410
1411    #[test]
1412    fn test_range_subsumption_greater_than() {
1413        // cached: amount > 100, new: amount > 150
1414        let cached = make_gt("amount", 100);
1415        let new = make_gt("amount", 150);
1416
1417        match check_subsumption(Some(&cached), Some(&new)) {
1418            SubsumptionResult::Subsumed { .. } => {}
1419            other => panic!("Expected Subsumed, got {:?}", other),
1420        }
1421
1422        // Reverse should not work: cached: amount > 150, new: amount > 100
1423        match check_subsumption(Some(&new), Some(&cached)) {
1424            SubsumptionResult::NoSubsumption => {}
1425            other => panic!("Expected NoSubsumption, got {:?}", other),
1426        }
1427    }
1428
1429    #[test]
1430    fn test_range_subsumption_less_than() {
1431        // cached: amount < 500, new: amount < 300
1432        let cached = make_lt("amount", 500);
1433        let new = make_lt("amount", 300);
1434
1435        match check_subsumption(Some(&cached), Some(&new)) {
1436            SubsumptionResult::Subsumed { .. } => {}
1437            other => panic!("Expected Subsumed, got {:?}", other),
1438        }
1439    }
1440
1441    #[test]
1442    fn test_and_subsumption() {
1443        // cached: amount > 100, new: amount > 100 AND status > 0
1444        let cached = make_gt("amount", 100);
1445        let status_check = make_gt("status", 0);
1446        let new = make_and(make_gt("amount", 100), status_check);
1447
1448        match check_subsumption(Some(&cached), Some(&new)) {
1449            SubsumptionResult::Subsumed { .. } => {}
1450            other => panic!("Expected Subsumed, got {:?}", other),
1451        }
1452    }
1453
1454    #[test]
1455    fn test_in_subsumption() {
1456        // cached: id IN (1,2,3,4,5), new: id IN (2,3)
1457        let cached = make_in("id", vec![1, 2, 3, 4, 5]);
1458        let new = make_in("id", vec![2, 3]);
1459
1460        match check_subsumption(Some(&cached), Some(&new)) {
1461            SubsumptionResult::Subsumed { .. } => {}
1462            other => panic!("Expected Subsumed, got {:?}", other),
1463        }
1464    }
1465
1466    #[test]
1467    fn test_no_predicate_to_predicate() {
1468        // cached: full scan, new: amount > 100
1469        let new = make_gt("amount", 100);
1470
1471        match check_subsumption(None, Some(&new)) {
1472            SubsumptionResult::Subsumed { .. } => {}
1473            other => panic!("Expected Subsumed, got {:?}", other),
1474        }
1475    }
1476
1477    #[test]
1478    fn test_cache_basic() {
1479        let cache = SemanticCache::new();
1480
1481        // Insert a result
1482        let rows = vec![
1483            Row::from_values(vec![Value::Integer(1), Value::Integer(200)]),
1484            Row::from_values(vec![Value::Integer(2), Value::Integer(300)]),
1485            Row::from_values(vec![Value::Integer(3), Value::Integer(400)]),
1486        ];
1487
1488        cache.insert(
1489            "orders",
1490            vec!["id".to_string(), "amount".to_string()],
1491            rows.clone(),
1492            Some(make_gt("amount", 100)),
1493        );
1494
1495        assert_eq!(cache.size(), 1);
1496
1497        // Lookup with identical predicate
1498        match cache.lookup(
1499            "orders",
1500            &["id".to_string(), "amount".to_string()],
1501            Some(&make_gt("amount", 100)),
1502        ) {
1503            CacheLookupResult::ExactHit(cached_rows) => {
1504                assert_eq!(cached_rows.len(), 3);
1505            }
1506            other => panic!("Expected ExactHit, got {:?}", other),
1507        }
1508
1509        let stats = cache.stats();
1510        assert_eq!(stats.exact_hits, 1);
1511    }
1512
1513    #[test]
1514    fn test_cache_subsumption_lookup() {
1515        let cache = SemanticCache::new();
1516
1517        // Cache result for amount > 100
1518        let rows = vec![
1519            Row::from_values(vec![Value::Integer(1), Value::Integer(150)]),
1520            Row::from_values(vec![Value::Integer(2), Value::Integer(200)]),
1521            Row::from_values(vec![Value::Integer(3), Value::Integer(300)]),
1522        ];
1523
1524        cache.insert(
1525            "orders",
1526            vec!["id".to_string(), "amount".to_string()],
1527            rows,
1528            Some(make_gt("amount", 100)),
1529        );
1530
1531        // Lookup with stricter predicate: amount > 180
1532        match cache.lookup(
1533            "orders",
1534            &["id".to_string(), "amount".to_string()],
1535            Some(&make_gt("amount", 180)),
1536        ) {
1537            CacheLookupResult::SubsumptionHit { rows, .. } => {
1538                assert_eq!(rows.len(), 3); // All cached rows returned
1539            }
1540            other => panic!("Expected SubsumptionHit, got {:?}", other),
1541        }
1542
1543        let stats = cache.stats();
1544        assert_eq!(stats.subsumption_hits, 1);
1545    }
1546
1547    #[test]
1548    fn test_cache_invalidation() {
1549        let cache = SemanticCache::new();
1550
1551        cache.insert(
1552            "orders",
1553            vec!["id".to_string()],
1554            vec![Row::from_values(vec![Value::Integer(1)])],
1555            None,
1556        );
1557
1558        assert_eq!(cache.size(), 1);
1559
1560        cache.invalidate_table("orders");
1561        assert_eq!(cache.size(), 0);
1562    }
1563
1564    #[test]
1565    fn v2_r5_zero_and_one_capacity_are_hard_bounds() {
1566        let disabled = SemanticCache::with_config(0, Duration::from_secs(60), 10, 10);
1567        disabled.insert(
1568            "t",
1569            vec!["id".to_string()],
1570            vec![Row::from_values(vec![Value::Integer(1)])],
1571            None,
1572        );
1573        assert_eq!(disabled.size(), 0);
1574
1575        let one = SemanticCache::with_config(1, Duration::from_secs(60), 10, 10);
1576        one.insert(
1577            "t",
1578            vec!["id".to_string()],
1579            vec![Row::from_values(vec![Value::Integer(1)])],
1580            Some(make_gt("id", 0)),
1581        );
1582        one.insert(
1583            "t",
1584            vec!["id".to_string()],
1585            vec![Row::from_values(vec![Value::Integer(2)])],
1586            Some(make_gt("id", 1)),
1587        );
1588        assert_eq!(one.size(), 1);
1589    }
1590
1591    #[test]
1592    fn v2_r5_adjacent_floats_are_not_exact_cache_hits() {
1593        let left = Expression::FloatLiteral(FloatLiteral {
1594            token: Token::new(TokenType::Float, "0.0", Position::default()),
1595            value: 0.0,
1596        });
1597        let right = Expression::FloatLiteral(FloatLiteral {
1598            token: Token::new(TokenType::Float, "5e-324", Position::default()),
1599            value: f64::from_bits(1),
1600        });
1601        assert!(!expressions_equivalent(&left, &right));
1602        assert!(expressions_equivalent(
1603            &Expression::FloatLiteral(FloatLiteral {
1604                token: Token::new(TokenType::Float, "-0.0", Position::default()),
1605                value: -0.0,
1606            }),
1607            &left,
1608        ));
1609    }
1610}