Skip to main content

synaptic_sqlite/
cache.rs

1use std::sync::{Arc, Mutex};
2
3use async_trait::async_trait;
4use rusqlite::Connection;
5use synaptic_core::{ChatResponse, SynapticError};
6
7/// Configuration for [`SqliteCache`].
8#[derive(Debug, Clone)]
9pub struct SqliteCacheConfig {
10    /// Path to the SQLite database file. Use `":memory:"` for an in-memory database.
11    pub path: String,
12    /// Optional TTL in seconds. When set, cached entries older than this are
13    /// treated as expired and excluded from lookups.
14    pub ttl: Option<u64>,
15}
16
17impl SqliteCacheConfig {
18    /// Create a new configuration with a file path.
19    pub fn new(path: impl Into<String>) -> Self {
20        Self {
21            path: path.into(),
22            ttl: None,
23        }
24    }
25
26    /// Create a configuration for an in-memory SQLite database.
27    pub fn in_memory() -> Self {
28        Self {
29            path: ":memory:".to_string(),
30            ttl: None,
31        }
32    }
33
34    /// Set the TTL (time-to-live) in seconds for cached entries.
35    pub fn with_ttl(mut self, seconds: u64) -> Self {
36        self.ttl = Some(seconds);
37        self
38    }
39}
40
41/// SQLite-backed implementation of the [`LlmCache`](synaptic_core::LlmCache) trait.
42///
43/// Stores serialized [`ChatResponse`] values in a SQLite table with optional
44/// TTL expiration. Uses `tokio::task::spawn_blocking` to avoid blocking the
45/// async runtime during SQLite operations.
46pub struct SqliteCache {
47    conn: Arc<Mutex<Connection>>,
48    ttl: Option<u64>,
49}
50
51impl SqliteCache {
52    /// Create a new `SqliteCache` from the given configuration.
53    ///
54    /// This opens (or creates) the SQLite database and initializes the cache
55    /// table if it does not already exist.
56    pub fn new(config: SqliteCacheConfig) -> Result<Self, SynapticError> {
57        let conn = Connection::open(&config.path)
58            .map_err(|e| SynapticError::Cache(format!("SQLite open error: {e}")))?;
59
60        conn.execute(
61            "CREATE TABLE IF NOT EXISTS llm_cache (
62                key TEXT PRIMARY KEY,
63                value TEXT NOT NULL,
64                created_at INTEGER NOT NULL DEFAULT (unixepoch())
65            )",
66            [],
67        )
68        .map_err(|e| SynapticError::Cache(format!("SQLite create table error: {e}")))?;
69
70        Ok(Self {
71            conn: Arc::new(Mutex::new(conn)),
72            ttl: config.ttl,
73        })
74    }
75}
76
77#[async_trait]
78impl synaptic_core::LlmCache for SqliteCache {
79    async fn get(&self, key: &str) -> Result<Option<ChatResponse>, SynapticError> {
80        let conn = self.conn.clone();
81        let ttl = self.ttl;
82        let key = key.to_string();
83
84        tokio::task::spawn_blocking(move || {
85            let conn = conn
86                .lock()
87                .map_err(|e| SynapticError::Cache(format!("lock error: {e}")))?;
88
89            let query = if ttl.is_some() {
90                "SELECT value FROM llm_cache WHERE key = ?1 AND created_at + ?2 > unixepoch()"
91            } else {
92                "SELECT value FROM llm_cache WHERE key = ?1"
93            };
94
95            let mut stmt = conn
96                .prepare(query)
97                .map_err(|e| SynapticError::Cache(format!("SQLite prepare error: {e}")))?;
98
99            let result = if let Some(ttl) = ttl {
100                stmt.query_row(rusqlite::params![key, ttl as i64], |row| {
101                    row.get::<_, String>(0)
102                })
103            } else {
104                stmt.query_row(rusqlite::params![key], |row| row.get::<_, String>(0))
105            };
106
107            match result {
108                Ok(json_str) => {
109                    let response: ChatResponse = serde_json::from_str(&json_str).map_err(|e| {
110                        SynapticError::Cache(format!("JSON deserialize error: {e}"))
111                    })?;
112                    Ok(Some(response))
113                }
114                Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
115                Err(e) => Err(SynapticError::Cache(format!("SQLite query error: {e}"))),
116            }
117        })
118        .await
119        .map_err(|e| SynapticError::Cache(format!("spawn_blocking error: {e}")))?
120    }
121
122    async fn put(&self, key: &str, response: &ChatResponse) -> Result<(), SynapticError> {
123        let conn = self.conn.clone();
124        let key = key.to_string();
125        let value = serde_json::to_string(response)
126            .map_err(|e| SynapticError::Cache(format!("JSON serialize error: {e}")))?;
127
128        tokio::task::spawn_blocking(move || {
129            let conn = conn
130                .lock()
131                .map_err(|e| SynapticError::Cache(format!("lock error: {e}")))?;
132
133            conn.execute(
134                "INSERT OR REPLACE INTO llm_cache (key, value, created_at) VALUES (?1, ?2, unixepoch())",
135                rusqlite::params![key, value],
136            )
137            .map_err(|e| SynapticError::Cache(format!("SQLite insert error: {e}")))?;
138
139            Ok(())
140        })
141        .await
142        .map_err(|e| SynapticError::Cache(format!("spawn_blocking error: {e}")))?
143    }
144
145    async fn clear(&self) -> Result<(), SynapticError> {
146        let conn = self.conn.clone();
147
148        tokio::task::spawn_blocking(move || {
149            let conn = conn
150                .lock()
151                .map_err(|e| SynapticError::Cache(format!("lock error: {e}")))?;
152
153            conn.execute("DELETE FROM llm_cache", [])
154                .map_err(|e| SynapticError::Cache(format!("SQLite delete error: {e}")))?;
155
156            Ok(())
157        })
158        .await
159        .map_err(|e| SynapticError::Cache(format!("spawn_blocking error: {e}")))?
160    }
161}