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
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
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 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
194fn 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}