1use 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#[derive(Clone)]
22pub struct SqliteCache {
23 conn: Arc<Mutex<Connection>>,
24}
25
26impl SqliteCache {
27 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 pub fn in_memory() -> Result<Self> {
56 let conn = Connection::open_in_memory().map_err(|e| CacheError::Backend(e.to_string()))?;
57 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 fn configure_pragmas(conn: &Connection) -> Result<()> {
80 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 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 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 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
216fn 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
257fn 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 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 #[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 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 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}