Skip to main content

faucet_cli/serve/history/
sqlite.rs

1//! SQLite-backed run history (`serve-history-sqlite`, Phase 5 of #127).
2//! Connection setup only — the schema, statements, and `RunHistory` impl are
3//! shared with Postgres via [`impl_sql_history!`](super::sql).
4
5use 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    /// Connect (creating the database file if missing), create the schema if
15    /// absent, and return the backend. WAL + a busy timeout let the connection
16    /// pool tolerate concurrent run writes. `lease_ttl` and `instance_id` drive
17    /// instance-fenced orphan recovery (#146 H7).
18    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        // Largest estimated size first.
119        assert_eq!(claimed[0].shard_id, "1");
120        assert_eq!(claimed[1].shard_id, "0");
121        assert_eq!(claimed[2].shard_id, "2");
122        // Parent run body is carried for the worker to rebuild the source.
123        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        // Everything is claimed → a second claim returns nothing.
130        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        // A claims the only shard.
143        let claimed = a.claim_shards(10).await.unwrap();
144        assert_eq!(claimed.len(), 1);
145
146        // B does not own it → cannot finalize.
147        assert!(
148            !b.finalize_shard("run1", "0", true).await.unwrap(),
149            "a non-owner must not finalize the shard"
150        );
151        // A owns it → finalize succeeds.
152        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        // lease_ttl = 0 → a claimed shard's lease is already in the past on the
164        // next call, so it is reclaimable deterministically.
165        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        // First reclaim: attempt 0 < 2 → requeued back to pending.
171        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        // Re-claim and reclaim until the attempt cap poisons it.
177        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        // F25: deleting a terminal run must also drop its shard rows so they
189        // don't leak unboundedly on the durable store.
190        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        // Make the run terminal so it is deletable.
198        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        // F25: purging expired terminal runs must reclaim their shard rows too.
215        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        // retain_for = 0 → the terminal run is immediately purgeable.
225        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        // Newest first.
263        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        // Filters.
276        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        // purge_expired(0) drops all audit rows.
296        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(); // same schema → deduped
376        h.catalog_record(&update("r3", v2, 9)).await.unwrap(); // changed → version 2
377
378        // List: two datasets, kind filter narrows to one.
379        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        // Detail: counters, deduped timeline with a diff, stats, edges.
399        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        // Lineage graph: whole graph and rooted slice both return the edge.
417        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        // The catalog survives run-record purges (accumulating value).
422        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        // F21: releasing a claim lets a replay of the key start fresh instead of
440        // 404-ing for the whole retention window.
441        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        // Without release, a different fingerprint on the same key is a Conflict.
450        h.release_idempotency("run1").await.unwrap();
451        // After release the key is free: a fresh claim (even a different
452        // fingerprint / run) succeeds rather than replaying/conflicting.
453        assert!(matches!(
454            h.claim_idempotency("k", "fp2", "run2", w).await.unwrap(),
455            Claim::Fresh
456        ));
457    }
458}