1use 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
20pub struct SqliteCache {
22 conn: Arc<Mutex<Connection>>,
23}
24
25impl SqliteCache {
26 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 pub fn in_memory() -> Result<Self> {
55 let conn = Connection::open_in_memory().map_err(|e| CacheError::Backend(e.to_string()))?;
56 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 fn configure_pragmas(conn: &Connection) -> Result<()> {
79 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 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
193fn 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}