Skip to main content

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