faucet_cli/serve/history/
sqlite.rs1use 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 release_idempotency_drops_the_claim() {
236 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 h.release_idempotency("run1").await.unwrap();
248 assert!(matches!(
251 h.claim_idempotency("k", "fp2", "run2", w).await.unwrap(),
252 Claim::Fresh
253 ));
254 }
255}