Skip to main content

repolith_cache/
sqlite.rs

1//! SQLite-backed [`Cache`] implementation.
2//!
3//! Wraps a single `rusqlite::Connection` behind `Arc<Mutex<>>` and runs every
4//! statement inside `tokio::task::spawn_blocking` because `rusqlite` is
5//! synchronous + `!Send`. Schema is the single CREATE TABLE in
6//! `schema.sql` (no migrations in M1 — `IF NOT EXISTS` is the upgrade path).
7//!
8//! `BuildEvent::Failed.error` is stored as JSON in the `error_json` column so
9//! the typed `BuildError` survives the roundtrip without lossy stringification.
10
11use async_trait::async_trait;
12use repolith_core::cache::{Cache, CacheError, Result};
13use repolith_core::types::{ActionId, BuildError, BuildEvent, Sha256};
14use rusqlite::{Connection, params};
15use std::path::Path;
16use std::sync::{Arc, Mutex};
17
18const SCHEMA: &str = include_str!("schema.sql");
19
20/// `SQLite` cache backend. Cheap to clone (`Arc<Mutex<Connection>>` shared).
21pub struct SqliteCache {
22    conn: Arc<Mutex<Connection>>,
23}
24
25impl SqliteCache {
26    /// Open (or create) the cache database at `path`. Creates the parent
27    /// directory if needed and applies the schema on first use.
28    ///
29    /// # Errors
30    /// - [`CacheError::Io`] when the parent directory cannot be created.
31    /// - [`CacheError::Backend`] when `rusqlite` cannot open the file or
32    ///   apply the schema.
33    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
34        let path = path.as_ref();
35        if let Some(parent) = path.parent()
36            && !parent.as_os_str().is_empty()
37        {
38            std::fs::create_dir_all(parent)?;
39        }
40        let conn = Connection::open(path).map_err(|e| CacheError::Backend(e.to_string()))?;
41        Self::configure_pragmas(&conn)?;
42        conn.execute_batch(SCHEMA)
43            .map_err(|e| CacheError::Backend(e.to_string()))?;
44        Ok(Self {
45            conn: Arc::new(Mutex::new(conn)),
46        })
47    }
48
49    /// Open an in-memory cache. Convenient for tests; data is lost on drop.
50    ///
51    /// # Errors
52    /// [`CacheError::Backend`] when `rusqlite` cannot open the in-memory
53    /// connection or apply the schema (extremely unlikely in practice).
54    pub fn in_memory() -> Result<Self> {
55        let conn = Connection::open_in_memory().map_err(|e| CacheError::Backend(e.to_string()))?;
56        // WAL is meaningless for `:memory:` databases, but `busy_timeout`
57        // and `synchronous=NORMAL` are still applied for consistency with
58        // file-backed caches.
59        Self::configure_pragmas(&conn)?;
60        conn.execute_batch(SCHEMA)
61            .map_err(|e| CacheError::Backend(e.to_string()))?;
62        Ok(Self {
63            conn: Arc::new(Mutex::new(conn)),
64        })
65    }
66
67    /// Apply the standard pragma set so concurrent `repolith sync`
68    /// invocations against the same cache file don't deadlock with
69    /// `SQLITE_BUSY`.
70    ///
71    /// - `journal_mode = WAL` — readers don't block writers, writers don't
72    ///   block readers. The `-wal` and `-shm` sidecar files appear next to
73    ///   `cache.db` automatically.
74    /// - `synchronous = NORMAL` — durable enough for a build cache (no
75    ///   per-commit fsync), an order of magnitude faster than `FULL`.
76    /// - `busy_timeout = 5000` — wait up to 5 s on lock contention before
77    ///   returning `SQLITE_BUSY`.
78    fn configure_pragmas(conn: &Connection) -> Result<()> {
79        // `journal_mode` returns the new mode; we ignore the result but the
80        // `query_row` call is what actually drives the pragma.
81        conn.query_row("PRAGMA journal_mode = WAL", [], |_| Ok(()))
82            .map_err(|e| CacheError::Backend(format!("set journal_mode=WAL: {e}")))?;
83        conn.pragma_update(None, "synchronous", "NORMAL")
84            .map_err(|e| CacheError::Backend(format!("set synchronous=NORMAL: {e}")))?;
85        conn.busy_timeout(std::time::Duration::from_secs(5))
86            .map_err(|e| CacheError::Backend(format!("set busy_timeout: {e}")))?;
87        Ok(())
88    }
89}
90
91#[async_trait]
92impl Cache for SqliteCache {
93    async fn last_build(&self, id: &ActionId) -> Option<BuildEvent> {
94        let conn = Arc::clone(&self.conn);
95        let id = id.clone();
96        tokio::task::spawn_blocking(move || -> Option<BuildEvent> {
97            let c = conn.lock().ok()?;
98            c.query_row(
99                "SELECT input_hash, output_hash, status, started_at, ended_at, error_json
100                 FROM build_events WHERE action_id = ?1",
101                params![id.0],
102                |row| {
103                    let input_hex: String = row.get(0)?;
104                    let status: String = row.get(2)?;
105                    let started: i64 = row.get(3)?;
106                    let ended: i64 = row.get(4)?;
107                    let ms = u64::try_from((ended - started).max(0)).unwrap_or(0);
108
109                    let input = parse_sha256(&input_hex).ok_or_else(|| {
110                        rusqlite::Error::InvalidColumnType(
111                            0,
112                            "input_hash".into(),
113                            rusqlite::types::Type::Text,
114                        )
115                    })?;
116
117                    Ok(if status == "success" {
118                        let out_hex: String = row.get(1)?;
119                        let output = parse_sha256(&out_hex).ok_or_else(|| {
120                            rusqlite::Error::InvalidColumnType(
121                                1,
122                                "output_hash".into(),
123                                rusqlite::types::Type::Text,
124                            )
125                        })?;
126                        BuildEvent::Success {
127                            id: id.clone(),
128                            input,
129                            output,
130                            ms,
131                        }
132                    } else {
133                        let err_json: Option<String> = row.get(5)?;
134                        let error = err_json
135                            .and_then(|s| serde_json::from_str::<BuildError>(&s).ok())
136                            .unwrap_or(BuildError::Cancelled);
137                        BuildEvent::Failed {
138                            id: id.clone(),
139                            input,
140                            error,
141                            ms,
142                        }
143                    })
144                },
145            )
146            .ok()
147        })
148        .await
149        .ok()
150        .flatten()
151    }
152
153    async fn record(&mut self, ev: BuildEvent) -> Result<()> {
154        let conn = Arc::clone(&self.conn);
155        tokio::task::spawn_blocking(move || -> Result<()> {
156            let c = conn
157                .lock()
158                .map_err(|_| CacheError::Backend("connection mutex poisoned".into()))?;
159            insert_event(&c, &ev)
160        })
161        .await
162        .map_err(|e| CacheError::Backend(format!("spawn_blocking join: {e}")))?
163    }
164
165    /// Override the default `Cache::record_batch` impl with a single
166    /// `SQLite` transaction so the whole layer's events land atomically.
167    /// Without this the default loops `record` and any crash mid-batch
168    /// leaves the cache half-updated.
169    async fn record_batch(&mut self, events: Vec<BuildEvent>) -> Result<()> {
170        if events.is_empty() {
171            return Ok(());
172        }
173        let conn = Arc::clone(&self.conn);
174        tokio::task::spawn_blocking(move || -> Result<()> {
175            let mut c = conn
176                .lock()
177                .map_err(|_| CacheError::Backend("connection mutex poisoned".into()))?;
178            let tx = c
179                .transaction()
180                .map_err(|e| CacheError::Backend(format!("begin transaction: {e}")))?;
181            for ev in &events {
182                insert_event(&tx, ev)?;
183            }
184            tx.commit()
185                .map_err(|e| CacheError::Backend(format!("commit transaction: {e}")))?;
186            Ok(())
187        })
188        .await
189        .map_err(|e| CacheError::Backend(format!("spawn_blocking join: {e}")))?
190    }
191}
192
193/// Insert (or replace) one [`BuildEvent`] row using `conn`. Pulled out
194/// of [`SqliteCache::record`] so [`SqliteCache::record_batch`] can run
195/// the same insertion inside a transaction.
196fn insert_event(conn: &Connection, ev: &BuildEvent) -> Result<()> {
197    match ev {
198        BuildEvent::Success {
199            id,
200            input,
201            output,
202            ms,
203        } => {
204            conn.execute(
205                "INSERT OR REPLACE INTO build_events
206                 (action_id, input_hash, output_hash, status, started_at, ended_at, error_json)
207                 VALUES (?1, ?2, ?3, 'success', 0, ?4, NULL)",
208                params![
209                    id.0,
210                    input.to_string(),
211                    output.to_string(),
212                    i64::try_from(*ms).unwrap_or(i64::MAX)
213                ],
214            )
215            .map_err(|e| CacheError::Backend(e.to_string()))?;
216        }
217        BuildEvent::Failed {
218            id,
219            input,
220            error,
221            ms,
222        } => {
223            let err_json = serde_json::to_string(error)
224                .map_err(|e| CacheError::Backend(format!("serialize error: {e}")))?;
225            conn.execute(
226                "INSERT OR REPLACE INTO build_events
227                 (action_id, input_hash, output_hash, status, started_at, ended_at, error_json)
228                 VALUES (?1, ?2, NULL, 'failed', 0, ?3, ?4)",
229                params![
230                    id.0,
231                    input.to_string(),
232                    i64::try_from(*ms).unwrap_or(i64::MAX),
233                    err_json
234                ],
235            )
236            .map_err(|e| CacheError::Backend(e.to_string()))?;
237        }
238    }
239    Ok(())
240}
241
242fn parse_sha256(s: &str) -> Option<Sha256> {
243    let bytes = hex::decode(s).ok()?;
244    let arr: [u8; 32] = bytes.try_into().ok()?;
245    Some(Sha256(arr))
246}