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 **canonical query text is byte-for-byte
19/// identical** AND the database state (schema version + per-collection write /
20/// analyze generations) has not changed.
21///
22/// `collection_generations` must be sorted by collection name before
23/// insertion for deterministic hashing.
24///
25/// # Correctness invariant (CACHE-01, issue #902)
26///
27/// `query_hash` is a 64-bit `FxHash` of the canonical query text. `FxHash` is a
28/// fast, **non-cryptographic** hash: distinct queries can be engineered to
29/// collide on the same 64-bit value. If equality were decided on `query_hash`
30/// alone, a colliding query would be treated as equal to an unrelated cached
31/// query and the cache could return **another query's compiled plan** (wrong
32/// results).
33///
34/// To make this impossible, `PlanKey` stores the canonical `query_text` and the
35/// hand-written [`PartialEq`]/[`Eq`] impls compare the full text (not the
36/// hash). `query_hash` is retained purely as a [`Hash`] accelerator so that the
37/// `DashMap`/`LruCache` buckets stay cheap to compute; the `Hash` impl
38/// deliberately feeds only `query_hash` (plus the generation fields) into the
39/// hasher rather than re-hashing the whole string on every lookup. This mirrors
40/// the safe pattern used by the `VelesQL` parse cache (`velesql/cache.rs`),
41/// which re-checks query-text equality on every lookup.
42///
43/// `Hash`/`Eq` consistency: equal keys have identical `query_text`, and
44/// `query_hash` is a pure function of `query_text`, so equal keys always hash
45/// identically — the `Hash`/`Eq` contract holds.
46#[derive(Clone, Debug)]
47pub struct PlanKey {
48    /// Canonical serialization of the query AST (see `Database::build_plan_key`).
49    ///
50    /// This is the authoritative query-identity field: equality is decided on
51    /// this string, never on `query_hash` alone. Stored behind `Arc<str>` so
52    /// cloning a `PlanKey` (done on every cache insert / promotion) is a cheap
53    /// refcount bump rather than a full string copy.
54    pub query_text: Arc<str>,
55    /// `FxHash` of `query_text`. Hash accelerator only — **not** an identity
56    /// field. Collisions are resolved by the `query_text` comparison in `Eq`.
57    pub query_hash: u64,
58    /// Monotonic counter incremented on every DDL operation.
59    pub schema_version: u64,
60    /// Per-collection write generation, sorted by collection name.
61    pub collection_generations: SmallVec<[u64; 4]>,
62    /// Per-collection analyze generation, sorted by collection name (issue #608).
63    ///
64    /// Parallel to `collection_generations` but tracks `ANALYZE` invocations
65    /// rather than data mutations. Including this in the cache key ensures
66    /// that running `ANALYZE` alone — with no intermediate write to bump
67    /// `write_generation` — still invalidates plans whose cost estimates
68    /// were derived from pre-analyze heuristics.
69    pub analyze_generations: SmallVec<[u64; 4]>,
70}
71
72impl PartialEq for PlanKey {
73    /// Equality compares the **canonical query text** (collision-safe), never
74    /// `query_hash` alone (issue #902). The generation fields gate cache
75    /// invalidation.
76    fn eq(&self, other: &Self) -> bool {
77        self.schema_version == other.schema_version
78            && self.collection_generations == other.collection_generations
79            && self.analyze_generations == other.analyze_generations
80            && self.query_text == other.query_text
81    }
82}
83
84impl Eq for PlanKey {}
85
86impl std::hash::Hash for PlanKey {
87    /// Feeds only the cheap `query_hash` accelerator (plus generation fields)
88    /// into the hasher — never the full `query_text` — so lookups stay fast.
89    /// Equal keys hash identically because `query_hash` is a pure function of
90    /// `query_text`.
91    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
92        self.query_hash.hash(state);
93        self.schema_version.hash(state);
94        self.collection_generations.hash(state);
95        self.analyze_generations.hash(state);
96    }
97}
98
99/// A compiled (cached) query execution plan.
100///
101/// Stored behind `Arc` in the cache; the cache value type is
102/// `Arc<CompiledPlan>` so `Clone` is not required on this struct.
103#[derive(Debug)]
104pub struct CompiledPlan {
105    /// The query plan produced by the planner.
106    pub plan: QueryPlan,
107    /// Collections referenced by this plan (for invalidation checks).
108    ///
109    /// Currently stale-key detection in `build_plan_key` handles invalidation:
110    /// when a collection's `write_generation` changes the key no longer matches
111    /// anything in the cache so a fresh plan is compiled on the next call.
112    ///
113    /// Future work (CACHE-01): use `referenced_collections` for targeted
114    /// invalidation — evict only plans that touch a mutated collection rather
115    /// than relying on stale-key detection. This requires an inverted index
116    /// from collection name to `PlanKey` and would reduce spurious misses in
117    /// multi-collection workloads.
118    pub referenced_collections: Vec<String>,
119    /// When this plan was compiled.
120    pub compiled_at: std::time::Instant,
121    /// How many times this cached plan has been reused.
122    pub reuse_count: AtomicU64,
123}
124
125/// Global cache hit/miss counters.
126#[derive(Debug, Default)]
127pub struct PlanCacheMetrics {
128    /// Total cache hits.
129    pub hits: AtomicU64,
130    /// Total cache misses.
131    pub misses: AtomicU64,
132}
133
134impl PlanCacheMetrics {
135    /// Records a cache hit.
136    pub fn record_hit(&self) {
137        self.hits.fetch_add(1, Ordering::Relaxed);
138    }
139
140    /// Records a cache miss.
141    pub fn record_miss(&self) {
142        self.misses.fetch_add(1, Ordering::Relaxed);
143    }
144
145    /// Returns total hits.
146    #[must_use]
147    pub fn hits(&self) -> u64 {
148        self.hits.load(Ordering::Relaxed)
149    }
150
151    /// Returns total misses.
152    #[must_use]
153    pub fn misses(&self) -> u64 {
154        self.misses.load(Ordering::Relaxed)
155    }
156
157    /// Returns the hit rate as a ratio in `[0.0, 1.0]`.
158    #[must_use]
159    #[allow(clippy::cast_precision_loss)]
160    pub fn hit_rate(&self) -> f64 {
161        let h = self.hits();
162        let m = self.misses();
163        let total = h + m;
164        if total == 0 {
165            0.0
166        } else {
167            // Precision loss is acceptable: hit rate is a diagnostic metric,
168            // not a value used in any computation where exactness matters.
169            h as f64 / total as f64
170        }
171    }
172}
173
174/// Thin wrapper around `LockFreeLruCache` for compiled query plans.
175///
176/// Tracks hit/miss metrics and delegates storage to the lock-free two-tier cache.
177pub struct CompiledPlanCache {
178    cache: LockFreeLruCache<PlanKey, Arc<CompiledPlan>>,
179    metrics: PlanCacheMetrics,
180}
181
182impl fmt::Debug for CompiledPlanCache {
183    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184        let stats = self.cache.stats();
185        f.debug_struct("CompiledPlanCache")
186            .field("l1_size", &stats.l1_size)
187            .field("l2_size", &stats.l2_size)
188            .field("hits", &self.metrics.hits())
189            .field("misses", &self.metrics.misses())
190            .finish()
191    }
192}
193
194impl CompiledPlanCache {
195    /// Creates a new compiled plan cache.
196    ///
197    /// # Arguments
198    ///
199    /// * `l1_capacity` - Maximum entries in L1 (hot cache)
200    /// * `l2_capacity` - Maximum entries in L2 (LRU backing store)
201    #[must_use]
202    pub fn new(l1_capacity: usize, l2_capacity: usize) -> Self {
203        Self {
204            cache: LockFreeLruCache::new(l1_capacity, l2_capacity),
205            metrics: PlanCacheMetrics::default(),
206        }
207    }
208
209    /// Returns `true` if a plan for `key` exists in the cache.
210    ///
211    /// Unlike [`get`](Self::get), this method does **not** record a hit or miss
212    /// in the metrics counters and does **not** increment `reuse_count`. It is
213    /// intended for existence checks (e.g. deciding whether to insert a newly
214    /// compiled plan) where polluting the metrics would distort hit-rate
215    /// calculations.
216    #[must_use]
217    pub fn contains(&self, key: &PlanKey) -> bool {
218        // Check L1 first (lock-free DashMap), then L2 (LRU behind a mutex).
219        // Using peek_l1 / peek_l2 avoids the LRU promotion that `get` would
220        // trigger, keeping the hot-path ordering stable.
221        self.cache.peek_l1(key).is_some() || self.cache.peek_l2(key).is_some()
222    }
223
224    /// Looks up a compiled plan by key, recording hit/miss.
225    #[must_use]
226    pub fn get(&self, key: &PlanKey) -> Option<Arc<CompiledPlan>> {
227        if let Some(plan) = self.cache.get(key) {
228            self.metrics.record_hit();
229            plan.reuse_count.fetch_add(1, Ordering::Relaxed);
230            Some(plan)
231        } else {
232            self.metrics.record_miss();
233            None
234        }
235    }
236
237    /// Inserts a compiled plan into the cache.
238    pub fn insert(&self, key: PlanKey, plan: Arc<CompiledPlan>) {
239        self.cache.insert(key, plan);
240    }
241
242    /// Returns the underlying cache statistics.
243    #[must_use]
244    pub fn stats(&self) -> super::LockFreeCacheStats {
245        self.cache.stats()
246    }
247
248    /// Returns a reference to the plan cache metrics.
249    #[must_use]
250    pub fn metrics(&self) -> &PlanCacheMetrics {
251        &self.metrics
252    }
253
254    /// Clears all cached plans from both L1 and L2 tiers.
255    ///
256    /// This does **not** reset the hit/miss metrics counters.
257    pub fn clear(&self) {
258        self.cache.clear();
259    }
260}