Skip to main content

sift_query_sqlx/cache/
mod.rs

1use std::sync::atomic::{AtomicU64, Ordering};
2use std::sync::{Arc, RwLock};
3
4use sift_query_algorithm::BoundedLruMap;
5use sift_query_core::ConfigError;
6
7use crate::{QueryShapeId, RenderedPgQuery};
8
9mod template;
10use crate::{PgRenderer, RenderError};
11use sift_query_core::{FieldId, QueryPlan, SearchEntity, SearchOptions};
12pub(crate) use template::RenderRequest;
13use template::{Template, TemplateKey};
14
15#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16enum CacheKey {
17    Rendered(QueryShapeId),
18    Template(Arc<TemplateKey>),
19}
20
21#[derive(Debug, Clone)]
22enum CacheEntry {
23    Sql(Arc<str>),
24    Template(Arc<Template>),
25}
26
27/// Cumulative observations for a bounded SQL-shape cache.
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
29pub struct ShapeCacheStats {
30    pub hits: u64,
31    pub misses: u64,
32    pub insertions: u64,
33    pub evictions: u64,
34    pub collisions: u64,
35    pub entries: usize,
36    pub capacity: usize,
37}
38
39/// Bounded LRU cache for SQL templates and value-free bind instructions.
40///
41/// Bind values are deliberately never cached. Template lookup compares the
42/// complete structured key, preserving correctness across hash collisions.
43/// The legacy `intern` entry point compares the complete SQL text.
44#[derive(Debug)]
45pub struct QueryShapeCache {
46    state: RwLock<CacheState>,
47}
48
49#[derive(Debug)]
50struct CacheState {
51    entries: BoundedLruMap<CacheKey, CacheEntry>,
52    hits: AtomicU64,
53    misses: u64,
54    insertions: u64,
55    evictions: u64,
56    collisions: u64,
57}
58
59impl QueryShapeCache {
60    pub fn new(capacity: usize) -> Result<Self, ConfigError> {
61        let entries = BoundedLruMap::new(capacity)
62            .map_err(|error| ConfigError::Invalid(format!("shape cache {error}")))?;
63        Ok(Self {
64            state: RwLock::new(CacheState {
65                entries,
66                hits: AtomicU64::new(0),
67                misses: 0,
68                insertions: 0,
69                evictions: 0,
70                collisions: 0,
71            }),
72        })
73    }
74
75    /// Reuses the cached SQL allocation when this exact shape is present.
76    #[must_use]
77    pub fn intern(&self, mut query: RenderedPgQuery) -> (RenderedPgQuery, bool) {
78        let mut state = self
79            .state
80            .write()
81            .unwrap_or_else(std::sync::PoisonError::into_inner);
82        let key = CacheKey::Rendered(query.shape_id);
83        if let Some(CacheEntry::Sql(sql)) = state.entries.get(&key).cloned() {
84            if sql.as_ref() == query.sql.as_ref() {
85                query.sql = sql;
86                increment_hits(&state.hits);
87                return (query, true);
88            }
89            state.collisions = state.collisions.saturating_add(1);
90            state.misses = state.misses.saturating_add(1);
91            return (query, false);
92        }
93
94        state.misses = state.misses.saturating_add(1);
95        if state
96            .entries
97            .insert(key, CacheEntry::Sql(Arc::clone(&query.sql)))
98        {
99            state.evictions = state.evictions.saturating_add(1);
100        }
101        state.insertions = state.insertions.saturating_add(1);
102        (query, false)
103    }
104
105    /// Renders on a miss; on a hit, only rebuilds this request's bind values.
106    pub fn render_list<E: SearchEntity>(
107        &self,
108        plan: &QueryPlan<E>,
109    ) -> Result<(RenderedPgQuery, bool), RenderError> {
110        self.render(plan, &PgRenderer, &RenderRequest::List)
111    }
112
113    pub fn render_cluster<E: SearchEntity>(
114        &self,
115        plan: &QueryPlan<E>,
116        options: Option<&SearchOptions<E>>,
117    ) -> Result<(RenderedPgQuery, bool), RenderError> {
118        self.render(plan, &PgRenderer, &RenderRequest::Cluster(options))
119    }
120
121    pub fn render_sum<E: SearchEntity>(
122        &self,
123        plan: &QueryPlan<E>,
124        field: FieldId,
125    ) -> Result<(RenderedPgQuery, bool), RenderError> {
126        self.render(plan, &PgRenderer, &RenderRequest::Sum(field))
127    }
128
129    pub fn render_window_list<E: SearchEntity>(
130        &self,
131        plan: &QueryPlan<E>,
132    ) -> Result<(RenderedPgQuery, bool), RenderError> {
133        self.render(plan, &PgRenderer, &RenderRequest::Window)
134    }
135
136    pub(crate) fn render<E: SearchEntity>(
137        &self,
138        plan: &QueryPlan<E>,
139        renderer: &PgRenderer,
140        request: &RenderRequest<'_, E>,
141    ) -> Result<(RenderedPgQuery, bool), RenderError> {
142        if matches!(request, RenderRequest::Window) && !renderer.supports_window_count(plan) {
143            return Err(RenderError::WrongTarget);
144        }
145        let key = CacheKey::Template(Arc::new(TemplateKey::new(plan, request)?));
146        // A hit on the current MRU entry cannot change eviction order. Let
147        // repeated readers proceed together, without weakening exact LRU.
148        {
149            let state = self
150                .state
151                .read()
152                .unwrap_or_else(std::sync::PoisonError::into_inner);
153            if let Some(CacheEntry::Template(template)) = state.entries.peek_mru(&key) {
154                let template = Arc::clone(template);
155                increment_hits(&state.hits);
156                drop(state);
157                return template.bind(plan).map(|query| (query, true));
158            }
159        }
160        {
161            let mut state = self
162                .state
163                .write()
164                .unwrap_or_else(std::sync::PoisonError::into_inner);
165            if let Some(CacheEntry::Template(template)) = state.entries.get(&key).cloned() {
166                increment_hits(&state.hits);
167                drop(state);
168                return template.bind(plan).map(|query| (query, true));
169            }
170            state.misses = state.misses.saturating_add(1);
171        }
172        // Never hold the cache lock while rendering or rebuilding binds.
173        let query = request.render(renderer, plan)?;
174        let template = Arc::new(Template::new(plan, request, &query)?);
175        let mut state = self
176            .state
177            .write()
178            .unwrap_or_else(std::sync::PoisonError::into_inner);
179        if state.entries.insert(key, CacheEntry::Template(template)) {
180            state.evictions = state.evictions.saturating_add(1);
181        }
182        state.insertions = state.insertions.saturating_add(1);
183        Ok((query, false))
184    }
185
186    #[must_use]
187    pub fn stats(&self) -> ShapeCacheStats {
188        let state = self
189            .state
190            .read()
191            .unwrap_or_else(std::sync::PoisonError::into_inner);
192        ShapeCacheStats {
193            hits: state.hits.load(Ordering::Relaxed),
194            misses: state.misses,
195            insertions: state.insertions,
196            evictions: state.evictions,
197            collisions: state.collisions,
198            entries: state.entries.len(),
199            capacity: state.entries.capacity(),
200        }
201    }
202
203    pub fn clear(&self) {
204        let mut state = self
205            .state
206            .write()
207            .unwrap_or_else(std::sync::PoisonError::into_inner);
208        state.entries.clear();
209    }
210}
211
212fn increment_hits(hits: &AtomicU64) {
213    let _ = hits.try_update(Ordering::Relaxed, Ordering::Relaxed, |count| {
214        Some(count.saturating_add(1))
215    });
216}