1use super::{HistoryError, RunRecord, RunStatus};
20use chrono::{DateTime, Utc};
21use std::time::Duration;
22
23pub const DDL: &[&str] = &[
26 "CREATE TABLE IF NOT EXISTS faucet_serve_runs (\
31 run_id TEXT PRIMARY KEY,\
32 name TEXT,\
33 status TEXT NOT NULL,\
34 submitted_at TEXT NOT NULL,\
35 finished_at TEXT,\
36 idempotency_key TEXT,\
37 owner TEXT,\
38 lease_expires_at TEXT,\
39 cancel_requested TEXT,\
40 body TEXT NOT NULL)",
41 "CREATE INDEX IF NOT EXISTS faucet_serve_runs_submitted_idx \
42 ON faucet_serve_runs (submitted_at)",
43 "CREATE INDEX IF NOT EXISTS faucet_serve_runs_status_lease_idx \
46 ON faucet_serve_runs (status, lease_expires_at)",
47 "CREATE INDEX IF NOT EXISTS faucet_serve_runs_pending_idx \
49 ON faucet_serve_runs (status, submitted_at)",
50 "CREATE TABLE IF NOT EXISTS faucet_serve_instances (\
51 instance_id TEXT PRIMARY KEY,\
52 started_at TEXT NOT NULL,\
53 last_heartbeat TEXT NOT NULL,\
54 listen TEXT,\
55 max_concurrent TEXT,\
56 in_flight TEXT)",
57 "CREATE INDEX IF NOT EXISTS faucet_serve_instances_hb_idx \
58 ON faucet_serve_instances (last_heartbeat)",
59 "CREATE TABLE IF NOT EXISTS faucet_serve_idem (\
60 key TEXT PRIMARY KEY,\
61 run_id TEXT NOT NULL,\
62 fingerprint TEXT NOT NULL,\
63 claimed_at TEXT NOT NULL)",
64];
65
66#[derive(Clone, Copy, Debug)]
68pub enum Dialect {
69 Postgres,
70 Sqlite,
71}
72
73pub struct Stmts {
75 pub upsert: String,
79 pub select_body: String,
80 pub select_status: String,
81 pub select_submitted: String,
82 pub delete: String,
83 pub list: String,
84 pub purge_runs: String,
85 pub purge_idem: String,
86 pub select_orphans: String,
89 pub renew_leases: String,
92 pub insert_idem: String,
93 pub select_idem: String,
94 pub takeover_idem: String,
95 pub delete_idem_by_run: String,
100 pub select_pending: String,
102 pub claim_one: String,
104 pub reclaim_select: String,
108 pub reclaim_requeue: String,
110 pub reclaim_fail: String,
112 pub finalize_owned: String,
114 pub cancel_pending: String,
116 pub request_cancel: String,
118 pub pending_cancellations: String,
120 pub heartbeat_instance: String,
122 pub live_instances: String,
124 pub prune_instances: String,
126}
127
128impl Stmts {
129 pub fn new(dialect: Dialect) -> Self {
130 match dialect {
131 Dialect::Postgres => Self::postgres(),
132 Dialect::Sqlite => Self::sqlite(),
133 }
134 }
135
136 fn postgres() -> Self {
137 Self {
138 upsert: "INSERT INTO faucet_serve_runs \
139 (run_id,name,status,submitted_at,finished_at,idempotency_key,owner,lease_expires_at,body) \
140 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) \
141 ON CONFLICT (run_id) DO UPDATE SET \
142 name=excluded.name,status=excluded.status,submitted_at=excluded.submitted_at,\
143 finished_at=excluded.finished_at,idempotency_key=excluded.idempotency_key,\
144 owner=excluded.owner,lease_expires_at=excluded.lease_expires_at,\
145 body=excluded.body"
146 .into(),
147 select_body: "SELECT body FROM faucet_serve_runs WHERE run_id=$1".into(),
148 select_status: "SELECT status FROM faucet_serve_runs WHERE run_id=$1".into(),
149 select_submitted: "SELECT submitted_at FROM faucet_serve_runs WHERE run_id=$1".into(),
150 delete: "DELETE FROM faucet_serve_runs WHERE run_id=$1".into(),
151 list: "SELECT body FROM faucet_serve_runs \
154 WHERE ($1::text IS NULL OR status = $2::text) \
155 AND ($3::text IS NULL OR name = $4::text) \
156 AND ($5::text IS NULL OR submitted_at >= $6::text) \
157 AND ($7::text IS NULL OR submitted_at <= $8::text) \
158 AND ($9::text IS NULL OR (submitted_at < $10::text \
159 OR (submitted_at = $11::text AND run_id < $12::text))) \
160 ORDER BY submitted_at DESC, run_id DESC LIMIT $13"
161 .into(),
162 purge_runs: "DELETE FROM faucet_serve_runs \
163 WHERE status IN ('completed','failed','cancelled') \
164 AND finished_at IS NOT NULL AND finished_at < $1"
165 .into(),
166 purge_idem: "DELETE FROM faucet_serve_idem WHERE claimed_at < $1".into(),
167 select_orphans: "SELECT body FROM faucet_serve_runs \
168 WHERE status IN ('queued','running') \
169 AND (lease_expires_at IS NULL OR lease_expires_at < $1)"
170 .into(),
171 renew_leases: "UPDATE faucet_serve_runs SET lease_expires_at = $1 \
172 WHERE owner = $2 AND status IN ('queued','running')"
173 .into(),
174 insert_idem: "INSERT INTO faucet_serve_idem (key,run_id,fingerprint,claimed_at) \
175 VALUES ($1,$2,$3,$4) ON CONFLICT (key) DO NOTHING"
176 .into(),
177 select_idem: "SELECT run_id,fingerprint,claimed_at FROM faucet_serve_idem WHERE key=$1"
178 .into(),
179 takeover_idem: "UPDATE faucet_serve_idem \
180 SET run_id=$1,fingerprint=$2,claimed_at=$3 WHERE key=$4 AND claimed_at=$5"
181 .into(),
182 delete_idem_by_run: "DELETE FROM faucet_serve_idem WHERE run_id=$1".into(),
183 select_pending: "SELECT run_id, body FROM faucet_serve_runs \
184 WHERE status = 'pending' ORDER BY submitted_at ASC LIMIT $1"
185 .into(),
186 claim_one: "UPDATE faucet_serve_runs \
187 SET owner = $1, status = 'running', lease_expires_at = $2, body = $3 \
188 WHERE run_id = $4 AND status = 'pending'"
189 .into(),
190 reclaim_select: "SELECT body FROM faucet_serve_runs \
191 WHERE status = 'running' \
192 AND (lease_expires_at IS NULL OR lease_expires_at < $1)"
193 .into(),
194 reclaim_requeue: "UPDATE faucet_serve_runs \
195 SET status = 'pending', owner = NULL, lease_expires_at = NULL, \
196 cancel_requested = NULL, body = $1 \
197 WHERE run_id = $2 AND status = 'running' \
198 AND (lease_expires_at IS NULL OR lease_expires_at < $3)"
199 .into(),
200 reclaim_fail: "UPDATE faucet_serve_runs \
201 SET status = 'failed', finished_at = $1, body = $2, owner = NULL \
202 WHERE run_id = $3 AND status = 'running' \
203 AND (lease_expires_at IS NULL OR lease_expires_at < $4)"
204 .into(),
205 finalize_owned: "UPDATE faucet_serve_runs \
206 SET status = $1, finished_at = $2, lease_expires_at = $3, body = $4 \
207 WHERE run_id = $5 AND owner = $6"
208 .into(),
209 cancel_pending: "UPDATE faucet_serve_runs \
210 SET status = 'cancelled', finished_at = $1, body = $2 \
211 WHERE run_id = $3 AND status = 'pending'"
212 .into(),
213 request_cancel: "UPDATE faucet_serve_runs \
214 SET cancel_requested = $1 WHERE run_id = $2 AND status = 'running'"
215 .into(),
216 pending_cancellations: "SELECT run_id FROM faucet_serve_runs \
217 WHERE status = 'running' AND owner = $1 AND cancel_requested IS NOT NULL"
218 .into(),
219 heartbeat_instance: "INSERT INTO faucet_serve_instances \
220 (instance_id, started_at, last_heartbeat, listen, max_concurrent, in_flight) \
221 VALUES ($1,$2,$3,$4,$5,$6) \
222 ON CONFLICT (instance_id) DO UPDATE SET \
223 last_heartbeat = excluded.last_heartbeat, listen = excluded.listen, \
224 max_concurrent = excluded.max_concurrent, in_flight = excluded.in_flight"
225 .into(),
226 live_instances: "SELECT instance_id, started_at, last_heartbeat, listen, \
227 max_concurrent, in_flight FROM faucet_serve_instances \
228 WHERE last_heartbeat >= $1"
229 .into(),
230 prune_instances: "DELETE FROM faucet_serve_instances WHERE last_heartbeat < $1".into(),
231 }
232 }
233
234 fn sqlite() -> Self {
235 Self {
236 upsert: "INSERT INTO faucet_serve_runs \
237 (run_id,name,status,submitted_at,finished_at,idempotency_key,owner,lease_expires_at,body) \
238 VALUES (?,?,?,?,?,?,?,?,?) \
239 ON CONFLICT (run_id) DO UPDATE SET \
240 name=excluded.name,status=excluded.status,submitted_at=excluded.submitted_at,\
241 finished_at=excluded.finished_at,idempotency_key=excluded.idempotency_key,\
242 owner=excluded.owner,lease_expires_at=excluded.lease_expires_at,\
243 body=excluded.body"
244 .into(),
245 select_body: "SELECT body FROM faucet_serve_runs WHERE run_id=?".into(),
246 select_status: "SELECT status FROM faucet_serve_runs WHERE run_id=?".into(),
247 select_submitted: "SELECT submitted_at FROM faucet_serve_runs WHERE run_id=?".into(),
248 delete: "DELETE FROM faucet_serve_runs WHERE run_id=?".into(),
249 list: "SELECT body FROM faucet_serve_runs \
250 WHERE (? IS NULL OR status = ?) \
251 AND (? IS NULL OR name = ?) \
252 AND (? IS NULL OR submitted_at >= ?) \
253 AND (? IS NULL OR submitted_at <= ?) \
254 AND (? IS NULL OR (submitted_at < ? \
255 OR (submitted_at = ? AND run_id < ?))) \
256 ORDER BY submitted_at DESC, run_id DESC LIMIT ?"
257 .into(),
258 purge_runs: "DELETE FROM faucet_serve_runs \
259 WHERE status IN ('completed','failed','cancelled') \
260 AND finished_at IS NOT NULL AND finished_at < ?"
261 .into(),
262 purge_idem: "DELETE FROM faucet_serve_idem WHERE claimed_at < ?".into(),
263 select_orphans: "SELECT body FROM faucet_serve_runs \
264 WHERE status IN ('queued','running') \
265 AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
266 .into(),
267 renew_leases: "UPDATE faucet_serve_runs SET lease_expires_at = ? \
268 WHERE owner = ? AND status IN ('queued','running')"
269 .into(),
270 insert_idem: "INSERT INTO faucet_serve_idem (key,run_id,fingerprint,claimed_at) \
271 VALUES (?,?,?,?) ON CONFLICT (key) DO NOTHING"
272 .into(),
273 select_idem: "SELECT run_id,fingerprint,claimed_at FROM faucet_serve_idem WHERE key=?"
274 .into(),
275 takeover_idem: "UPDATE faucet_serve_idem \
276 SET run_id=?,fingerprint=?,claimed_at=? WHERE key=? AND claimed_at=?"
277 .into(),
278 delete_idem_by_run: "DELETE FROM faucet_serve_idem WHERE run_id=?".into(),
279 select_pending: "SELECT run_id, body FROM faucet_serve_runs \
280 WHERE status = 'pending' ORDER BY submitted_at ASC LIMIT ?"
281 .into(),
282 claim_one: "UPDATE faucet_serve_runs \
283 SET owner = ?, status = 'running', lease_expires_at = ?, body = ? \
284 WHERE run_id = ? AND status = 'pending'"
285 .into(),
286 reclaim_select: "SELECT body FROM faucet_serve_runs \
287 WHERE status = 'running' \
288 AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
289 .into(),
290 reclaim_requeue: "UPDATE faucet_serve_runs \
291 SET status = 'pending', owner = NULL, lease_expires_at = NULL, \
292 cancel_requested = NULL, body = ? \
293 WHERE run_id = ? AND status = 'running' \
294 AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
295 .into(),
296 reclaim_fail: "UPDATE faucet_serve_runs \
297 SET status = 'failed', finished_at = ?, body = ?, owner = NULL \
298 WHERE run_id = ? AND status = 'running' \
299 AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
300 .into(),
301 finalize_owned: "UPDATE faucet_serve_runs \
302 SET status = ?, finished_at = ?, lease_expires_at = ?, body = ? \
303 WHERE run_id = ? AND owner = ?"
304 .into(),
305 cancel_pending: "UPDATE faucet_serve_runs \
306 SET status = 'cancelled', finished_at = ?, body = ? \
307 WHERE run_id = ? AND status = 'pending'"
308 .into(),
309 request_cancel: "UPDATE faucet_serve_runs \
310 SET cancel_requested = ? WHERE run_id = ? AND status = 'running'"
311 .into(),
312 pending_cancellations: "SELECT run_id FROM faucet_serve_runs \
313 WHERE status = 'running' AND owner = ? AND cancel_requested IS NOT NULL"
314 .into(),
315 heartbeat_instance: "INSERT INTO faucet_serve_instances \
316 (instance_id, started_at, last_heartbeat, listen, max_concurrent, in_flight) \
317 VALUES (?,?,?,?,?,?) \
318 ON CONFLICT (instance_id) DO UPDATE SET \
319 last_heartbeat = excluded.last_heartbeat, listen = excluded.listen, \
320 max_concurrent = excluded.max_concurrent, in_flight = excluded.in_flight"
321 .into(),
322 live_instances: "SELECT instance_id, started_at, last_heartbeat, listen, \
323 max_concurrent, in_flight FROM faucet_serve_instances \
324 WHERE last_heartbeat >= ?"
325 .into(),
326 prune_instances: "DELETE FROM faucet_serve_instances WHERE last_heartbeat < ?".into(),
327 }
328 }
329}
330
331pub const CLAIM_ATTEMPTS: usize = 4;
334
335pub fn fmt_ts(dt: DateTime<Utc>) -> String {
337 dt.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true)
338}
339
340pub fn is_expired(claimed_at: &str, now: DateTime<Utc>, window: Duration) -> bool {
344 match DateTime::parse_from_rfc3339(claimed_at) {
345 Ok(t) => now
346 .signed_duration_since(t.with_timezone(&Utc))
347 .to_std()
348 .map(|age| age >= window)
349 .unwrap_or(false),
350 Err(_) => false,
351 }
352}
353
354pub fn threshold(now: DateTime<Utc>, window: Duration) -> String {
356 let delta =
357 chrono::Duration::from_std(window).unwrap_or_else(|_| chrono::Duration::days(36_500));
358 fmt_ts(now - delta)
359}
360
361pub fn encode_body(rec: &RunRecord) -> Result<String, HistoryError> {
362 serde_json::to_string(rec).map_err(|e| HistoryError::Backend(format!("encode run record: {e}")))
363}
364
365pub fn decode_body(body: &str) -> Result<RunRecord, HistoryError> {
366 serde_json::from_str(body).map_err(|e| HistoryError::Backend(format!("decode run record: {e}")))
367}
368
369pub fn parse_status(s: &str) -> RunStatus {
370 match s {
371 "queued" => RunStatus::Queued,
372 "pending" => RunStatus::Pending,
373 "running" => RunStatus::Running,
374 "completed" => RunStatus::Completed,
375 "cancelled" => RunStatus::Cancelled,
376 _ => RunStatus::Failed,
377 }
378}
379
380macro_rules! impl_sql_history {
384 ($name:ident, $pool:ty) => {
385 pub struct $name {
388 pool: $pool,
389 idem_retention: std::time::Duration,
390 instance_id: String,
392 lease_ttl: std::time::Duration,
394 stmts: $crate::serve::history::sql::Stmts,
395 }
396
397 impl $name {
398 pub fn from_parts(
400 pool: $pool,
401 idem_retention: std::time::Duration,
402 lease_ttl: std::time::Duration,
403 instance_id: String,
404 stmts: $crate::serve::history::sql::Stmts,
405 ) -> Self {
406 Self {
407 pool,
408 idem_retention,
409 instance_id,
410 lease_ttl,
411 stmts,
412 }
413 }
414
415 pub fn pool(&self) -> &$pool {
417 &self.pool
418 }
419 }
420
421 #[async_trait::async_trait]
422 impl $crate::serve::history::RunHistory for $name {
423 async fn claim_idempotency(
424 &self,
425 key: &str,
426 fingerprint: &str,
427 run_id: &str,
428 window: std::time::Duration,
429 ) -> Result<$crate::serve::history::Claim, $crate::serve::history::HistoryError> {
430 use sqlx::Row as _;
431 use $crate::serve::history::Claim;
432 use $crate::serve::history::HistoryError;
433 use $crate::serve::history::sql;
434
435 let now = chrono::Utc::now();
436 let now_s = sql::fmt_ts(now);
437 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
438
439 for _ in 0..sql::CLAIM_ATTEMPTS {
440 let inserted = sqlx::query(&self.stmts.insert_idem)
442 .bind(key)
443 .bind(run_id)
444 .bind(fingerprint)
445 .bind(&now_s)
446 .execute(&self.pool)
447 .await
448 .map_err(backend)?
449 .rows_affected();
450 if inserted == 1 {
451 return Ok(Claim::Fresh);
452 }
453 let Some(row) = sqlx::query(&self.stmts.select_idem)
455 .bind(key)
456 .fetch_optional(&self.pool)
457 .await
458 .map_err(backend)?
459 else {
460 continue;
462 };
463 let existing_run: String = row.try_get("run_id").map_err(backend)?;
464 let existing_fp: String = row.try_get("fingerprint").map_err(backend)?;
465 let claimed_at: String = row.try_get("claimed_at").map_err(backend)?;
466
467 if sql::is_expired(&claimed_at, now, window) {
468 let took = sqlx::query(&self.stmts.takeover_idem)
471 .bind(run_id)
472 .bind(fingerprint)
473 .bind(&now_s)
474 .bind(key)
475 .bind(&claimed_at)
476 .execute(&self.pool)
477 .await
478 .map_err(backend)?
479 .rows_affected();
480 if took == 1 {
481 return Ok(Claim::Fresh);
482 }
483 continue; }
485 return Ok(if existing_fp == fingerprint {
486 Claim::Replay(existing_run)
487 } else {
488 Claim::Conflict
489 });
490 }
491 tracing::warn!(
494 key,
495 "idempotency claim exhausted retries; reporting conflict"
496 );
497 Ok(Claim::Conflict)
498 }
499
500 async fn upsert(
501 &self,
502 rec: &$crate::serve::history::RunRecord,
503 ) -> Result<(), $crate::serve::history::HistoryError> {
504 use $crate::serve::history::HistoryError;
505 use $crate::serve::history::sql;
506 let body = sql::encode_body(rec)?;
507 let submitted = sql::fmt_ts(rec.submitted_at);
508 let finished = rec.finished_at.map(sql::fmt_ts);
509 let lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
514 sqlx::query(&self.stmts.upsert)
515 .bind(&rec.run_id)
516 .bind(rec.name.as_deref())
517 .bind(rec.status.as_str())
518 .bind(&submitted)
519 .bind(finished.as_deref())
520 .bind(rec.idempotency_key.as_deref())
521 .bind(&self.instance_id)
522 .bind(&lease)
523 .bind(&body)
524 .execute(&self.pool)
525 .await
526 .map_err(|e| HistoryError::Backend(e.to_string()))?;
527 Ok(())
528 }
529
530 async fn get(
531 &self,
532 id: &str,
533 ) -> Result<
534 Option<$crate::serve::history::RunRecord>,
535 $crate::serve::history::HistoryError,
536 > {
537 use sqlx::Row as _;
538 use $crate::serve::history::HistoryError;
539 use $crate::serve::history::sql;
540 let row = sqlx::query(&self.stmts.select_body)
541 .bind(id)
542 .fetch_optional(&self.pool)
543 .await
544 .map_err(|e| HistoryError::Backend(e.to_string()))?;
545 match row {
546 None => Ok(None),
547 Some(r) => {
548 let body: String = r
549 .try_get("body")
550 .map_err(|e| HistoryError::Backend(e.to_string()))?;
551 Ok(Some(sql::decode_body(&body)?))
552 }
553 }
554 }
555
556 async fn list(
557 &self,
558 filter: &$crate::serve::history::ListFilter,
559 ) -> Result<$crate::serve::history::ListPage, $crate::serve::history::HistoryError>
560 {
561 use sqlx::Row as _;
562 use $crate::serve::history::HistoryError;
563 use $crate::serve::history::ListPage;
564 use $crate::serve::history::sql;
565 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
566
567 let cursor_ts: Option<String> = match &filter.cursor {
571 None => None,
572 Some(c) => sqlx::query(&self.stmts.select_submitted)
573 .bind(c)
574 .fetch_optional(&self.pool)
575 .await
576 .map_err(backend)?
577 .map(|r| r.try_get::<String, _>("submitted_at"))
578 .transpose()
579 .map_err(backend)?,
580 };
581 let cur_id = if cursor_ts.is_some() {
582 filter.cursor.as_deref()
583 } else {
584 None
585 };
586
587 let status_s = filter.status.map(|s| s.as_str());
588 let name_s = filter.name.as_deref();
589 let since_s = filter.since.map(sql::fmt_ts);
590 let until_s = filter.until.map(sql::fmt_ts);
591 let limit = filter.limit.max(1);
592 let fetch_n = limit as i64 + 1; let rows = sqlx::query(&self.stmts.list)
595 .bind(status_s)
596 .bind(status_s)
597 .bind(name_s)
598 .bind(name_s)
599 .bind(since_s.as_deref())
600 .bind(since_s.as_deref())
601 .bind(until_s.as_deref())
602 .bind(until_s.as_deref())
603 .bind(cursor_ts.as_deref())
604 .bind(cursor_ts.as_deref())
605 .bind(cursor_ts.as_deref())
606 .bind(cur_id)
607 .bind(fetch_n)
608 .fetch_all(&self.pool)
609 .await
610 .map_err(backend)?;
611
612 let mut runs = Vec::with_capacity(rows.len());
613 for r in &rows {
614 let body: String = r.try_get("body").map_err(backend)?;
615 runs.push(sql::decode_body(&body)?);
616 }
617 let next_cursor = if runs.len() > limit {
618 Some(runs[limit - 1].run_id.clone())
619 } else {
620 None
621 };
622 runs.truncate(limit);
623 Ok(ListPage { runs, next_cursor })
624 }
625
626 async fn delete(
627 &self,
628 id: &str,
629 ) -> Result<$crate::serve::history::DeleteOutcome, $crate::serve::history::HistoryError>
630 {
631 use sqlx::Row as _;
632 use $crate::serve::history::DeleteOutcome;
633 use $crate::serve::history::HistoryError;
634 use $crate::serve::history::sql;
635 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
636 let status: Option<String> = sqlx::query(&self.stmts.select_status)
637 .bind(id)
638 .fetch_optional(&self.pool)
639 .await
640 .map_err(backend)?
641 .map(|r| r.try_get::<String, _>("status"))
642 .transpose()
643 .map_err(backend)?;
644 match status {
645 None => Ok(DeleteOutcome::NotFound),
646 Some(s) if !sql::parse_status(&s).is_terminal() => {
647 Ok(DeleteOutcome::StillRunning)
648 }
649 Some(_) => {
650 sqlx::query(&self.stmts.delete)
651 .bind(id)
652 .execute(&self.pool)
653 .await
654 .map_err(backend)?;
655 sqlx::query(&self.stmts.delete_idem_by_run)
661 .bind(id)
662 .execute(&self.pool)
663 .await
664 .map_err(backend)?;
665 Ok(DeleteOutcome::Deleted)
666 }
667 }
668 }
669
670 async fn purge_expired(
671 &self,
672 retain_for: std::time::Duration,
673 ) -> Result<usize, $crate::serve::history::HistoryError> {
674 use $crate::serve::history::HistoryError;
675 use $crate::serve::history::sql;
676 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
677 let now = chrono::Utc::now();
678 let removed = sqlx::query(&self.stmts.purge_runs)
679 .bind(sql::threshold(now, retain_for))
680 .execute(&self.pool)
681 .await
682 .map_err(backend)?
683 .rows_affected() as usize;
684 let _ = sqlx::query(&self.stmts.purge_idem)
686 .bind(sql::threshold(now, self.idem_retention))
687 .execute(&self.pool)
688 .await;
689 let _ = sqlx::query(&self.stmts.prune_instances)
693 .bind(sql::threshold(now, retain_for))
694 .execute(&self.pool)
695 .await;
696 Ok(removed)
697 }
698
699 async fn recover_orphans(&self) -> Result<usize, $crate::serve::history::HistoryError> {
700 use sqlx::Row as _;
701 use $crate::serve::history::HistoryError;
702 use $crate::serve::history::RunStatus;
703 use $crate::serve::history::sql;
704 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
705 let now = chrono::Utc::now();
706 let rows = sqlx::query(&self.stmts.select_orphans)
711 .bind(sql::fmt_ts(now))
712 .fetch_all(&self.pool)
713 .await
714 .map_err(backend)?;
715 let mut count = 0usize;
716 for r in &rows {
717 let body: String = r.try_get("body").map_err(backend)?;
718 let mut rec = sql::decode_body(&body)?;
719 rec.status = RunStatus::Failed;
720 rec.finished_at = Some(now);
721 rec.error = Some(
722 "owning serve instance's lease expired before the run finished".into(),
723 );
724 if rec.elapsed_secs.is_none()
725 && let Some(started) = rec.started_at
726 {
727 rec.elapsed_secs = (now - started).to_std().ok().map(|d| d.as_secs_f64());
728 }
729 self.upsert(&rec).await?;
730 count += 1;
731 }
732 Ok(count)
733 }
734
735 async fn renew_leases(&self) -> Result<usize, $crate::serve::history::HistoryError> {
736 use $crate::serve::history::HistoryError;
737 use $crate::serve::history::sql;
738 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
739 let new_lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
740 let renewed = sqlx::query(&self.stmts.renew_leases)
741 .bind(&new_lease)
742 .bind(&self.instance_id)
743 .execute(&self.pool)
744 .await
745 .map_err(backend)?
746 .rows_affected() as usize;
747 Ok(renewed)
748 }
749
750 async fn claim_pending(
751 &self,
752 limit: usize,
753 ) -> Result<Vec<$crate::serve::history::RunRecord>, $crate::serve::history::HistoryError>
754 {
755 use sqlx::Row as _;
756 use $crate::serve::history::HistoryError;
757 use $crate::serve::history::RunStatus;
758 use $crate::serve::history::sql;
759 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
760 if limit == 0 {
761 return Ok(Vec::new());
762 }
763 let now = chrono::Utc::now();
764 let lease = sql::fmt_ts(now + self.lease_ttl);
765
766 let rows = sqlx::query(&self.stmts.select_pending)
768 .bind(limit as i64)
769 .fetch_all(&self.pool)
770 .await
771 .map_err(backend)?;
772
773 let mut claimed = Vec::new();
778 for row in &rows {
779 let run_id: String = row.try_get("run_id").map_err(backend)?;
780 let body: String = row.try_get("body").map_err(backend)?;
781 let mut r = sql::decode_body(&body)?;
785 r.status = RunStatus::Running;
786 let new_body = sql::encode_body(&r)?;
787 let won = sqlx::query(&self.stmts.claim_one)
789 .bind(&self.instance_id)
790 .bind(&lease)
791 .bind(&new_body)
792 .bind(&run_id)
793 .execute(&self.pool)
794 .await
795 .map_err(backend)?
796 .rows_affected();
797 if won == 1 {
798 claimed.push(r);
799 }
800 }
801 Ok(claimed)
802 }
803
804 async fn reclaim_orphans(
805 &self,
806 max_attempts: u32,
807 ) -> Result<$crate::serve::history::ReclaimReport, $crate::serve::history::HistoryError>
808 {
809 use sqlx::Row as _;
810 use $crate::serve::history::HistoryError;
811 use $crate::serve::history::ReclaimReport;
812 use $crate::serve::history::RunStatus;
813 use $crate::serve::history::sql;
814 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
815 let now = chrono::Utc::now();
816 let now_s = sql::fmt_ts(now);
817
818 let rows = sqlx::query(&self.stmts.reclaim_select)
819 .bind(&now_s)
820 .fetch_all(&self.pool)
821 .await
822 .map_err(backend)?;
823
824 let mut report = ReclaimReport::default();
825 for row in &rows {
826 let body: String = row.try_get("body").map_err(backend)?;
827 let mut rec = sql::decode_body(&body)?;
828 let next_attempt = rec.attempt + 1;
829 if rec.attempt < max_attempts {
833 rec.attempt = next_attempt;
835 rec.status = RunStatus::Pending;
836 let new_body = sql::encode_body(&rec)?;
837 let n = sqlx::query(&self.stmts.reclaim_requeue)
838 .bind(&new_body)
839 .bind(&rec.run_id)
840 .bind(&now_s)
841 .execute(&self.pool)
842 .await
843 .map_err(backend)?
844 .rows_affected();
845 if n == 1 {
846 report.requeued += 1;
847 }
848 } else {
849 rec.attempt = next_attempt;
851 rec.status = RunStatus::Failed;
852 rec.finished_at = Some(now);
853 rec.error = Some(format!(
854 "run reclaimed {next_attempt} times after its owning instance's \
855 lease expired; giving up (poison run)"
856 ));
857 if rec.elapsed_secs.is_none()
858 && let Some(started) = rec.started_at
859 {
860 rec.elapsed_secs =
861 (now - started).to_std().ok().map(|d| d.as_secs_f64());
862 }
863 let new_body = sql::encode_body(&rec)?;
864 let n = sqlx::query(&self.stmts.reclaim_fail)
865 .bind(&now_s)
866 .bind(&new_body)
867 .bind(&rec.run_id)
868 .bind(&now_s)
869 .execute(&self.pool)
870 .await
871 .map_err(backend)?
872 .rows_affected();
873 if n == 1 {
874 report.failed += 1;
875 }
876 }
877 }
878 Ok(report)
879 }
880
881 async fn finalize_owned(
882 &self,
883 rec: &$crate::serve::history::RunRecord,
884 ) -> Result<bool, $crate::serve::history::HistoryError> {
885 use $crate::serve::history::HistoryError;
886 use $crate::serve::history::sql;
887 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
888 let mut rec = rec.clone();
892 if rec.status.is_terminal() && rec.finished_at.is_none() {
893 rec.finished_at = Some(chrono::Utc::now());
894 }
895 let body = sql::encode_body(&rec)?;
896 let finished = rec.finished_at.map(sql::fmt_ts);
897 let lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
898 let n = sqlx::query(&self.stmts.finalize_owned)
899 .bind(rec.status.as_str())
900 .bind(finished.as_deref())
901 .bind(&lease)
902 .bind(&body)
903 .bind(&rec.run_id)
904 .bind(&self.instance_id)
905 .execute(&self.pool)
906 .await
907 .map_err(backend)?
908 .rows_affected();
909 Ok(n == 1)
910 }
911
912 async fn cancel_pending(
913 &self,
914 run_id: &str,
915 ) -> Result<bool, $crate::serve::history::HistoryError> {
916 use sqlx::Row as _;
917 use $crate::serve::history::HistoryError;
918 use $crate::serve::history::RunStatus;
919 use $crate::serve::history::sql;
920 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
921 let Some(row) = sqlx::query(&self.stmts.select_body)
924 .bind(run_id)
925 .fetch_optional(&self.pool)
926 .await
927 .map_err(backend)?
928 else {
929 return Ok(false);
930 };
931 let body: String = row.try_get("body").map_err(backend)?;
932 let mut rec = sql::decode_body(&body)?;
933 if rec.status != RunStatus::Pending {
934 return Ok(false);
935 }
936 let now = chrono::Utc::now();
937 rec.status = RunStatus::Cancelled;
938 rec.finished_at = Some(now);
939 let new_body = sql::encode_body(&rec)?;
940 let n = sqlx::query(&self.stmts.cancel_pending)
941 .bind(sql::fmt_ts(now))
942 .bind(&new_body)
943 .bind(run_id)
944 .execute(&self.pool)
945 .await
946 .map_err(backend)?
947 .rows_affected();
948 Ok(n == 1)
949 }
950
951 async fn request_cancel(
952 &self,
953 run_id: &str,
954 ) -> Result<(), $crate::serve::history::HistoryError> {
955 use $crate::serve::history::HistoryError;
956 use $crate::serve::history::sql;
957 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
958 sqlx::query(&self.stmts.request_cancel)
959 .bind(sql::fmt_ts(chrono::Utc::now()))
960 .bind(run_id)
961 .execute(&self.pool)
962 .await
963 .map_err(backend)?;
964 Ok(())
965 }
966
967 async fn pending_cancellations(
968 &self,
969 ) -> Result<Vec<String>, $crate::serve::history::HistoryError> {
970 use sqlx::Row as _;
971 use $crate::serve::history::HistoryError;
972 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
973 let rows = sqlx::query(&self.stmts.pending_cancellations)
974 .bind(&self.instance_id)
975 .fetch_all(&self.pool)
976 .await
977 .map_err(backend)?;
978 let mut ids = Vec::with_capacity(rows.len());
979 for r in &rows {
980 ids.push(r.try_get::<String, _>("run_id").map_err(backend)?);
981 }
982 Ok(ids)
983 }
984
985 async fn heartbeat_instance(
986 &self,
987 beat: &$crate::serve::history::InstanceHeartbeat,
988 ) -> Result<(), $crate::serve::history::HistoryError> {
989 use $crate::serve::history::HistoryError;
990 use $crate::serve::history::sql;
991 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
992 let now = sql::fmt_ts(chrono::Utc::now());
993 sqlx::query(&self.stmts.heartbeat_instance)
994 .bind(&self.instance_id)
995 .bind(sql::fmt_ts(beat.started_at))
996 .bind(&now)
997 .bind(beat.listen.as_deref())
998 .bind(beat.max_concurrent.to_string())
999 .bind(beat.in_flight.to_string())
1000 .execute(&self.pool)
1001 .await
1002 .map_err(backend)?;
1003 Ok(())
1004 }
1005
1006 async fn live_instances(
1007 &self,
1008 ttl: std::time::Duration,
1009 ) -> Result<Vec<$crate::serve::history::InstanceRecord>, $crate::serve::history::HistoryError>
1010 {
1011 use sqlx::Row as _;
1012 use $crate::serve::history::HistoryError;
1013 use $crate::serve::history::InstanceRecord;
1014 use $crate::serve::history::sql;
1015 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1016 let now = chrono::Utc::now();
1017 let rows = sqlx::query(&self.stmts.live_instances)
1018 .bind(sql::threshold(now, ttl))
1019 .fetch_all(&self.pool)
1020 .await
1021 .map_err(backend)?;
1022 let parse_dt = |s: &str| {
1023 chrono::DateTime::parse_from_rfc3339(s)
1024 .map(|d| d.to_utc())
1025 .unwrap_or(now)
1026 };
1027 let mut out = Vec::with_capacity(rows.len());
1028 for r in &rows {
1029 let started: String = r.try_get("started_at").map_err(backend)?;
1030 let hb: String = r.try_get("last_heartbeat").map_err(backend)?;
1031 let mc: Option<String> = r.try_get("max_concurrent").map_err(backend)?;
1032 let inf: Option<String> = r.try_get("in_flight").map_err(backend)?;
1033 out.push(InstanceRecord {
1034 instance_id: r.try_get("instance_id").map_err(backend)?,
1035 started_at: parse_dt(&started),
1036 last_heartbeat: parse_dt(&hb),
1037 listen: r.try_get("listen").map_err(backend)?,
1038 max_concurrent: mc.and_then(|s| s.parse().ok()).unwrap_or(0),
1039 in_flight: inf.and_then(|s| s.parse().ok()).unwrap_or(0),
1040 });
1041 }
1042 Ok(out)
1043 }
1044
1045 fn degraded(&self) -> bool {
1046 false
1049 }
1050 }
1051 };
1052}
1053
1054pub(crate) use impl_sql_history;
1055
1056#[cfg(test)]
1057mod tests {
1058 use super::*;
1059
1060 #[test]
1061 fn fmt_ts_is_fixed_width_and_sortable() {
1062 let a = fmt_ts(
1063 DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
1064 .unwrap()
1065 .to_utc(),
1066 );
1067 let b = fmt_ts(
1068 DateTime::parse_from_rfc3339("2026-01-01T00:00:01Z")
1069 .unwrap()
1070 .to_utc(),
1071 );
1072 assert!(a.ends_with('Z'));
1073 assert_eq!(a.len(), b.len(), "fixed width");
1074 assert!(a < b, "lexicographic order matches chronological order");
1075 }
1076
1077 #[test]
1078 fn is_expired_respects_window() {
1079 let now = Utc::now();
1080 let old = fmt_ts(now - chrono::Duration::seconds(120));
1081 assert!(is_expired(&old, now, Duration::from_secs(60)));
1082 assert!(!is_expired(&old, now, Duration::from_secs(600)));
1083 assert!(!is_expired("not-a-timestamp", now, Duration::ZERO));
1085 }
1086
1087 #[test]
1088 fn parse_status_round_trips_known_and_defaults_failed() {
1089 for s in [
1090 RunStatus::Queued,
1091 RunStatus::Pending,
1092 RunStatus::Running,
1093 RunStatus::Completed,
1094 RunStatus::Failed,
1095 RunStatus::Cancelled,
1096 ] {
1097 assert_eq!(parse_status(s.as_str()), s);
1098 }
1099 assert_eq!(parse_status("garbage"), RunStatus::Failed);
1100 }
1101
1102 #[test]
1103 fn body_round_trips() {
1104 let rec = RunRecord::queued(
1105 "r1".into(),
1106 Some("n".into()),
1107 Default::default(),
1108 Some("idem".into()),
1109 Utc::now(),
1110 );
1111 let encoded = encode_body(&rec).unwrap();
1112 let decoded = decode_body(&encoded).unwrap();
1113 assert_eq!(decoded.run_id, "r1");
1114 assert_eq!(decoded.idempotency_key.as_deref(), Some("idem"));
1115 }
1116
1117 #[test]
1118 fn postgres_and_sqlite_statements_differ_only_in_placeholders() {
1119 let pg = Stmts::new(Dialect::Postgres);
1120 let lite = Stmts::new(Dialect::Sqlite);
1121 assert!(pg.upsert.contains("$1") && lite.upsert.contains('?'));
1122 assert!(pg.list.contains("$13") && lite.list.contains('?'));
1123 assert!(pg.insert_idem.contains("ON CONFLICT (key) DO NOTHING"));
1125 assert!(lite.insert_idem.contains("ON CONFLICT (key) DO NOTHING"));
1126 assert!(pg.claim_one.contains("$3") && lite.claim_one.contains('?'));
1127 assert!(pg.heartbeat_instance.contains("faucet_serve_instances"));
1128 assert!(lite.heartbeat_instance.contains("faucet_serve_instances"));
1129 }
1130}