Skip to main content

prax_postgres/
statement.rs

1//! Prepared statement caching.
2
3use std::num::NonZeroUsize;
4use std::sync::Mutex;
5
6use deadpool_postgres::{Object, Transaction};
7use lru::LruCache;
8use tokio_postgres::Statement;
9use tracing::{debug, trace};
10
11use crate::error::PgResult;
12
13/// A cache for prepared statements.
14///
15/// Tracks which SQL strings have been prepared so we emit a `trace!`
16/// for hits vs. misses. Eviction is true LRU via [`lru::LruCache`] —
17/// when the cache reaches `max_size` the least-recently-used entry is
18/// dropped on the next insert.
19///
20/// The cache is keyed on the SQL string; the actual `Statement` is
21/// fetched from `client.prepare_cached` on every call (deadpool reuses
22/// its own per-connection cache).
23pub struct PreparedStatementCache {
24    max_size: usize,
25    /// LRU cache of SQL strings we've seen. The value is `()` because
26    /// the real `Statement` lives in deadpool-postgres' per-connection
27    /// cache; we just need to know whether we've encountered the SQL
28    /// before for tracing/metrics. `Mutex` (not `RwLock`) because every
29    /// `get_or_prepare` mutates LRU order, so the read-only path
30    /// doesn't exist.
31    prepared_queries: Mutex<LruCache<String, ()>>,
32}
33
34impl PreparedStatementCache {
35    /// Create a new statement cache with the given maximum size.
36    ///
37    /// `max_size` of 0 is treated as 1 to satisfy `NonZeroUsize`.
38    pub fn new(max_size: usize) -> Self {
39        let cap = NonZeroUsize::new(max_size.max(1)).expect("max(1) ensures non-zero");
40        Self {
41            max_size,
42            prepared_queries: Mutex::new(LruCache::new(cap)),
43        }
44    }
45
46    /// Get or prepare a statement for the given SQL.
47    pub async fn get_or_prepare(&self, client: &Object, sql: &str) -> PgResult<Statement> {
48        let is_cached = {
49            let mut cache = self
50                .prepared_queries
51                .lock()
52                .unwrap_or_else(|e| e.into_inner());
53            if cache.get(sql).is_some() {
54                true
55            } else {
56                cache.put(sql.to_string(), ());
57                false
58            }
59        };
60
61        if is_cached {
62            trace!(sql = %sql, "Using cached prepared statement");
63        } else {
64            trace!(sql = %sql, "Preparing new statement");
65        }
66
67        // Always prepare - the database will reuse if it's cached server-side
68        let stmt = client.prepare_cached(sql).await?;
69        Ok(stmt)
70    }
71
72    /// Get or prepare a statement within a transaction.
73    pub async fn get_or_prepare_in_txn<'a>(
74        &self,
75        txn: &Transaction<'a>,
76        sql: &str,
77    ) -> PgResult<Statement> {
78        let is_cached = {
79            let mut cache = self
80                .prepared_queries
81                .lock()
82                .unwrap_or_else(|e| e.into_inner());
83            if cache.get(sql).is_some() {
84                true
85            } else {
86                cache.put(sql.to_string(), ());
87                false
88            }
89        };
90
91        if is_cached {
92            trace!(sql = %sql, "Using cached prepared statement (txn)");
93        } else {
94            trace!(sql = %sql, "Preparing new statement (txn)");
95        }
96
97        let stmt = txn.prepare_cached(sql).await?;
98        Ok(stmt)
99    }
100
101    /// Clear all cached statements.
102    pub fn clear(&self) {
103        let mut cache = self
104            .prepared_queries
105            .lock()
106            .unwrap_or_else(|e| e.into_inner());
107        cache.clear();
108        debug!("Statement cache cleared");
109    }
110
111    /// Forget a single cached SQL string, so the next `get_or_prepare` for it
112    /// records a miss and re-prepares.
113    ///
114    /// Used to recover from PostgreSQL `0A000 "cached plan must not change
115    /// result type"`: DDL altered a referenced table's result columns while a
116    /// prepared statement for this SQL was cached on the connection. Dropping
117    /// the key here, combined with a fresh (uncached) prepare on the retry,
118    /// re-plans against the current schema. Returns whether the key was
119    /// present.
120    pub fn evict(&self, sql: &str) -> bool {
121        let mut cache = self
122            .prepared_queries
123            .lock()
124            .unwrap_or_else(|e| e.into_inner());
125        let existed = cache.pop(sql).is_some();
126        if existed {
127            debug!(sql = %sql, "Evicted stale prepared statement from cache");
128        }
129        existed
130    }
131
132    /// Get the number of cached statement keys.
133    pub fn len(&self) -> usize {
134        let cache = self
135            .prepared_queries
136            .lock()
137            .unwrap_or_else(|e| e.into_inner());
138        cache.len()
139    }
140
141    /// Check if the cache is empty.
142    pub fn is_empty(&self) -> bool {
143        self.len() == 0
144    }
145
146    /// Get the maximum cache size.
147    pub fn max_size(&self) -> usize {
148        self.max_size
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn test_cache_creation() {
158        let cache = PreparedStatementCache::new(100);
159        assert_eq!(cache.max_size(), 100);
160        assert!(cache.is_empty());
161    }
162
163    #[test]
164    fn test_cache_clear() {
165        let cache = PreparedStatementCache::new(100);
166
167        // Manually insert some entries for testing
168        {
169            let mut inner = cache.prepared_queries.lock().unwrap();
170            inner.put("SELECT 1".to_string(), ());
171            inner.put("SELECT 2".to_string(), ());
172        }
173
174        assert_eq!(cache.len(), 2);
175        cache.clear();
176        assert!(cache.is_empty());
177    }
178
179    #[test]
180    fn test_cache_lru_eviction() {
181        let cache = PreparedStatementCache::new(2);
182        {
183            let mut inner = cache.prepared_queries.lock().unwrap();
184            inner.put("A".to_string(), ());
185            inner.put("B".to_string(), ());
186            // Touch A so B becomes LRU.
187            let _ = inner.get("A");
188            inner.put("C".to_string(), ());
189        }
190        let inner = cache.prepared_queries.lock().unwrap();
191        assert_eq!(inner.len(), 2);
192        assert!(inner.peek("A").is_some());
193        assert!(inner.peek("B").is_none(), "B should have been evicted");
194        assert!(inner.peek("C").is_some());
195    }
196
197    #[test]
198    fn test_evict_removes_only_the_named_sql() {
199        let cache = PreparedStatementCache::new(10);
200        {
201            let mut inner = cache.prepared_queries.lock().unwrap();
202            inner.put("SELECT 1".to_string(), ());
203            inner.put("SELECT 2".to_string(), ());
204        }
205        // Evicting a present key reports true and drops just that entry.
206        assert!(cache.evict("SELECT 1"));
207        {
208            let inner = cache.prepared_queries.lock().unwrap();
209            assert!(inner.peek("SELECT 1").is_none());
210            assert!(inner.peek("SELECT 2").is_some());
211        }
212        // Evicting an absent key is a no-op reporting false.
213        assert!(!cache.evict("SELECT 1"));
214        assert!(!cache.evict("never cached"));
215    }
216}