1#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
8pub mod fallback;
9pub mod memory;
10#[cfg(feature = "serve-history-postgres")]
11pub mod postgres;
12#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
13pub mod sql;
14#[cfg(feature = "serve-history-sqlite")]
15pub mod sqlite;
16
17use crate::error::CliResult;
18use crate::executor::InvocationOutcome;
19use crate::serve::config::HistoryBackendSpec;
20use async_trait::async_trait;
21use chrono::{DateTime, Utc};
22use serde::{Deserialize, Serialize};
23use std::collections::BTreeMap;
24use std::sync::Arc;
25use std::time::Duration;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum RunStatus {
31 Queued,
32 Running,
33 Completed,
34 Failed,
35 Cancelled,
36}
37
38impl RunStatus {
39 pub fn is_terminal(self) -> bool {
40 matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
41 }
42 pub fn as_str(self) -> &'static str {
43 match self {
44 Self::Queued => "queued",
45 Self::Running => "running",
46 Self::Completed => "completed",
47 Self::Failed => "failed",
48 Self::Cancelled => "cancelled",
49 }
50 }
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct InvocationRecord {
56 pub row_id: String,
57 pub parent_record_key: Option<String>,
58 pub records_written: usize,
59 pub error: Option<String>,
60}
61
62impl From<&InvocationOutcome> for InvocationRecord {
63 fn from(o: &InvocationOutcome) -> Self {
64 Self {
65 row_id: o.row_id.clone(),
66 parent_record_key: o.parent_record_key.clone(),
67 records_written: o.records_written,
68 error: o.error.clone(),
69 }
70 }
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct RunRecord {
76 pub run_id: String,
77 pub name: Option<String>,
78 pub labels: BTreeMap<String, String>,
79 pub status: RunStatus,
80 pub submitted_at: DateTime<Utc>,
81 pub started_at: Option<DateTime<Utc>>,
82 pub finished_at: Option<DateTime<Utc>>,
83 pub elapsed_secs: Option<f64>,
84 pub records_written: u64,
85 pub invocations: Vec<InvocationRecord>,
86 pub error: Option<String>,
87 pub idempotency_key: Option<String>,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub doctor_report: Option<serde_json::Value>,
90}
91
92impl RunRecord {
93 pub fn queued(
95 run_id: String,
96 name: Option<String>,
97 labels: BTreeMap<String, String>,
98 idempotency_key: Option<String>,
99 submitted_at: DateTime<Utc>,
100 ) -> Self {
101 Self {
102 run_id,
103 name,
104 labels,
105 status: RunStatus::Queued,
106 submitted_at,
107 started_at: None,
108 finished_at: None,
109 elapsed_secs: None,
110 records_written: 0,
111 invocations: Vec::new(),
112 error: None,
113 idempotency_key,
114 doctor_report: None,
115 }
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum Claim {
122 Fresh,
124 Replay(String),
126 Conflict,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum DeleteOutcome {
133 Deleted,
134 NotFound,
135 StillRunning,
136}
137
138#[derive(Debug, Default, Clone)]
140pub struct ListFilter {
141 pub status: Option<RunStatus>,
142 pub name: Option<String>,
143 pub since: Option<DateTime<Utc>>,
144 pub until: Option<DateTime<Utc>>,
145 pub limit: usize,
146 pub cursor: Option<String>,
147}
148
149#[derive(Debug)]
151pub struct ListPage {
152 pub runs: Vec<RunRecord>,
153 pub next_cursor: Option<String>,
154}
155
156#[derive(Debug, thiserror::Error)]
159pub enum HistoryError {
160 #[error("run-history backend error: {0}")]
161 Backend(String),
162 #[error("{0}")]
166 Degraded(String),
167}
168
169#[async_trait]
170pub trait RunHistory: Send + Sync {
171 async fn claim_idempotency(
174 &self,
175 key: &str,
176 fingerprint: &str,
177 run_id: &str,
178 window: Duration,
179 ) -> Result<Claim, HistoryError>;
180
181 async fn upsert(&self, rec: &RunRecord) -> Result<(), HistoryError>;
183
184 async fn get(&self, id: &str) -> Result<Option<RunRecord>, HistoryError>;
185
186 async fn list(&self, filter: &ListFilter) -> Result<ListPage, HistoryError>;
187
188 async fn delete(&self, id: &str) -> Result<DeleteOutcome, HistoryError>;
190
191 async fn purge_expired(&self, retain_for: Duration) -> Result<usize, HistoryError>;
194
195 async fn recover_orphans(&self) -> Result<usize, HistoryError>;
200
201 async fn renew_leases(&self) -> Result<usize, HistoryError> {
206 Ok(0)
207 }
208
209 fn degraded(&self) -> bool;
212}
213
214pub async fn connect(
219 spec: &HistoryBackendSpec,
220 idem_retention: Duration,
221 lease_ttl: Duration,
222 instance_id: &str,
223) -> CliResult<Arc<dyn RunHistory>> {
224 match spec {
225 HistoryBackendSpec::Memory => {
226 Ok(Arc::new(memory::MemoryHistory::new(idem_retention)) as Arc<dyn RunHistory>)
227 }
228 HistoryBackendSpec::Postgres(url) => {
229 connect_postgres(url, idem_retention, lease_ttl, instance_id).await
230 }
231 HistoryBackendSpec::Sqlite(url) => {
232 connect_sqlite(url, idem_retention, lease_ttl, instance_id).await
233 }
234 }
235}
236
237#[cfg(feature = "serve-history-postgres")]
238async fn connect_postgres(
239 url: &str,
240 idem: Duration,
241 lease_ttl: Duration,
242 instance_id: &str,
243) -> CliResult<Arc<dyn RunHistory>> {
244 Ok(into_history(
245 postgres::PostgresHistory::connect(url, idem, lease_ttl, instance_id.to_string()).await,
246 idem,
247 "postgres",
248 ))
249}
250
251#[cfg(not(feature = "serve-history-postgres"))]
252async fn connect_postgres(
253 _url: &str,
254 _idem: Duration,
255 _lease_ttl: Duration,
256 _instance_id: &str,
257) -> CliResult<Arc<dyn RunHistory>> {
258 Err(crate::error::CliError::Serve(
259 "persistent Postgres run history requires building faucet with the \
260 `serve-history-postgres` feature"
261 .into(),
262 ))
263}
264
265#[cfg(feature = "serve-history-sqlite")]
266async fn connect_sqlite(
267 url: &str,
268 idem: Duration,
269 lease_ttl: Duration,
270 instance_id: &str,
271) -> CliResult<Arc<dyn RunHistory>> {
272 Ok(into_history(
273 sqlite::SqliteHistory::connect(url, idem, lease_ttl, instance_id.to_string()).await,
274 idem,
275 "sqlite",
276 ))
277}
278
279#[cfg(not(feature = "serve-history-sqlite"))]
280async fn connect_sqlite(
281 _url: &str,
282 _idem: Duration,
283 _lease_ttl: Duration,
284 _instance_id: &str,
285) -> CliResult<Arc<dyn RunHistory>> {
286 Err(crate::error::CliError::Serve(
287 "persistent SQLite run history requires building faucet with the \
288 `serve-history-sqlite` feature"
289 .into(),
290 ))
291}
292
293#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
296fn into_history<H: RunHistory + 'static>(
297 result: Result<H, HistoryError>,
298 idem: Duration,
299 label: &'static str,
300) -> Arc<dyn RunHistory> {
301 match result {
302 Ok(backend) => Arc::new(fallback::FallbackHistory::healthy(
303 Box::new(backend),
304 idem,
305 label,
306 )),
307 Err(e) => {
308 tracing::error!(
309 backend = label, error = %e,
310 "run-history backend unavailable at startup; starting DEGRADED on in-memory store"
311 );
312 Arc::new(fallback::FallbackHistory::degraded_at_startup(idem, label))
313 }
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn terminal_classification() {
323 assert!(!RunStatus::Queued.is_terminal());
324 assert!(!RunStatus::Running.is_terminal());
325 assert!(RunStatus::Completed.is_terminal());
326 assert!(RunStatus::Failed.is_terminal());
327 assert!(RunStatus::Cancelled.is_terminal());
328 }
329
330 #[test]
331 fn run_record_serializes_status_snake_case() {
332 let rec = RunRecord::queued(
333 "r1".into(),
334 Some("n".into()),
335 Default::default(),
336 None,
337 Utc::now(),
338 );
339 let v = serde_json::to_value(&rec).unwrap();
340 assert_eq!(v["status"], "queued");
341 assert_eq!(v["run_id"], "r1");
342 assert!(v.get("doctor_report").is_none());
344 }
345}