Skip to main content

velesdb_core/cache/
plan_cache.rs

1//! Compiled query plan cache types for `VelesDB` (CACHE-01).
2//!
3//! Provides `PlanKey` (deterministic cache key), `CompiledPlan` (cached execution plan),
4//! `PlanCacheMetrics` (hit/miss counters), and `CompiledPlanCache` (thin wrapper around
5//! `LockFreeLruCache`).
6
7use std::fmt;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::Arc;
10
11use smallvec::SmallVec;
12
13use super::LockFreeLruCache;
14use crate::velesql::QueryPlan;
15
16/// Deterministic cache key for compiled query plans.
17///
18/// Two keys are equal iff the query text is identical AND the database state
19/// (schema version + per-collection write generations) has not changed.
20///
21/// `collection_generations` must be sorted by collection name before
22/// insertion for deterministic hashing.
23///
24/// # Correctness invariant (CACHE-01)
25///
26/// Cache correctness depends on `query_hash` capturing **all** collection
27/// names referenced by the query (base table + JOIN targets). If two
28/// structurally different queries happened to hash to the same value the cache
29/// would serve a stale plan. This is prevented by using the full canonical
30/// serialization of the `Query` AST — not just the collection name — as the
31/// hash input (see `Database::build_plan_key`). The `collection_generations`
32/// vector is then ordered by collection name so that the same set of
33/// collections always produces the same key regardless of JOIN ordering.
34#[derive(Clone, Debug, PartialEq, Eq, Hash)]
35pub struct PlanKey {
36    /// `FxHash` of canonical query text.
37    pub query_hash: u64,
38    /// Monotonic counter incremented on every DDL operation.
39    pub schema_version: u64,
40    /// Per-collection write generation, sorted by collection name.
41    pub collection_generations: SmallVec<[u64; 4]>,
42}
43
44/// A compiled (cached) query execution plan.
45///
46/// Stored behind `Arc` in the cache; the cache value type is
47/// `Arc<CompiledPlan>` so `Clone` is not required on this struct.
48#[derive(Debug)]
49pub struct CompiledPlan {
50    /// The query plan produced by the planner.
51    pub plan: QueryPlan,
52    /// Collections referenced by this plan (for invalidation checks).
53    ///
54    /// Currently stale-key detection in `build_plan_key` handles invalidation:
55    /// when a collection's `write_generation` changes the key no longer matches
56    /// anything in the cache so a fresh plan is compiled on the next call.
57    ///
58    /// Future work (CACHE-01): use `referenced_collections` for targeted
59    /// invalidation — evict only plans that touch a mutated collection rather
60    /// than relying on stale-key detection. This requires an inverted index
61    /// from collection name to `PlanKey` and would reduce spurious misses in
62    /// multi-collection workloads.
63    pub referenced_collections: Vec<String>,
64    /// When this plan was compiled.
65    pub compiled_at: std::time::Instant,
66    /// How many times this cached plan has been reused.
67    pub reuse_count: AtomicU64,
68}
69
70/// Global cache hit/miss counters.
71#[derive(Debug, Default)]
72pub struct PlanCacheMetrics {
73    /// Total cache hits.
74    pub hits: AtomicU64,
75    /// Total cache misses.
76    pub misses: AtomicU64,
77}
78
79impl PlanCacheMetrics {
80    /// Records a cache hit.
81    pub fn record_hit(&self) {
82        self.hits.fetch_add(1, Ordering::Relaxed);
83    }
84
85    /// Records a cache miss.
86    pub fn record_miss(&self) {
87        self.misses.fetch_add(1, Ordering::Relaxed);
88    }
89
90    /// Returns total hits.
91    #[must_use]
92    pub fn hits(&self) -> u64 {
93        self.hits.load(Ordering::Relaxed)
94    }
95
96    /// Returns total misses.
97    #[must_use]
98    pub fn misses(&self) -> u64 {
99        self.misses.load(Ordering::Relaxed)
100    }
101
102    /// Returns the hit rate as a ratio in `[0.0, 1.0]`.
103    #[must_use]
104    #[allow(clippy::cast_precision_loss)]
105    pub fn hit_rate(&self) -> f64 {
106        let h = self.hits();
107        let m = self.misses();
108        let total = h + m;
109        if total == 0 {
110            0.0
111        } else {
112            // Precision loss is acceptable: hit rate is a diagnostic metric,
113            // not a value used in any computation where exactness matters.
114            h as f64 / total as f64
115        }
116    }
117}
118
119/// Thin wrapper around `LockFreeLruCache` for compiled query plans.
120///
121/// Tracks hit/miss metrics and delegates storage to the lock-free two-tier cache.
122pub struct CompiledPlanCache {
123    cache: LockFreeLruCache<PlanKey, Arc<CompiledPlan>>,
124    metrics: PlanCacheMetrics,
125}
126
127impl fmt::Debug for CompiledPlanCache {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        let stats = self.cache.stats();
130        f.debug_struct("CompiledPlanCache")
131            .field("l1_size", &stats.l1_size)
132            .field("l2_size", &stats.l2_size)
133            .field("hits", &self.metrics.hits())
134            .field("misses", &self.metrics.misses())
135            .finish()
136    }
137}
138
139impl CompiledPlanCache {
140    /// Creates a new compiled plan cache.
141    ///
142    /// # Arguments
143    ///
144    /// * `l1_capacity` - Maximum entries in L1 (hot cache)
145    /// * `l2_capacity` - Maximum entries in L2 (LRU backing store)
146    #[must_use]
147    pub fn new(l1_capacity: usize, l2_capacity: usize) -> Self {
148        Self {
149            cache: LockFreeLruCache::new(l1_capacity, l2_capacity),
150            metrics: PlanCacheMetrics::default(),
151        }
152    }
153
154    /// Returns `true` if a plan for `key` exists in the cache.
155    ///
156    /// Unlike [`get`](Self::get), this method does **not** record a hit or miss
157    /// in the metrics counters and does **not** increment `reuse_count`. It is
158    /// intended for existence checks (e.g. deciding whether to insert a newly
159    /// compiled plan) where polluting the metrics would distort hit-rate
160    /// calculations.
161    #[must_use]
162    pub fn contains(&self, key: &PlanKey) -> bool {
163        // Check L1 first (lock-free DashMap), then L2 (LRU behind a mutex).
164        // Using peek_l1 / peek_l2 avoids the LRU promotion that `get` would
165        // trigger, keeping the hot-path ordering stable.
166        self.cache.peek_l1(key).is_some() || self.cache.peek_l2(key).is_some()
167    }
168
169    /// Looks up a compiled plan by key, recording hit/miss.
170    #[must_use]
171    pub fn get(&self, key: &PlanKey) -> Option<Arc<CompiledPlan>> {
172        if let Some(plan) = self.cache.get(key) {
173            self.metrics.record_hit();
174            plan.reuse_count.fetch_add(1, Ordering::Relaxed);
175            Some(plan)
176        } else {
177            self.metrics.record_miss();
178            None
179        }
180    }
181
182    /// Inserts a compiled plan into the cache.
183    pub fn insert(&self, key: PlanKey, plan: Arc<CompiledPlan>) {
184        self.cache.insert(key, plan);
185    }
186
187    /// Returns the underlying cache statistics.
188    #[must_use]
189    pub fn stats(&self) -> super::LockFreeCacheStats {
190        self.cache.stats()
191    }
192
193    /// Returns a reference to the plan cache metrics.
194    #[must_use]
195    pub fn metrics(&self) -> &PlanCacheMetrics {
196        &self.metrics
197    }
198}