1use super::HistoryError;
6use super::sql::{DDL, Dialect, Stmts, impl_sql_history};
7use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
8use std::str::FromStr;
9use std::time::Duration;
10
11impl_sql_history!(SqliteHistory, sqlx::SqlitePool);
12
13impl SqliteHistory {
14 pub async fn connect(
19 url: &str,
20 idem_retention: Duration,
21 lease_ttl: Duration,
22 instance_id: String,
23 ) -> Result<Self, HistoryError> {
24 let opts = SqliteConnectOptions::from_str(url)
25 .map_err(|e| HistoryError::Backend(format!("invalid sqlite url '{url}': {e}")))?
26 .create_if_missing(true)
27 .journal_mode(SqliteJournalMode::Wal)
28 .busy_timeout(Duration::from_secs(5));
29 let pool = SqlitePoolOptions::new()
30 .max_connections(5)
31 .connect_with(opts)
32 .await
33 .map_err(|e| HistoryError::Backend(format!("SQLite connection failed: {e}")))?;
34 for stmt in DDL {
35 sqlx::query(stmt)
36 .execute(&pool)
37 .await
38 .map_err(|e| HistoryError::Backend(format!("creating run-history schema: {e}")))?;
39 }
40 Ok(Self::from_parts(
41 pool,
42 idem_retention,
43 lease_ttl,
44 instance_id,
45 Stmts::new(Dialect::Sqlite),
46 ))
47 }
48}
49
50#[cfg(test)]
51mod shard_tests {
52 use super::*;
53 use crate::serve::history::{RunHistory, RunRecord, RunStatus, ShardInsert};
54 use std::collections::BTreeMap;
55
56 fn shard(id: &str, size: u64) -> ShardInsert {
57 ShardInsert {
58 shard_id: id.into(),
59 descriptor: serde_json::json!({ "i": id }),
60 size_estimate: Some(size),
61 }
62 }
63
64 async fn backend(url: &str, instance: &str, ttl: Duration) -> SqliteHistory {
65 SqliteHistory::connect(url, Duration::from_secs(300), ttl, instance.into())
66 .await
67 .expect("connect")
68 }
69
70 async fn seed_run(h: &SqliteHistory, run_id: &str) {
71 let mut rec = RunRecord::queued(
72 run_id.into(),
73 None,
74 BTreeMap::new(),
75 None,
76 chrono::Utc::now(),
77 );
78 rec.status = RunStatus::Pending;
79 rec.config_body = Some("version: 1".into());
80 h.upsert(&rec).await.expect("seed run");
81 }
82
83 fn url_in(dir: &std::path::Path) -> String {
84 format!("sqlite://{}/h.db", dir.display())
85 }
86
87 #[tokio::test]
88 async fn insert_shards_is_idempotent_and_progress_counts() {
89 let dir = tempfile::tempdir().unwrap();
90 let h = backend(&url_in(dir.path()), "a", Duration::from_secs(60)).await;
91 seed_run(&h, "run1").await;
92 let shards = [shard("0", 10), shard("1", 20), shard("2", 5)];
93
94 assert_eq!(h.insert_shards("run1", &shards).await.unwrap(), 3);
95 assert_eq!(
96 h.insert_shards("run1", &shards).await.unwrap(),
97 0,
98 "re-insert is a no-op (ON CONFLICT DO NOTHING)"
99 );
100
101 let p = h.shard_progress("run1").await.unwrap();
102 assert_eq!(p.total, 3);
103 assert_eq!(p.pending, 3);
104 assert!(!p.all_terminal());
105 }
106
107 #[tokio::test]
108 async fn claim_shards_largest_first_marks_running_and_is_exclusive() {
109 let dir = tempfile::tempdir().unwrap();
110 let h = backend(&url_in(dir.path()), "a", Duration::from_secs(60)).await;
111 seed_run(&h, "run1").await;
112 h.insert_shards("run1", &[shard("0", 10), shard("1", 20), shard("2", 5)])
113 .await
114 .unwrap();
115
116 let claimed = h.claim_shards(10).await.unwrap();
117 assert_eq!(claimed.len(), 3);
118 assert_eq!(claimed[0].shard_id, "1");
120 assert_eq!(claimed[1].shard_id, "0");
121 assert_eq!(claimed[2].shard_id, "2");
122 assert_eq!(claimed[0].run.config_body.as_deref(), Some("version: 1"));
124 assert_eq!(claimed[0].descriptor, serde_json::json!({ "i": "1" }));
125
126 let p = h.shard_progress("run1").await.unwrap();
127 assert_eq!(p.running, 3);
128
129 assert!(h.claim_shards(10).await.unwrap().is_empty());
131 }
132
133 #[tokio::test]
134 async fn finalize_shard_is_owner_fenced() {
135 let dir = tempfile::tempdir().unwrap();
136 let url = url_in(dir.path());
137 let a = backend(&url, "inst-a", Duration::from_secs(60)).await;
138 let b = backend(&url, "inst-b", Duration::from_secs(60)).await;
139 seed_run(&a, "run1").await;
140 a.insert_shards("run1", &[shard("0", 1)]).await.unwrap();
141
142 let claimed = a.claim_shards(10).await.unwrap();
144 assert_eq!(claimed.len(), 1);
145
146 assert!(
148 !b.finalize_shard("run1", "0", true).await.unwrap(),
149 "a non-owner must not finalize the shard"
150 );
151 assert!(a.finalize_shard("run1", "0", true).await.unwrap());
153
154 let p = a.shard_progress("run1").await.unwrap();
155 assert_eq!(p.completed, 1);
156 assert!(p.all_terminal());
157 }
158
159 #[tokio::test]
160 async fn reclaim_shards_requeues_expired_then_poisons() {
161 let dir = tempfile::tempdir().unwrap();
162 let url = url_in(dir.path());
163 let h = backend(&url, "inst-a", Duration::ZERO).await;
166 seed_run(&h, "run1").await;
167 h.insert_shards("run1", &[shard("0", 1)]).await.unwrap();
168 h.claim_shards(10).await.unwrap();
169
170 let r1 = h.reclaim_shards(2).await.unwrap();
172 assert_eq!(r1.requeued, 1);
173 assert_eq!(r1.failed, 0);
174 assert_eq!(h.shard_progress("run1").await.unwrap().pending, 1);
175
176 h.claim_shards(10).await.unwrap();
178 let r2 = h.reclaim_shards(2).await.unwrap();
179 assert_eq!(r2.requeued, 1, "attempt 1 < 2 → still requeued");
180 h.claim_shards(10).await.unwrap();
181 let r3 = h.reclaim_shards(2).await.unwrap();
182 assert_eq!(r3.failed, 1, "attempt 2 >= 2 → poisoned (failed)");
183 assert_eq!(h.shard_progress("run1").await.unwrap().failed, 1);
184 }
185
186 #[tokio::test]
187 async fn delete_run_removes_its_shard_rows() {
188 use crate::serve::history::DeleteOutcome;
191 let dir = tempfile::tempdir().unwrap();
192 let h = backend(&url_in(dir.path()), "a", Duration::from_secs(60)).await;
193 seed_run(&h, "run1").await;
194 h.insert_shards("run1", &[shard("0", 1), shard("1", 1)])
195 .await
196 .unwrap();
197 let mut rec = h.get("run1").await.unwrap().unwrap();
199 rec.status = RunStatus::Completed;
200 rec.finished_at = Some(chrono::Utc::now());
201 h.upsert(&rec).await.unwrap();
202
203 assert_eq!(h.shard_progress("run1").await.unwrap().total, 2);
204 assert_eq!(h.delete("run1").await.unwrap(), DeleteOutcome::Deleted);
205 assert_eq!(
206 h.shard_progress("run1").await.unwrap().total,
207 0,
208 "shard rows must be removed when the run is deleted"
209 );
210 }
211
212 #[tokio::test]
213 async fn purge_expired_removes_orphaned_shard_rows() {
214 let dir = tempfile::tempdir().unwrap();
216 let h = backend(&url_in(dir.path()), "a", Duration::from_secs(60)).await;
217 seed_run(&h, "run1").await;
218 h.insert_shards("run1", &[shard("0", 1)]).await.unwrap();
219 let mut rec = h.get("run1").await.unwrap().unwrap();
220 rec.status = RunStatus::Completed;
221 rec.finished_at = Some(chrono::Utc::now());
222 h.upsert(&rec).await.unwrap();
223
224 let removed = h.purge_expired(Duration::ZERO).await.unwrap();
226 assert_eq!(removed, 1, "the terminal run is purged");
227 assert_eq!(
228 h.shard_progress("run1").await.unwrap().total,
229 0,
230 "orphaned shard rows must be purged with their parent run"
231 );
232 }
233
234 #[tokio::test]
235 async fn audit_record_list_filter_and_purge() {
236 use crate::serve::history::{AuditEntry, AuditFilter};
237 let dir = tempfile::tempdir().unwrap();
238 let h = backend(&url_in(dir.path()), "a", Duration::from_secs(60)).await;
239 let now = chrono::Utc::now();
240 let entry =
241 |id: &str, principal: &str, action: &str, result: &str, secs_ago: i64| AuditEntry {
242 id: id.into(),
243 timestamp: now - chrono::Duration::seconds(secs_ago),
244 principal: principal.into(),
245 role: "admin".into(),
246 action: action.into(),
247 run_id: Some(format!("r-{id}")),
248 config_fingerprint: Some("fp".into()),
249 source_ip: Some("127.0.0.1".into()),
250 result: result.into(),
251 };
252 h.record_audit(&entry("1", "alice", "run.submit", "ok", 3))
253 .await
254 .unwrap();
255 h.record_audit(&entry("2", "bob", "run.submit", "denied", 2))
256 .await
257 .unwrap();
258 h.record_audit(&entry("3", "alice", "run.cancel", "ok", 1))
259 .await
260 .unwrap();
261
262 let all = h
264 .list_audit(&AuditFilter {
265 limit: 50,
266 ..Default::default()
267 })
268 .await
269 .unwrap();
270 assert_eq!(all.len(), 3);
271 assert_eq!(all[0].id, "3");
272 assert_eq!(all[0].run_id.as_deref(), Some("r-3"));
273 assert_eq!(all[0].source_ip.as_deref(), Some("127.0.0.1"));
274
275 let alice = h
277 .list_audit(&AuditFilter {
278 principal: Some("alice".into()),
279 limit: 50,
280 ..Default::default()
281 })
282 .await
283 .unwrap();
284 assert_eq!(alice.len(), 2);
285 let submits = h
286 .list_audit(&AuditFilter {
287 action: Some("run.submit".into()),
288 limit: 50,
289 ..Default::default()
290 })
291 .await
292 .unwrap();
293 assert_eq!(submits.len(), 2);
294
295 h.purge_expired(Duration::ZERO).await.unwrap();
297 assert!(
298 h.list_audit(&AuditFilter {
299 limit: 50,
300 ..Default::default()
301 })
302 .await
303 .unwrap()
304 .is_empty()
305 );
306 }
307
308 #[tokio::test]
309 async fn config_snapshot_roundtrips_and_upserts_latest() {
310 use crate::serve::history::catalog::ConfigSnapshot;
311 use std::collections::BTreeMap;
312 let dir = tempfile::tempdir().unwrap();
313 let h = backend(&url_in(dir.path()), "a", Duration::from_secs(60)).await;
314 assert!(
315 h.catalog_last_config_snapshot("p").await.unwrap().is_none(),
316 "no snapshot before any record"
317 );
318 let mk = |ver: &str| ConfigSnapshot {
319 pipeline: "p".into(),
320 recorded_at: chrono::Utc::now(),
321 faucet_version: ver.into(),
322 rows: BTreeMap::new(),
323 };
324 h.catalog_record_config_snapshot(&mk("1")).await.unwrap();
325 h.catalog_record_config_snapshot(&mk("2")).await.unwrap();
326 let got = h.catalog_last_config_snapshot("p").await.unwrap().unwrap();
327 assert_eq!(
328 got.faucet_version, "2",
329 "latest-wins upsert on one pipeline"
330 );
331 assert!(
332 h.catalog_last_config_snapshot("nope")
333 .await
334 .unwrap()
335 .is_none(),
336 "keyed per pipeline"
337 );
338 }
339
340 #[tokio::test]
341 async fn catalog_record_roundtrips_datasets_timeline_stats_and_edges() {
342 use crate::serve::history::catalog::{
343 self, CatalogListFilter, CatalogUpdate, DatasetObservation, DatasetRole,
344 };
345 let dir = tempfile::tempdir().unwrap();
346 let h = backend(&url_in(dir.path()), "a", Duration::from_secs(60)).await;
347
348 let update = |run: &str, schema: serde_json::Value, records: u64| CatalogUpdate {
349 run_id: run.into(),
350 pipeline: "p".into(),
351 row: "default".into(),
352 recorded_at: chrono::Utc::now(),
353 sources: vec![DatasetObservation {
354 uri: "csv://./in.csv".into(),
355 kind: "csv".into(),
356 role: DatasetRole::Source,
357 schema: Some(schema.clone()),
358 records,
359 }],
360 sink: DatasetObservation {
361 uri: "jsonl://./out.jsonl".into(),
362 kind: "jsonl".into(),
363 role: DatasetRole::Sink,
364 schema: Some(schema),
365 records,
366 },
367 column_lineage: Some(serde_json::json!({"fields": {}})),
368 };
369 let v1 = serde_json::json!({"type":"object","properties":{"id":{"type":"integer"}}});
370 let v2 = serde_json::json!({"type":"object","properties":{"id":{"type":"integer"},"email":{"type":"string"}}});
371
372 h.catalog_record(&update("r1", v1.clone(), 10))
373 .await
374 .unwrap();
375 h.catalog_record(&update("r2", v1, 12)).await.unwrap(); h.catalog_record(&update("r3", v2, 9)).await.unwrap(); let page = h
380 .catalog_list_datasets(&CatalogListFilter {
381 limit: 10,
382 ..Default::default()
383 })
384 .await
385 .unwrap();
386 assert_eq!(page.datasets.len(), 2);
387 let page = h
388 .catalog_list_datasets(&CatalogListFilter {
389 kind: Some("csv".into()),
390 limit: 10,
391 ..Default::default()
392 })
393 .await
394 .unwrap();
395 assert_eq!(page.datasets.len(), 1);
396 assert_eq!(page.datasets[0].uri, "csv://./in.csv");
397
398 let src_id = catalog::dataset_id("csv://./in.csv");
400 let detail = h.catalog_get_dataset(&src_id).await.unwrap().unwrap();
401 assert_eq!(detail.dataset.runs, 3);
402 assert_eq!(detail.dataset.total_records, 31);
403 assert_eq!(detail.dataset.last_run_id, "r3");
404 assert_eq!(detail.schema_timeline.len(), 2, "same schema deduped");
405 assert_eq!(detail.schema_timeline[0].version, 1);
406 assert!(detail.schema_timeline[0].diff.is_none());
407 let diff = detail.schema_timeline[1].diff.as_ref().expect("v2 diff");
408 assert_eq!(diff["added"][0]["column"], "email");
409 assert_eq!(detail.stats.len(), 3, "one volume point per run");
410 assert_eq!(detail.stats[0].records, 9, "newest first");
411 assert_eq!(detail.downstream.len(), 1);
412 assert!(detail.upstream.is_empty());
413 assert_eq!(detail.downstream[0].runs, 3);
414 assert!(detail.downstream[0].column_lineage.is_some());
415
416 assert_eq!(h.catalog_lineage(None, 5).await.unwrap().len(), 1);
418 assert_eq!(h.catalog_lineage(Some(&src_id), 2).await.unwrap().len(), 1);
419 assert!(h.catalog_get_dataset("missing").await.unwrap().is_none());
420
421 h.purge_expired(Duration::ZERO).await.unwrap();
423 assert_eq!(
424 h.catalog_list_datasets(&CatalogListFilter {
425 limit: 10,
426 ..Default::default()
427 })
428 .await
429 .unwrap()
430 .datasets
431 .len(),
432 2,
433 "catalog rows are never purged by run retention"
434 );
435 }
436
437 #[tokio::test]
438 async fn release_idempotency_drops_the_claim() {
439 use crate::serve::history::Claim;
442 let dir = tempfile::tempdir().unwrap();
443 let h = backend(&url_in(dir.path()), "a", Duration::from_secs(60)).await;
444 let w = Duration::from_secs(3600);
445 assert!(matches!(
446 h.claim_idempotency("k", "fp1", "run1", w).await.unwrap(),
447 Claim::Fresh
448 ));
449 h.release_idempotency("run1").await.unwrap();
451 assert!(matches!(
454 h.claim_idempotency("k", "fp2", "run2", w).await.unwrap(),
455 Claim::Fresh
456 ));
457 }
458}