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}