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 release_idempotency_drops_the_claim() {
236        // F21: releasing a claim lets a replay of the key start fresh instead of
237        // 404-ing for the whole retention window.
238        use crate::serve::history::Claim;
239        let dir = tempfile::tempdir().unwrap();
240        let h = backend(&url_in(dir.path()), "a", Duration::from_secs(60)).await;
241        let w = Duration::from_secs(3600);
242        assert!(matches!(
243            h.claim_idempotency("k", "fp1", "run1", w).await.unwrap(),
244            Claim::Fresh
245        ));
246        // Without release, a different fingerprint on the same key is a Conflict.
247        h.release_idempotency("run1").await.unwrap();
248        // After release the key is free: a fresh claim (even a different
249        // fingerprint / run) succeeds rather than replaying/conflicting.
250        assert!(matches!(
251            h.claim_idempotency("k", "fp2", "run2", w).await.unwrap(),
252            Claim::Fresh
253        ));
254    }
255}