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}