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, BuildRecord, 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    /// The single read path, shared by [`Cache::last_build`] and
92    /// [`Cache::last_record`] so the two can never drift apart.
93    async fn fetch(&self, id: &ActionId) -> Option<BuildRecord> {
94        let conn = Arc::clone(&self.conn);
95        let id = id.clone();
96        tokio::task::spawn_blocking(move || -> Option<BuildRecord> {
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                    // `started_at == 0` is the "no write time" marker: it is
110                    // what every row written before this column carried a
111                    // real clock reading looks like, and what `insert_event`
112                    // still falls back to when the clock is unusable. Such a
113                    // row must report absence, never an epoch-zero date.
114                    let recorded_at = if started == 0 {
115                        None
116                    } else {
117                        u64::try_from(ended).ok()
118                    };
119
120                    let input = parse_sha256(&input_hex).ok_or_else(|| {
121                        rusqlite::Error::InvalidColumnType(
122                            0,
123                            "input_hash".into(),
124                            rusqlite::types::Type::Text,
125                        )
126                    })?;
127
128                    let event = if status == "success" {
129                        let out_hex: String = row.get(1)?;
130                        let output = parse_sha256(&out_hex).ok_or_else(|| {
131                            rusqlite::Error::InvalidColumnType(
132                                1,
133                                "output_hash".into(),
134                                rusqlite::types::Type::Text,
135                            )
136                        })?;
137                        BuildEvent::Success {
138                            id: id.clone(),
139                            input,
140                            output,
141                            ms,
142                        }
143                    } else {
144                        let err_json: Option<String> = row.get(5)?;
145                        let error = err_json
146                            .and_then(|s| serde_json::from_str::<BuildError>(&s).ok())
147                            .unwrap_or(BuildError::Cancelled);
148                        BuildEvent::Failed {
149                            id: id.clone(),
150                            input,
151                            error,
152                            ms,
153                        }
154                    };
155                    Ok(BuildRecord { event, recorded_at })
156                },
157            )
158            .ok()
159        })
160        .await
161        .ok()
162        .flatten()
163    }
164}
165
166#[async_trait]
167impl Cache for SqliteCache {
168    async fn last_build(&self, id: &ActionId) -> Option<BuildEvent> {
169        self.fetch(id).await.map(|r| r.event)
170    }
171
172    async fn last_record(&self, id: &ActionId) -> Option<BuildRecord> {
173        self.fetch(id).await
174    }
175
176    async fn record(&mut self, ev: BuildEvent) -> Result<()> {
177        let conn = Arc::clone(&self.conn);
178        tokio::task::spawn_blocking(move || -> Result<()> {
179            let c = conn
180                .lock()
181                .map_err(|_| CacheError::Backend("connection mutex poisoned".into()))?;
182            insert_event(&c, &ev)
183        })
184        .await
185        .map_err(|e| CacheError::Backend(format!("spawn_blocking join: {e}")))?
186    }
187
188    /// Override the default `Cache::record_batch` impl with a single
189    /// `SQLite` transaction so the whole layer's events land atomically.
190    /// Without this the default loops `record` and any crash mid-batch
191    /// leaves the cache half-updated.
192    async fn record_batch(&mut self, events: Vec<BuildEvent>) -> Result<()> {
193        if events.is_empty() {
194            return Ok(());
195        }
196        let conn = Arc::clone(&self.conn);
197        tokio::task::spawn_blocking(move || -> Result<()> {
198            let mut c = conn
199                .lock()
200                .map_err(|_| CacheError::Backend("connection mutex poisoned".into()))?;
201            let tx = c
202                .transaction()
203                .map_err(|e| CacheError::Backend(format!("begin transaction: {e}")))?;
204            for ev in &events {
205                insert_event(&tx, ev)?;
206            }
207            tx.commit()
208                .map_err(|e| CacheError::Backend(format!("commit transaction: {e}")))?;
209            Ok(())
210        })
211        .await
212        .map_err(|e| CacheError::Backend(format!("spawn_blocking join: {e}")))?
213    }
214}
215
216/// Insert (or replace) one [`BuildEvent`] row using `conn`. Pulled out
217/// of [`SqliteCache::record`] so [`SqliteCache::record_batch`] can run
218/// the same insertion inside a transaction.
219fn insert_event(conn: &Connection, ev: &BuildEvent) -> Result<()> {
220    match ev {
221        BuildEvent::Success {
222            id,
223            input,
224            output,
225            ms,
226        } => {
227            let (started, ended) = stamps(*ms);
228            conn.execute(
229                "INSERT OR REPLACE INTO build_events
230                 (action_id, input_hash, output_hash, status, started_at, ended_at, error_json)
231                 VALUES (?1, ?2, ?3, 'success', ?4, ?5, NULL)",
232                params![id.0, input.to_string(), output.to_string(), started, ended],
233            )
234            .map_err(|e| CacheError::Backend(e.to_string()))?;
235        }
236        BuildEvent::Failed {
237            id,
238            input,
239            error,
240            ms,
241        } => {
242            let err_json = serde_json::to_string(error)
243                .map_err(|e| CacheError::Backend(format!("serialize error: {e}")))?;
244            let (started, ended) = stamps(*ms);
245            conn.execute(
246                "INSERT OR REPLACE INTO build_events
247                 (action_id, input_hash, output_hash, status, started_at, ended_at, error_json)
248                 VALUES (?1, ?2, NULL, 'failed', ?3, ?4, ?5)",
249                params![id.0, input.to_string(), started, ended, err_json],
250            )
251            .map_err(|e| CacheError::Backend(e.to_string()))?;
252        }
253    }
254    Ok(())
255}
256
257/// The `(started_at, ended_at)` pair to store for a build that took `ms`.
258///
259/// Anchored on the wall clock so `ended_at` is a genuine timestamp — the
260/// columns previously held `(0, ms)`, which made them lie about their own
261/// names and left the backend unable to say when anything ran.
262///
263/// The subtraction is deliberate: the reader recovers the duration as
264/// `ended - started`, so anchoring the *end* on the clock keeps that
265/// invariant exactly intact while making the stored end date true. No
266/// migration is needed, and no existing row changes meaning.
267///
268/// A clock that cannot be read, or that places "now" at or before this
269/// build's own duration, is not one we can date anything with. Both degrade
270/// to the legacy `(0, ms)` shape, which the reader already interprets as
271/// "no write time" — so a broken clock costs a missing date, never a wrong
272/// duration and never a 1970 date.
273fn stamps(ms: u64) -> (i64, i64) {
274    let ms = i64::try_from(ms).unwrap_or(i64::MAX);
275    let legacy = (0, ms);
276    let Ok(since_epoch) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) else {
277        return legacy;
278    };
279    let ended = i64::try_from(since_epoch.as_millis()).unwrap_or(i64::MAX);
280    if ended <= ms {
281        legacy
282    } else {
283        (ended - ms, ended)
284    }
285}
286
287fn parse_sha256(s: &str) -> Option<Sha256> {
288    let bytes = hex::decode(s).ok()?;
289    let arr: [u8; 32] = bytes.try_into().ok()?;
290    Some(Sha256(arr))
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use repolith_core::types::ActionId;
297
298    fn ok_event(id: &str, ms: u64) -> BuildEvent {
299        BuildEvent::Success {
300            id: ActionId(id.to_string()),
301            input: Sha256([3; 32]),
302            output: Sha256([4; 32]),
303            ms,
304        }
305    }
306
307    /// Write a row exactly as every version before this fix did: the
308    /// `started_at` column pinned to 0, the duration parked in `ended_at`.
309    fn insert_legacy_row(cache: &SqliteCache, id: &str, ms: i64) {
310        let c = cache.conn.lock().expect("lock");
311        c.execute(
312            "INSERT OR REPLACE INTO build_events
313             (action_id, input_hash, output_hash, status, started_at, ended_at, error_json)
314             VALUES (?1, ?2, ?3, 'success', 0, ?4, NULL)",
315            params![
316                id,
317                Sha256([3; 32]).to_string(),
318                Sha256([4; 32]).to_string(),
319                ms
320            ],
321        )
322        .expect("insert legacy row");
323    }
324
325    fn columns_of(cache: &SqliteCache, id: &str) -> (i64, i64) {
326        let c = cache.conn.lock().expect("lock");
327        c.query_row(
328            "SELECT started_at, ended_at FROM build_events WHERE action_id = ?1",
329            params![id],
330            |r| Ok((r.get(0)?, r.get(1)?)),
331        )
332        .expect("row exists")
333    }
334
335    /// The whole point of not migrating: a cache written by an older
336    /// repolith must keep working, and must not surface a 1970 date.
337    #[tokio::test]
338    async fn legacy_row_keeps_its_duration_and_reports_no_date() {
339        let cache = SqliteCache::in_memory().expect("in-memory");
340        insert_legacy_row(&cache, "old::cargo-install::0", 1234);
341        let id = ActionId("old::cargo-install::0".to_string());
342
343        let rec = cache
344            .last_record(&id)
345            .await
346            .expect("a pre-fix row must stay readable");
347        assert!(
348            rec.recorded_at.is_none(),
349            "started_at == 0 is the pre-fix shape; it means 'unknown', not epoch zero"
350        );
351        match rec.event {
352            BuildEvent::Success { ms, .. } => {
353                assert_eq!(
354                    ms, 1234,
355                    "the duration stored by the old writer must survive"
356                );
357            }
358            BuildEvent::Failed { .. } => panic!("expected Success"),
359        }
360    }
361
362    #[tokio::test]
363    async fn new_rows_are_dated_without_disturbing_the_duration() {
364        let mut cache = SqliteCache::in_memory().expect("in-memory");
365        let id = ActionId("new::cargo-install::0".to_string());
366        cache.record(ok_event(&id.0, 1234)).await.expect("record");
367
368        let rec = cache.last_record(&id).await.expect("readable");
369        match rec.event {
370            BuildEvent::Success { ms, .. } => assert_eq!(ms, 1234, "duration must be untouched"),
371            BuildEvent::Failed { .. } => panic!("expected Success"),
372        }
373        assert!(
374            rec.recorded_at.is_some(),
375            "a freshly written row must be dated"
376        );
377
378        // Assert on the raw columns too: the read path recovers `ms` by
379        // subtraction, so a writer that stored (0, ms) would pass every
380        // assertion above while still leaving the columns meaningless.
381        let (started, ended) = columns_of(&cache, &id.0);
382        assert_eq!(ended - started, 1234, "duration recoverable by subtraction");
383        assert!(
384            started > 1_600_000_000_000,
385            "started_at must be a real epoch-ms reading (2020 or later), got {started}"
386        );
387    }
388
389    #[test]
390    fn stamps_preserve_the_subtraction_invariant() {
391        for ms in [0u64, 1, 1234, 86_400_000] {
392            let (started, ended) = stamps(ms);
393            assert_eq!(
394                u64::try_from(ended - started).expect("non-negative"),
395                ms,
396                "last_build recovers the duration as ended - started; \
397                 stamps() must keep that exact for ms = {ms}"
398            );
399        }
400    }
401
402    #[test]
403    fn an_undatable_build_falls_back_to_the_legacy_shape() {
404        // A duration larger than the current epoch time cannot be anchored
405        // on the clock without inventing a pre-epoch start. Degrade to the
406        // undated shape instead — a missing date, never a wrong duration.
407        let (started, ended) = stamps(u64::MAX);
408        assert_eq!(started, 0, "0 is the 'no write time' marker");
409        assert_eq!(ended - started, i64::MAX, "duration still recoverable");
410    }
411}