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    /// Per-collection analyze generation, sorted by collection name (issue #608).
43    ///
44    /// Parallel to `collection_generations` but tracks `ANALYZE` invocations
45    /// rather than data mutations. Including this in the cache key ensures
46    /// that running `ANALYZE` alone — with no intermediate write to bump
47    /// `write_generation` — still invalidates plans whose cost estimates
48    /// were derived from pre-analyze heuristics.
49    pub analyze_generations: SmallVec<[u64; 4]>,
50}
51
52/// A compiled (cached) query execution plan.
53///
54/// Stored behind `Arc` in the cache; the cache value type is
55/// `Arc<CompiledPlan>` so `Clone` is not required on this struct.
56#[derive(Debug)]
57pub struct CompiledPlan {
58    /// The query plan produced by the planner.
59    pub plan: QueryPlan,
60    /// Collections referenced by this plan (for invalidation checks).
61    ///
62    /// Currently stale-key detection in `build_plan_key` handles invalidation:
63    /// when a collection's `write_generation` changes the key no longer matches
64    /// anything in the cache so a fresh plan is compiled on the next call.
65    ///
66    /// Future work (CACHE-01): use `referenced_collections` for targeted
67    /// invalidation — evict only plans that touch a mutated collection rather
68    /// than relying on stale-key detection. This requires an inverted index
69    /// from collection name to `PlanKey` and would reduce spurious misses in
70    /// multi-collection workloads.
71    pub referenced_collections: Vec<String>,
72    /// When this plan was compiled.
73    pub compiled_at: std::time::Instant,
74    /// How many times this cached plan has been reused.
75    pub reuse_count: AtomicU64,
76}
77
78/// Global cache hit/miss counters.
79#[derive(Debug, Default)]
80pub struct PlanCacheMetrics {
81    /// Total cache hits.
82    pub hits: AtomicU64,
83    /// Total cache misses.
84    pub misses: AtomicU64,
85}
86
87impl PlanCacheMetrics {
88    /// Records a cache hit.
89    pub fn record_hit(&self) {
90        self.hits.fetch_add(1, Ordering::Relaxed);
91    }
92
93    /// Records a cache miss.
94    pub fn record_miss(&self) {
95        self.misses.fetch_add(1, Ordering::Relaxed);
96    }
97
98    /// Returns total hits.
99    #[must_use]
100    pub fn hits(&self) -> u64 {
101        self.hits.load(Ordering::Relaxed)
102    }
103
104    /// Returns total misses.
105    #[must_use]
106    pub fn misses(&self) -> u64 {
107        self.misses.load(Ordering::Relaxed)
108    }
109
110    /// Returns the hit rate as a ratio in `[0.0, 1.0]`.
111    #[must_use]
112    #[allow(clippy::cast_precision_loss)]
113    pub fn hit_rate(&self) -> f64 {
114        let h = self.hits();
115        let m = self.misses();
116        let total = h + m;
117        if total == 0 {
118            0.0
119        } else {
120            // Precision loss is acceptable: hit rate is a diagnostic metric,
121            // not a value used in any computation where exactness matters.
122            h as f64 / total as f64
123        }
124    }
125}
126
127/// Thin wrapper around `LockFreeLruCache` for compiled query plans.
128///
129/// Tracks hit/miss metrics and delegates storage to the lock-free two-tier cache.
130pub struct CompiledPlanCache {
131    cache: LockFreeLruCache<PlanKey, Arc<CompiledPlan>>,
132    metrics: PlanCacheMetrics,
133}
134
135impl fmt::Debug for CompiledPlanCache {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        let stats = self.cache.stats();
138        f.debug_struct("CompiledPlanCache")
139            .field("l1_size", &stats.l1_size)
140            .field("l2_size", &stats.l2_size)
141            .field("hits", &self.metrics.hits())
142            .field("misses", &self.metrics.misses())
143            .finish()
144    }
145}
146
147impl CompiledPlanCache {
148    /// Creates a new compiled plan cache.
149    ///
150    /// # Arguments
151    ///
152    /// * `l1_capacity` - Maximum entries in L1 (hot cache)
153    /// * `l2_capacity` - Maximum entries in L2 (LRU backing store)
154    #[must_use]
155    pub fn new(l1_capacity: usize, l2_capacity: usize) -> Self {
156        Self {
157            cache: LockFreeLruCache::new(l1_capacity, l2_capacity),
158            metrics: PlanCacheMetrics::default(),
159        }
160    }
161
162    /// Returns `true` if a plan for `key` exists in the cache.
163    ///
164    /// Unlike [`get`](Self::get), this method does **not** record a hit or miss
165    /// in the metrics counters and does **not** increment `reuse_count`. It is
166    /// intended for existence checks (e.g. deciding whether to insert a newly
167    /// compiled plan) where polluting the metrics would distort hit-rate
168    /// calculations.
169    #[must_use]
170    pub fn contains(&self, key: &PlanKey) -> bool {
171        // Check L1 first (lock-free DashMap), then L2 (LRU behind a mutex).
172        // Using peek_l1 / peek_l2 avoids the LRU promotion that `get` would
173        // trigger, keeping the hot-path ordering stable.
174        self.cache.peek_l1(key).is_some() || self.cache.peek_l2(key).is_some()
175    }
176
177    /// Looks up a compiled plan by key, recording hit/miss.
178    #[must_use]
179    pub fn get(&self, key: &PlanKey) -> Option<Arc<CompiledPlan>> {
180        if let Some(plan) = self.cache.get(key) {
181            self.metrics.record_hit();
182            plan.reuse_count.fetch_add(1, Ordering::Relaxed);
183            Some(plan)
184        } else {
185            self.metrics.record_miss();
186            None
187        }
188    }
189
190    /// Inserts a compiled plan into the cache.
191    pub fn insert(&self, key: PlanKey, plan: Arc<CompiledPlan>) {
192        self.cache.insert(key, plan);
193    }
194
195    /// Returns the underlying cache statistics.
196    #[must_use]
197    pub fn stats(&self) -> super::LockFreeCacheStats {
198        self.cache.stats()
199    }
200
201    /// Returns a reference to the plan cache metrics.
202    #[must_use]
203    pub fn metrics(&self) -> &PlanCacheMetrics {
204        &self.metrics
205    }
206
207    /// Clears all cached plans from both L1 and L2 tiers.
208    ///
209    /// This does **not** reset the hit/miss metrics counters.
210    pub fn clear(&self) {
211        self.cache.clear();
212    }
213}