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}