oracle_rs/
statement_cache.rs

1//! Statement caching for improved performance
2//!
3//! This module provides client-side statement caching to avoid repeated
4//! parsing of SQL statements on the Oracle server. When a statement is
5//! executed, its cursor ID and metadata are cached. Subsequent executions
6//! of the same SQL text can reuse the cached cursor, skipping the parse phase.
7
8use indexmap::IndexMap;
9use std::time::Instant;
10
11use crate::statement::Statement;
12
13/// Wrapper for a cached statement with usage tracking
14#[derive(Debug)]
15struct CachedStatement {
16    /// The cached statement with cursor_id and metadata
17    statement: Statement,
18    /// Whether the statement is currently in use
19    in_use: bool,
20    /// When this statement was last used
21    last_used: Instant,
22}
23
24impl CachedStatement {
25    fn new(statement: Statement) -> Self {
26        Self {
27            statement,
28            in_use: false,
29            last_used: Instant::now(),
30        }
31    }
32
33    fn touch(&mut self) {
34        self.last_used = Instant::now();
35    }
36}
37
38/// Client-side statement cache using LRU eviction
39///
40/// The cache stores prepared statements keyed by their SQL text.
41/// When a statement is retrieved from cache, its cursor ID is preserved,
42/// allowing Oracle to skip parsing and use the cached server cursor.
43///
44/// # Example
45///
46/// ```ignore
47/// // Statement caching is automatic when enabled via config
48/// let mut config = Config::new("localhost", 1521, "FREEPDB1", "user", "pass");
49/// config.set_stmtcachesize(20);  // Enable with 20 statement cache
50///
51/// let conn = Connection::connect_with_config(config).await?;
52///
53/// // First call: parses SQL, gets cursor_id from Oracle
54/// conn.query("SELECT * FROM users WHERE id = :1", &[Value::Integer(1)]).await?;
55///
56/// // Second call: reuses cached cursor, no re-parsing!
57/// conn.query("SELECT * FROM users WHERE id = :1", &[Value::Integer(2)]).await?;
58/// ```
59#[derive(Debug)]
60pub struct StatementCache {
61    /// The cache using IndexMap for O(1) lookup + LRU ordering
62    cache: IndexMap<String, CachedStatement>,
63    /// Maximum number of statements to cache
64    max_size: usize,
65    /// Cursor IDs that need to be closed on the server
66    /// These are accumulated and sent piggybacked on the next message
67    cursors_to_close: Vec<u16>,
68}
69
70impl StatementCache {
71    /// Create a new statement cache with the given maximum size
72    ///
73    /// A size of 0 effectively disables caching.
74    pub fn new(max_size: usize) -> Self {
75        Self {
76            cache: IndexMap::with_capacity(max_size),
77            max_size,
78            cursors_to_close: Vec::new(),
79        }
80    }
81
82    /// Get a statement from the cache, if available
83    ///
84    /// Returns a clone of the cached statement with preserved cursor_id and metadata.
85    /// If the cached statement is already in use, returns a fresh statement.
86    /// Updates LRU ordering on hit.
87    pub fn get(&mut self, sql: &str) -> Option<Statement> {
88        if self.max_size == 0 {
89            return None;
90        }
91
92        // Check if we have this SQL cached
93        if let Some(cached) = self.cache.get_mut(sql) {
94            cached.touch();
95
96            if cached.in_use {
97                // Statement is in use - return a fresh statement
98                // The caller will get a new cursor from Oracle
99                tracing::trace!(sql = sql, "Statement cache hit but in use, returning fresh");
100                return None;
101            }
102
103            // Mark as in use and return a clone for reuse
104            cached.in_use = true;
105            tracing::trace!(
106                sql = sql,
107                cursor_id = cached.statement.cursor_id(),
108                "Statement cache hit"
109            );
110            return Some(cached.statement.clone_for_reuse());
111        }
112
113        tracing::trace!(sql = sql, "Statement cache miss");
114        None
115    }
116
117    /// Store a statement in the cache
118    ///
119    /// DDL statements are never cached. If the cache is full, the least
120    /// recently used statement is evicted and its cursor ID is queued for closing.
121    pub fn put(&mut self, sql: String, statement: Statement) {
122        if self.max_size == 0 {
123            return;
124        }
125
126        // Never cache DDL statements (CREATE, ALTER, DROP, etc.)
127        if statement.is_ddl() {
128            tracing::trace!(sql = sql, "Not caching DDL statement");
129            return;
130        }
131
132        // Don't cache statements without a cursor_id (not yet executed)
133        if statement.cursor_id() == 0 {
134            tracing::trace!(sql = sql, "Not caching statement without cursor_id");
135            return;
136        }
137
138        // Check if already cached (update it)
139        if let Some(cached) = self.cache.get_mut(&sql) {
140            cached.statement = statement;
141            cached.in_use = false;
142            cached.touch();
143            tracing::trace!(sql = sql, "Updated existing cache entry");
144            return;
145        }
146
147        // Evict LRU entry if cache is full
148        if self.cache.len() >= self.max_size {
149            self.evict_lru();
150        }
151
152        tracing::trace!(
153            sql = sql,
154            cursor_id = statement.cursor_id(),
155            "Adding statement to cache"
156        );
157        self.cache.insert(sql, CachedStatement::new(statement));
158    }
159
160    /// Return a statement to the cache after use
161    ///
162    /// This marks the statement as no longer in use so it can be reused.
163    pub fn return_statement(&mut self, sql: &str) {
164        if let Some(cached) = self.cache.get_mut(sql) {
165            cached.in_use = false;
166            tracing::trace!(sql = sql, "Statement returned to cache");
167        }
168    }
169
170    /// Get and clear the list of cursor IDs that need to be closed
171    ///
172    /// These cursor IDs should be sent to the server on the next message.
173    pub fn take_cursors_to_close(&mut self) -> Vec<u16> {
174        std::mem::take(&mut self.cursors_to_close)
175    }
176
177    /// Clear all cached statements
178    ///
179    /// All cursor IDs are queued for closing. This should be called
180    /// when the session changes (e.g., DRCP session switch).
181    pub fn clear(&mut self) {
182        for (_, cached) in self.cache.drain(..) {
183            if cached.statement.cursor_id() != 0 {
184                self.cursors_to_close.push(cached.statement.cursor_id());
185            }
186        }
187        tracing::debug!(
188            cursors_to_close = self.cursors_to_close.len(),
189            "Statement cache cleared"
190        );
191    }
192
193    /// Get the current number of cached statements
194    pub fn len(&self) -> usize {
195        self.cache.len()
196    }
197
198    /// Check if the cache is empty
199    pub fn is_empty(&self) -> bool {
200        self.cache.is_empty()
201    }
202
203    /// Get the maximum cache size
204    pub fn max_size(&self) -> usize {
205        self.max_size
206    }
207
208    /// Evict the least recently used entry
209    fn evict_lru(&mut self) {
210        // Find the LRU entry (first entry that's not in use)
211        let lru_key = self
212            .cache
213            .iter()
214            .filter(|(_, cached)| !cached.in_use)
215            .min_by_key(|(_, cached)| cached.last_used)
216            .map(|(key, _)| key.clone());
217
218        if let Some(key) = lru_key {
219            if let Some(cached) = self.cache.swap_remove(&key) {
220                // Queue the cursor for closing
221                if cached.statement.cursor_id() != 0 {
222                    self.cursors_to_close.push(cached.statement.cursor_id());
223                }
224                tracing::trace!(
225                    sql = key,
226                    cursor_id = cached.statement.cursor_id(),
227                    "Evicted LRU statement from cache"
228                );
229            }
230        } else {
231            // All statements are in use - this is rare but possible
232            tracing::warn!("Statement cache full and all statements in use");
233        }
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    fn make_test_statement(sql: &str, cursor_id: u16) -> Statement {
242        let mut stmt = Statement::new(sql);
243        stmt.set_cursor_id(cursor_id);
244        stmt.set_executed(true);
245        stmt
246    }
247
248    #[test]
249    fn test_cache_basic() {
250        let mut cache = StatementCache::new(5);
251
252        // Add a statement
253        let stmt = make_test_statement("SELECT 1 FROM DUAL", 100);
254        cache.put("SELECT 1 FROM DUAL".to_string(), stmt);
255
256        assert_eq!(cache.len(), 1);
257
258        // Retrieve it
259        let cached = cache.get("SELECT 1 FROM DUAL").expect("Should be cached");
260        assert_eq!(cached.cursor_id(), 100);
261
262        // Return it
263        cache.return_statement("SELECT 1 FROM DUAL");
264    }
265
266    #[test]
267    fn test_cache_miss() {
268        let mut cache = StatementCache::new(5);
269        assert!(cache.get("SELECT 1 FROM DUAL").is_none());
270    }
271
272    #[test]
273    fn test_cache_disabled() {
274        let mut cache = StatementCache::new(0);
275
276        let stmt = make_test_statement("SELECT 1 FROM DUAL", 100);
277        cache.put("SELECT 1 FROM DUAL".to_string(), stmt);
278
279        assert_eq!(cache.len(), 0);
280        assert!(cache.get("SELECT 1 FROM DUAL").is_none());
281    }
282
283    #[test]
284    fn test_ddl_not_cached() {
285        let mut cache = StatementCache::new(5);
286
287        let mut stmt = Statement::new("CREATE TABLE test (id NUMBER)");
288        stmt.set_cursor_id(100);
289        cache.put("CREATE TABLE test (id NUMBER)".to_string(), stmt);
290
291        assert_eq!(cache.len(), 0);
292    }
293
294    #[test]
295    fn test_no_cursor_not_cached() {
296        let mut cache = StatementCache::new(5);
297
298        // Statement without cursor_id should not be cached
299        let stmt = Statement::new("SELECT 1 FROM DUAL");
300        cache.put("SELECT 1 FROM DUAL".to_string(), stmt);
301
302        assert_eq!(cache.len(), 0);
303    }
304
305    #[test]
306    fn test_lru_eviction() {
307        let mut cache = StatementCache::new(3);
308
309        // Add 3 statements
310        cache.put(
311            "SELECT 1 FROM DUAL".to_string(),
312            make_test_statement("SELECT 1 FROM DUAL", 1),
313        );
314        cache.put(
315            "SELECT 2 FROM DUAL".to_string(),
316            make_test_statement("SELECT 2 FROM DUAL", 2),
317        );
318        cache.put(
319            "SELECT 3 FROM DUAL".to_string(),
320            make_test_statement("SELECT 3 FROM DUAL", 3),
321        );
322
323        assert_eq!(cache.len(), 3);
324
325        // Access the first one to make it recently used
326        cache.get("SELECT 1 FROM DUAL");
327        cache.return_statement("SELECT 1 FROM DUAL");
328
329        // Add a 4th - should evict "SELECT 2" (LRU)
330        cache.put(
331            "SELECT 4 FROM DUAL".to_string(),
332            make_test_statement("SELECT 4 FROM DUAL", 4),
333        );
334
335        assert_eq!(cache.len(), 3);
336        assert!(cache.get("SELECT 2 FROM DUAL").is_none()); // Evicted
337        assert!(cache.get("SELECT 1 FROM DUAL").is_some()); // Still there
338
339        // Check cursor 2 is queued for closing
340        let to_close = cache.take_cursors_to_close();
341        assert!(to_close.contains(&2));
342    }
343
344    #[test]
345    fn test_in_use_not_returned() {
346        let mut cache = StatementCache::new(5);
347
348        cache.put(
349            "SELECT 1 FROM DUAL".to_string(),
350            make_test_statement("SELECT 1 FROM DUAL", 100),
351        );
352
353        // Get the statement (marks it in use)
354        let _ = cache.get("SELECT 1 FROM DUAL");
355
356        // Try to get it again - should return None because it's in use
357        assert!(cache.get("SELECT 1 FROM DUAL").is_none());
358
359        // Return it
360        cache.return_statement("SELECT 1 FROM DUAL");
361
362        // Now we can get it again
363        assert!(cache.get("SELECT 1 FROM DUAL").is_some());
364    }
365
366    #[test]
367    fn test_clear() {
368        let mut cache = StatementCache::new(5);
369
370        cache.put(
371            "SELECT 1 FROM DUAL".to_string(),
372            make_test_statement("SELECT 1 FROM DUAL", 1),
373        );
374        cache.put(
375            "SELECT 2 FROM DUAL".to_string(),
376            make_test_statement("SELECT 2 FROM DUAL", 2),
377        );
378
379        assert_eq!(cache.len(), 2);
380
381        cache.clear();
382
383        assert_eq!(cache.len(), 0);
384
385        // Cursor IDs should be queued for closing
386        let to_close = cache.take_cursors_to_close();
387        assert_eq!(to_close.len(), 2);
388        assert!(to_close.contains(&1));
389        assert!(to_close.contains(&2));
390    }
391
392    #[test]
393    fn test_update_existing() {
394        let mut cache = StatementCache::new(5);
395
396        cache.put(
397            "SELECT 1 FROM DUAL".to_string(),
398            make_test_statement("SELECT 1 FROM DUAL", 100),
399        );
400
401        // Update with new cursor_id
402        cache.put(
403            "SELECT 1 FROM DUAL".to_string(),
404            make_test_statement("SELECT 1 FROM DUAL", 200),
405        );
406
407        assert_eq!(cache.len(), 1);
408
409        let cached = cache.get("SELECT 1 FROM DUAL").unwrap();
410        assert_eq!(cached.cursor_id(), 200);
411    }
412}