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