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 "CREATE TABLE IF NOT EXISTS faucet_serve_shards (\
70 run_id TEXT NOT NULL,\
71 shard_id TEXT NOT NULL,\
72 descriptor TEXT NOT NULL,\
73 size_estimate TEXT,\
74 status TEXT NOT NULL,\
75 owner TEXT,\
76 lease_expires_at TEXT,\
77 attempt TEXT NOT NULL,\
78 finished_at TEXT,\
79 PRIMARY KEY (run_id, shard_id))",
80 "CREATE INDEX IF NOT EXISTS faucet_serve_shards_claim_idx \
81 ON faucet_serve_shards (status, lease_expires_at)",
82];
83
84#[derive(Clone, Copy, Debug)]
86pub enum Dialect {
87 Postgres,
88 Sqlite,
89}
90
91pub struct Stmts {
93 pub upsert: String,
97 pub select_body: String,
98 pub select_status: String,
99 pub select_submitted: String,
100 pub delete: String,
101 pub list: String,
102 pub purge_runs: String,
103 pub purge_idem: String,
104 pub select_orphans: String,
107 pub renew_leases: String,
110 pub insert_idem: String,
111 pub select_idem: String,
112 pub takeover_idem: String,
113 pub delete_idem_by_run: String,
118 pub select_pending: String,
120 pub claim_one: String,
122 pub reclaim_select: String,
126 pub reclaim_requeue: String,
128 pub reclaim_fail: String,
130 pub finalize_owned: String,
132 pub cancel_pending: String,
134 pub request_cancel: String,
136 pub pending_cancellations: String,
138 pub heartbeat_instance: String,
140 pub live_instances: String,
142 pub prune_instances: String,
144 pub insert_shard: String,
147 pub claim_shards_select: String,
149 pub claim_shard_one: String,
151 pub renew_shard_leases: String,
153 pub reclaim_shards_select: String,
155 pub reclaim_shard_requeue: String,
157 pub reclaim_shard_fail: String,
159 pub finalize_shard: String,
161 pub shard_progress: String,
163 pub pending_shard_cancellations: String,
167 pub select_sharded_parents: String,
170 pub finalize_sharded_parent: String,
174 pub delete_shards_by_run: String,
177 pub purge_orphan_shards: String,
180}
181
182impl Stmts {
183 pub fn new(dialect: Dialect) -> Self {
184 match dialect {
185 Dialect::Postgres => Self::postgres(),
186 Dialect::Sqlite => Self::sqlite(),
187 }
188 }
189
190 fn postgres() -> Self {
191 Self {
192 upsert: "INSERT INTO faucet_serve_runs \
193 (run_id,name,status,submitted_at,finished_at,idempotency_key,owner,lease_expires_at,body) \
194 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) \
195 ON CONFLICT (run_id) DO UPDATE SET \
196 name=excluded.name,status=excluded.status,submitted_at=excluded.submitted_at,\
197 finished_at=excluded.finished_at,idempotency_key=excluded.idempotency_key,\
198 owner=excluded.owner,lease_expires_at=excluded.lease_expires_at,\
199 body=excluded.body"
200 .into(),
201 select_body: "SELECT body FROM faucet_serve_runs WHERE run_id=$1".into(),
202 select_status: "SELECT status FROM faucet_serve_runs WHERE run_id=$1".into(),
203 select_submitted: "SELECT submitted_at FROM faucet_serve_runs WHERE run_id=$1".into(),
204 delete: "DELETE FROM faucet_serve_runs WHERE run_id=$1".into(),
205 list: "SELECT body FROM faucet_serve_runs \
208 WHERE ($1::text IS NULL OR status = $2::text) \
209 AND ($3::text IS NULL OR name = $4::text) \
210 AND ($5::text IS NULL OR submitted_at >= $6::text) \
211 AND ($7::text IS NULL OR submitted_at <= $8::text) \
212 AND ($9::text IS NULL OR (submitted_at < $10::text \
213 OR (submitted_at = $11::text AND run_id < $12::text))) \
214 ORDER BY submitted_at DESC, run_id DESC LIMIT $13"
215 .into(),
216 purge_runs: "DELETE FROM faucet_serve_runs \
217 WHERE status IN ('completed','failed','cancelled') \
218 AND finished_at IS NOT NULL AND finished_at < $1"
219 .into(),
220 purge_idem: "DELETE FROM faucet_serve_idem WHERE claimed_at < $1".into(),
221 select_orphans: "SELECT body FROM faucet_serve_runs \
222 WHERE status IN ('queued','running') \
223 AND (lease_expires_at IS NULL OR lease_expires_at < $1)"
224 .into(),
225 renew_leases: "UPDATE faucet_serve_runs SET lease_expires_at = $1 \
226 WHERE owner = $2 AND status IN ('queued','running')"
227 .into(),
228 insert_idem: "INSERT INTO faucet_serve_idem (key,run_id,fingerprint,claimed_at) \
229 VALUES ($1,$2,$3,$4) ON CONFLICT (key) DO NOTHING"
230 .into(),
231 select_idem: "SELECT run_id,fingerprint,claimed_at FROM faucet_serve_idem WHERE key=$1"
232 .into(),
233 takeover_idem: "UPDATE faucet_serve_idem \
234 SET run_id=$1,fingerprint=$2,claimed_at=$3 WHERE key=$4 AND claimed_at=$5"
235 .into(),
236 delete_idem_by_run: "DELETE FROM faucet_serve_idem WHERE run_id=$1".into(),
237 select_pending: "SELECT run_id, body FROM faucet_serve_runs \
238 WHERE status = 'pending' ORDER BY submitted_at ASC LIMIT $1"
239 .into(),
240 claim_one: "UPDATE faucet_serve_runs \
241 SET owner = $1, status = 'running', lease_expires_at = $2, body = $3 \
242 WHERE run_id = $4 AND status = 'pending'"
243 .into(),
244 reclaim_select: "SELECT body FROM faucet_serve_runs \
245 WHERE status = 'running' \
246 AND (lease_expires_at IS NULL OR lease_expires_at < $1)"
247 .into(),
248 reclaim_requeue: "UPDATE faucet_serve_runs \
249 SET status = 'pending', owner = NULL, lease_expires_at = NULL, \
250 cancel_requested = NULL, body = $1 \
251 WHERE run_id = $2 AND status = 'running' \
252 AND (lease_expires_at IS NULL OR lease_expires_at < $3)"
253 .into(),
254 reclaim_fail: "UPDATE faucet_serve_runs \
255 SET status = 'failed', finished_at = $1, body = $2, owner = NULL \
256 WHERE run_id = $3 AND status = 'running' \
257 AND (lease_expires_at IS NULL OR lease_expires_at < $4)"
258 .into(),
259 finalize_owned: "UPDATE faucet_serve_runs \
260 SET status = $1, finished_at = $2, lease_expires_at = $3, body = $4 \
261 WHERE run_id = $5 AND owner = $6"
262 .into(),
263 cancel_pending: "UPDATE faucet_serve_runs \
264 SET status = 'cancelled', finished_at = $1, body = $2 \
265 WHERE run_id = $3 AND status = 'pending'"
266 .into(),
267 request_cancel: "UPDATE faucet_serve_runs \
268 SET cancel_requested = $1 WHERE run_id = $2 AND status IN ('running','sharded')"
269 .into(),
270 pending_cancellations: "SELECT run_id FROM faucet_serve_runs \
271 WHERE status = 'running' AND owner = $1 AND cancel_requested IS NOT NULL"
272 .into(),
273 heartbeat_instance: "INSERT INTO faucet_serve_instances \
274 (instance_id, started_at, last_heartbeat, listen, max_concurrent, in_flight) \
275 VALUES ($1,$2,$3,$4,$5,$6) \
276 ON CONFLICT (instance_id) DO UPDATE SET \
277 last_heartbeat = excluded.last_heartbeat, listen = excluded.listen, \
278 max_concurrent = excluded.max_concurrent, in_flight = excluded.in_flight"
279 .into(),
280 live_instances: "SELECT instance_id, started_at, last_heartbeat, listen, \
281 max_concurrent, in_flight FROM faucet_serve_instances \
282 WHERE last_heartbeat >= $1"
283 .into(),
284 prune_instances: "DELETE FROM faucet_serve_instances WHERE last_heartbeat < $1".into(),
285 insert_shard: "INSERT INTO faucet_serve_shards \
286 (run_id, shard_id, descriptor, size_estimate, status, attempt) \
287 VALUES ($1,$2,$3,$4,'pending','0') \
288 ON CONFLICT (run_id, shard_id) DO NOTHING"
289 .into(),
290 claim_shards_select: "SELECT s.run_id, s.shard_id, s.descriptor, r.body \
291 FROM faucet_serve_shards s JOIN faucet_serve_runs r ON r.run_id = s.run_id \
292 WHERE s.status = 'pending' \
293 ORDER BY CAST(COALESCE(s.size_estimate, '0') AS BIGINT) DESC, s.run_id, s.shard_id \
294 LIMIT $1"
295 .into(),
296 claim_shard_one: "UPDATE faucet_serve_shards \
297 SET owner = $1, status = 'running', lease_expires_at = $2 \
298 WHERE run_id = $3 AND shard_id = $4 AND status = 'pending'"
299 .into(),
300 renew_shard_leases: "UPDATE faucet_serve_shards SET lease_expires_at = $1 \
301 WHERE owner = $2 AND status = 'running'"
302 .into(),
303 reclaim_shards_select: "SELECT run_id, shard_id, attempt FROM faucet_serve_shards \
304 WHERE status = 'running' \
305 AND (lease_expires_at IS NULL OR lease_expires_at < $1)"
306 .into(),
307 reclaim_shard_requeue: "UPDATE faucet_serve_shards \
308 SET status = 'pending', owner = NULL, lease_expires_at = NULL, attempt = $1 \
309 WHERE run_id = $2 AND shard_id = $3 AND status = 'running' \
310 AND (lease_expires_at IS NULL OR lease_expires_at < $4)"
311 .into(),
312 reclaim_shard_fail: "UPDATE faucet_serve_shards \
313 SET status = 'failed', finished_at = $1, owner = NULL \
314 WHERE run_id = $2 AND shard_id = $3 AND status = 'running' \
315 AND (lease_expires_at IS NULL OR lease_expires_at < $4)"
316 .into(),
317 finalize_shard: "UPDATE faucet_serve_shards \
318 SET status = $1, finished_at = $2 \
319 WHERE run_id = $3 AND shard_id = $4 AND owner = $5 AND status = 'running'"
320 .into(),
321 shard_progress: "SELECT status, COUNT(*) AS n FROM faucet_serve_shards \
322 WHERE run_id = $1 GROUP BY status"
323 .into(),
324 pending_shard_cancellations: "SELECT DISTINCT s.run_id \
325 FROM faucet_serve_shards s \
326 JOIN faucet_serve_runs r ON r.run_id = s.run_id \
327 WHERE s.owner = $1 AND s.status = 'running' \
328 AND r.cancel_requested IS NOT NULL"
329 .into(),
330 select_sharded_parents: "SELECT run_id FROM faucet_serve_runs \
331 WHERE status = 'sharded'"
332 .into(),
333 finalize_sharded_parent: "UPDATE faucet_serve_runs \
334 SET status = $1, finished_at = $2, body = $3 \
335 WHERE run_id = $4 AND status = 'sharded'"
336 .into(),
337 delete_shards_by_run: "DELETE FROM faucet_serve_shards WHERE run_id = $1".into(),
338 purge_orphan_shards: "DELETE FROM faucet_serve_shards \
339 WHERE run_id NOT IN (SELECT run_id FROM faucet_serve_runs)"
340 .into(),
341 }
342 }
343
344 fn sqlite() -> Self {
345 Self {
346 upsert: "INSERT INTO faucet_serve_runs \
347 (run_id,name,status,submitted_at,finished_at,idempotency_key,owner,lease_expires_at,body) \
348 VALUES (?,?,?,?,?,?,?,?,?) \
349 ON CONFLICT (run_id) DO UPDATE SET \
350 name=excluded.name,status=excluded.status,submitted_at=excluded.submitted_at,\
351 finished_at=excluded.finished_at,idempotency_key=excluded.idempotency_key,\
352 owner=excluded.owner,lease_expires_at=excluded.lease_expires_at,\
353 body=excluded.body"
354 .into(),
355 select_body: "SELECT body FROM faucet_serve_runs WHERE run_id=?".into(),
356 select_status: "SELECT status FROM faucet_serve_runs WHERE run_id=?".into(),
357 select_submitted: "SELECT submitted_at FROM faucet_serve_runs WHERE run_id=?".into(),
358 delete: "DELETE FROM faucet_serve_runs WHERE run_id=?".into(),
359 list: "SELECT body FROM faucet_serve_runs \
360 WHERE (? IS NULL OR status = ?) \
361 AND (? IS NULL OR name = ?) \
362 AND (? IS NULL OR submitted_at >= ?) \
363 AND (? IS NULL OR submitted_at <= ?) \
364 AND (? IS NULL OR (submitted_at < ? \
365 OR (submitted_at = ? AND run_id < ?))) \
366 ORDER BY submitted_at DESC, run_id DESC LIMIT ?"
367 .into(),
368 purge_runs: "DELETE FROM faucet_serve_runs \
369 WHERE status IN ('completed','failed','cancelled') \
370 AND finished_at IS NOT NULL AND finished_at < ?"
371 .into(),
372 purge_idem: "DELETE FROM faucet_serve_idem WHERE claimed_at < ?".into(),
373 select_orphans: "SELECT body FROM faucet_serve_runs \
374 WHERE status IN ('queued','running') \
375 AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
376 .into(),
377 renew_leases: "UPDATE faucet_serve_runs SET lease_expires_at = ? \
378 WHERE owner = ? AND status IN ('queued','running')"
379 .into(),
380 insert_idem: "INSERT INTO faucet_serve_idem (key,run_id,fingerprint,claimed_at) \
381 VALUES (?,?,?,?) ON CONFLICT (key) DO NOTHING"
382 .into(),
383 select_idem: "SELECT run_id,fingerprint,claimed_at FROM faucet_serve_idem WHERE key=?"
384 .into(),
385 takeover_idem: "UPDATE faucet_serve_idem \
386 SET run_id=?,fingerprint=?,claimed_at=? WHERE key=? AND claimed_at=?"
387 .into(),
388 delete_idem_by_run: "DELETE FROM faucet_serve_idem WHERE run_id=?".into(),
389 select_pending: "SELECT run_id, body FROM faucet_serve_runs \
390 WHERE status = 'pending' ORDER BY submitted_at ASC LIMIT ?"
391 .into(),
392 claim_one: "UPDATE faucet_serve_runs \
393 SET owner = ?, status = 'running', lease_expires_at = ?, body = ? \
394 WHERE run_id = ? AND status = 'pending'"
395 .into(),
396 reclaim_select: "SELECT body FROM faucet_serve_runs \
397 WHERE status = 'running' \
398 AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
399 .into(),
400 reclaim_requeue: "UPDATE faucet_serve_runs \
401 SET status = 'pending', owner = NULL, lease_expires_at = NULL, \
402 cancel_requested = NULL, body = ? \
403 WHERE run_id = ? AND status = 'running' \
404 AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
405 .into(),
406 reclaim_fail: "UPDATE faucet_serve_runs \
407 SET status = 'failed', finished_at = ?, body = ?, owner = NULL \
408 WHERE run_id = ? AND status = 'running' \
409 AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
410 .into(),
411 finalize_owned: "UPDATE faucet_serve_runs \
412 SET status = ?, finished_at = ?, lease_expires_at = ?, body = ? \
413 WHERE run_id = ? AND owner = ?"
414 .into(),
415 cancel_pending: "UPDATE faucet_serve_runs \
416 SET status = 'cancelled', finished_at = ?, body = ? \
417 WHERE run_id = ? AND status = 'pending'"
418 .into(),
419 request_cancel: "UPDATE faucet_serve_runs \
420 SET cancel_requested = ? WHERE run_id = ? AND status IN ('running','sharded')"
421 .into(),
422 pending_cancellations: "SELECT run_id FROM faucet_serve_runs \
423 WHERE status = 'running' AND owner = ? AND cancel_requested IS NOT NULL"
424 .into(),
425 heartbeat_instance: "INSERT INTO faucet_serve_instances \
426 (instance_id, started_at, last_heartbeat, listen, max_concurrent, in_flight) \
427 VALUES (?,?,?,?,?,?) \
428 ON CONFLICT (instance_id) DO UPDATE SET \
429 last_heartbeat = excluded.last_heartbeat, listen = excluded.listen, \
430 max_concurrent = excluded.max_concurrent, in_flight = excluded.in_flight"
431 .into(),
432 live_instances: "SELECT instance_id, started_at, last_heartbeat, listen, \
433 max_concurrent, in_flight FROM faucet_serve_instances \
434 WHERE last_heartbeat >= ?"
435 .into(),
436 prune_instances: "DELETE FROM faucet_serve_instances WHERE last_heartbeat < ?".into(),
437 insert_shard: "INSERT INTO faucet_serve_shards \
438 (run_id, shard_id, descriptor, size_estimate, status, attempt) \
439 VALUES (?,?,?,?,'pending','0') \
440 ON CONFLICT (run_id, shard_id) DO NOTHING"
441 .into(),
442 claim_shards_select: "SELECT s.run_id, s.shard_id, s.descriptor, r.body \
443 FROM faucet_serve_shards s JOIN faucet_serve_runs r ON r.run_id = s.run_id \
444 WHERE s.status = 'pending' \
445 ORDER BY CAST(COALESCE(s.size_estimate, '0') AS INTEGER) DESC, s.run_id, s.shard_id \
446 LIMIT ?"
447 .into(),
448 claim_shard_one: "UPDATE faucet_serve_shards \
449 SET owner = ?, status = 'running', lease_expires_at = ? \
450 WHERE run_id = ? AND shard_id = ? AND status = 'pending'"
451 .into(),
452 renew_shard_leases: "UPDATE faucet_serve_shards SET lease_expires_at = ? \
453 WHERE owner = ? AND status = 'running'"
454 .into(),
455 reclaim_shards_select: "SELECT run_id, shard_id, attempt FROM faucet_serve_shards \
456 WHERE status = 'running' \
457 AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
458 .into(),
459 reclaim_shard_requeue: "UPDATE faucet_serve_shards \
460 SET status = 'pending', owner = NULL, lease_expires_at = NULL, attempt = ? \
461 WHERE run_id = ? AND shard_id = ? AND status = 'running' \
462 AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
463 .into(),
464 reclaim_shard_fail: "UPDATE faucet_serve_shards \
465 SET status = 'failed', finished_at = ?, owner = NULL \
466 WHERE run_id = ? AND shard_id = ? AND status = 'running' \
467 AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
468 .into(),
469 finalize_shard: "UPDATE faucet_serve_shards \
470 SET status = ?, finished_at = ? \
471 WHERE run_id = ? AND shard_id = ? AND owner = ? AND status = 'running'"
472 .into(),
473 shard_progress: "SELECT status, COUNT(*) AS n FROM faucet_serve_shards \
474 WHERE run_id = ? GROUP BY status"
475 .into(),
476 pending_shard_cancellations: "SELECT DISTINCT s.run_id \
477 FROM faucet_serve_shards s \
478 JOIN faucet_serve_runs r ON r.run_id = s.run_id \
479 WHERE s.owner = ? AND s.status = 'running' \
480 AND r.cancel_requested IS NOT NULL"
481 .into(),
482 select_sharded_parents: "SELECT run_id FROM faucet_serve_runs \
483 WHERE status = 'sharded'"
484 .into(),
485 finalize_sharded_parent: "UPDATE faucet_serve_runs \
486 SET status = ?, finished_at = ?, body = ? \
487 WHERE run_id = ? AND status = 'sharded'"
488 .into(),
489 delete_shards_by_run: "DELETE FROM faucet_serve_shards WHERE run_id = ?".into(),
490 purge_orphan_shards: "DELETE FROM faucet_serve_shards \
491 WHERE run_id NOT IN (SELECT run_id FROM faucet_serve_runs)"
492 .into(),
493 }
494 }
495}
496
497pub const CLAIM_ATTEMPTS: usize = 4;
500
501pub fn fmt_ts(dt: DateTime<Utc>) -> String {
503 dt.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true)
504}
505
506pub fn is_expired(claimed_at: &str, now: DateTime<Utc>, window: Duration) -> bool {
510 match DateTime::parse_from_rfc3339(claimed_at) {
511 Ok(t) => now
512 .signed_duration_since(t.with_timezone(&Utc))
513 .to_std()
514 .map(|age| age >= window)
515 .unwrap_or(false),
516 Err(_) => false,
517 }
518}
519
520pub fn threshold(now: DateTime<Utc>, window: Duration) -> String {
522 let delta =
523 chrono::Duration::from_std(window).unwrap_or_else(|_| chrono::Duration::days(36_500));
524 fmt_ts(now - delta)
525}
526
527pub fn encode_body(rec: &RunRecord) -> Result<String, HistoryError> {
528 serde_json::to_string(rec).map_err(|e| HistoryError::Backend(format!("encode run record: {e}")))
529}
530
531pub fn decode_body(body: &str) -> Result<RunRecord, HistoryError> {
532 serde_json::from_str(body).map_err(|e| HistoryError::Backend(format!("decode run record: {e}")))
533}
534
535pub fn parse_status(s: &str) -> RunStatus {
536 match s {
537 "queued" => RunStatus::Queued,
538 "pending" => RunStatus::Pending,
539 "running" => RunStatus::Running,
540 "sharded" => RunStatus::Sharded,
541 "completed" => RunStatus::Completed,
542 "cancelled" => RunStatus::Cancelled,
543 _ => RunStatus::Failed,
544 }
545}
546
547macro_rules! impl_sql_history {
551 ($name:ident, $pool:ty) => {
552 pub struct $name {
555 pool: $pool,
556 idem_retention: std::time::Duration,
557 instance_id: String,
559 lease_ttl: std::time::Duration,
561 stmts: $crate::serve::history::sql::Stmts,
562 }
563
564 impl $name {
565 pub fn from_parts(
567 pool: $pool,
568 idem_retention: std::time::Duration,
569 lease_ttl: std::time::Duration,
570 instance_id: String,
571 stmts: $crate::serve::history::sql::Stmts,
572 ) -> Self {
573 Self {
574 pool,
575 idem_retention,
576 instance_id,
577 lease_ttl,
578 stmts,
579 }
580 }
581
582 pub fn pool(&self) -> &$pool {
584 &self.pool
585 }
586 }
587
588 #[async_trait::async_trait]
589 impl $crate::serve::history::RunHistory for $name {
590 async fn claim_idempotency(
591 &self,
592 key: &str,
593 fingerprint: &str,
594 run_id: &str,
595 window: std::time::Duration,
596 ) -> Result<$crate::serve::history::Claim, $crate::serve::history::HistoryError> {
597 use sqlx::Row as _;
598 use $crate::serve::history::Claim;
599 use $crate::serve::history::HistoryError;
600 use $crate::serve::history::sql;
601
602 let now = chrono::Utc::now();
603 let now_s = sql::fmt_ts(now);
604 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
605
606 for _ in 0..sql::CLAIM_ATTEMPTS {
607 let inserted = sqlx::query(&self.stmts.insert_idem)
609 .bind(key)
610 .bind(run_id)
611 .bind(fingerprint)
612 .bind(&now_s)
613 .execute(&self.pool)
614 .await
615 .map_err(backend)?
616 .rows_affected();
617 if inserted == 1 {
618 return Ok(Claim::Fresh);
619 }
620 let Some(row) = sqlx::query(&self.stmts.select_idem)
622 .bind(key)
623 .fetch_optional(&self.pool)
624 .await
625 .map_err(backend)?
626 else {
627 continue;
629 };
630 let existing_run: String = row.try_get("run_id").map_err(backend)?;
631 let existing_fp: String = row.try_get("fingerprint").map_err(backend)?;
632 let claimed_at: String = row.try_get("claimed_at").map_err(backend)?;
633
634 if sql::is_expired(&claimed_at, now, window) {
635 let took = sqlx::query(&self.stmts.takeover_idem)
638 .bind(run_id)
639 .bind(fingerprint)
640 .bind(&now_s)
641 .bind(key)
642 .bind(&claimed_at)
643 .execute(&self.pool)
644 .await
645 .map_err(backend)?
646 .rows_affected();
647 if took == 1 {
648 return Ok(Claim::Fresh);
649 }
650 continue; }
652 return Ok(if existing_fp == fingerprint {
653 Claim::Replay(existing_run)
654 } else {
655 Claim::Conflict
656 });
657 }
658 tracing::warn!(
661 key,
662 "idempotency claim exhausted retries; reporting conflict"
663 );
664 Ok(Claim::Conflict)
665 }
666
667 async fn upsert(
668 &self,
669 rec: &$crate::serve::history::RunRecord,
670 ) -> Result<(), $crate::serve::history::HistoryError> {
671 use $crate::serve::history::HistoryError;
672 use $crate::serve::history::sql;
673 let body = sql::encode_body(rec)?;
674 let submitted = sql::fmt_ts(rec.submitted_at);
675 let finished = rec.finished_at.map(sql::fmt_ts);
676 let lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
681 sqlx::query(&self.stmts.upsert)
682 .bind(&rec.run_id)
683 .bind(rec.name.as_deref())
684 .bind(rec.status.as_str())
685 .bind(&submitted)
686 .bind(finished.as_deref())
687 .bind(rec.idempotency_key.as_deref())
688 .bind(&self.instance_id)
689 .bind(&lease)
690 .bind(&body)
691 .execute(&self.pool)
692 .await
693 .map_err(|e| HistoryError::Backend(e.to_string()))?;
694 Ok(())
695 }
696
697 async fn get(
698 &self,
699 id: &str,
700 ) -> Result<
701 Option<$crate::serve::history::RunRecord>,
702 $crate::serve::history::HistoryError,
703 > {
704 use sqlx::Row as _;
705 use $crate::serve::history::HistoryError;
706 use $crate::serve::history::sql;
707 let row = sqlx::query(&self.stmts.select_body)
708 .bind(id)
709 .fetch_optional(&self.pool)
710 .await
711 .map_err(|e| HistoryError::Backend(e.to_string()))?;
712 match row {
713 None => Ok(None),
714 Some(r) => {
715 let body: String = r
716 .try_get("body")
717 .map_err(|e| HistoryError::Backend(e.to_string()))?;
718 Ok(Some(sql::decode_body(&body)?))
719 }
720 }
721 }
722
723 async fn list(
724 &self,
725 filter: &$crate::serve::history::ListFilter,
726 ) -> Result<$crate::serve::history::ListPage, $crate::serve::history::HistoryError>
727 {
728 use sqlx::Row as _;
729 use $crate::serve::history::HistoryError;
730 use $crate::serve::history::ListPage;
731 use $crate::serve::history::sql;
732 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
733
734 let cursor_ts: Option<String> = match &filter.cursor {
738 None => None,
739 Some(c) => sqlx::query(&self.stmts.select_submitted)
740 .bind(c)
741 .fetch_optional(&self.pool)
742 .await
743 .map_err(backend)?
744 .map(|r| r.try_get::<String, _>("submitted_at"))
745 .transpose()
746 .map_err(backend)?,
747 };
748 let cur_id = if cursor_ts.is_some() {
749 filter.cursor.as_deref()
750 } else {
751 None
752 };
753
754 let status_s = filter.status.map(|s| s.as_str());
755 let name_s = filter.name.as_deref();
756 let since_s = filter.since.map(sql::fmt_ts);
757 let until_s = filter.until.map(sql::fmt_ts);
758 let limit = filter.limit.max(1);
759 let fetch_n = limit as i64 + 1; let rows = sqlx::query(&self.stmts.list)
762 .bind(status_s)
763 .bind(status_s)
764 .bind(name_s)
765 .bind(name_s)
766 .bind(since_s.as_deref())
767 .bind(since_s.as_deref())
768 .bind(until_s.as_deref())
769 .bind(until_s.as_deref())
770 .bind(cursor_ts.as_deref())
771 .bind(cursor_ts.as_deref())
772 .bind(cursor_ts.as_deref())
773 .bind(cur_id)
774 .bind(fetch_n)
775 .fetch_all(&self.pool)
776 .await
777 .map_err(backend)?;
778
779 let mut runs = Vec::with_capacity(rows.len());
780 for r in &rows {
781 let body: String = r.try_get("body").map_err(backend)?;
782 runs.push(sql::decode_body(&body)?);
783 }
784 let next_cursor = if runs.len() > limit {
785 Some(runs[limit - 1].run_id.clone())
786 } else {
787 None
788 };
789 runs.truncate(limit);
790 Ok(ListPage { runs, next_cursor })
791 }
792
793 async fn delete(
794 &self,
795 id: &str,
796 ) -> Result<$crate::serve::history::DeleteOutcome, $crate::serve::history::HistoryError>
797 {
798 use sqlx::Row as _;
799 use $crate::serve::history::DeleteOutcome;
800 use $crate::serve::history::HistoryError;
801 use $crate::serve::history::sql;
802 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
803 let status: Option<String> = sqlx::query(&self.stmts.select_status)
804 .bind(id)
805 .fetch_optional(&self.pool)
806 .await
807 .map_err(backend)?
808 .map(|r| r.try_get::<String, _>("status"))
809 .transpose()
810 .map_err(backend)?;
811 match status {
812 None => Ok(DeleteOutcome::NotFound),
813 Some(s) if !sql::parse_status(&s).is_terminal() => {
814 Ok(DeleteOutcome::StillRunning)
815 }
816 Some(_) => {
817 sqlx::query(&self.stmts.delete)
818 .bind(id)
819 .execute(&self.pool)
820 .await
821 .map_err(backend)?;
822 sqlx::query(&self.stmts.delete_idem_by_run)
828 .bind(id)
829 .execute(&self.pool)
830 .await
831 .map_err(backend)?;
832 sqlx::query(&self.stmts.delete_shards_by_run)
836 .bind(id)
837 .execute(&self.pool)
838 .await
839 .map_err(backend)?;
840 Ok(DeleteOutcome::Deleted)
841 }
842 }
843 }
844
845 async fn release_idempotency(
846 &self,
847 run_id: &str,
848 ) -> Result<(), $crate::serve::history::HistoryError> {
849 use $crate::serve::history::HistoryError;
850 sqlx::query(&self.stmts.delete_idem_by_run)
851 .bind(run_id)
852 .execute(&self.pool)
853 .await
854 .map_err(|e| HistoryError::Backend(e.to_string()))?;
855 Ok(())
856 }
857
858 async fn purge_expired(
859 &self,
860 retain_for: std::time::Duration,
861 ) -> Result<usize, $crate::serve::history::HistoryError> {
862 use $crate::serve::history::HistoryError;
863 use $crate::serve::history::sql;
864 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
865 let now = chrono::Utc::now();
866 let removed = sqlx::query(&self.stmts.purge_runs)
867 .bind(sql::threshold(now, retain_for))
868 .execute(&self.pool)
869 .await
870 .map_err(backend)?
871 .rows_affected() as usize;
872 let _ = sqlx::query(&self.stmts.purge_idem)
874 .bind(sql::threshold(now, self.idem_retention))
875 .execute(&self.pool)
876 .await;
877 let _ = sqlx::query(&self.stmts.prune_instances)
881 .bind(sql::threshold(now, retain_for))
882 .execute(&self.pool)
883 .await;
884 let _ = sqlx::query(&self.stmts.purge_orphan_shards)
888 .execute(&self.pool)
889 .await;
890 Ok(removed)
891 }
892
893 async fn recover_orphans(&self) -> Result<usize, $crate::serve::history::HistoryError> {
894 use sqlx::Row as _;
895 use $crate::serve::history::HistoryError;
896 use $crate::serve::history::RunStatus;
897 use $crate::serve::history::sql;
898 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
899 let now = chrono::Utc::now();
900 let rows = sqlx::query(&self.stmts.select_orphans)
905 .bind(sql::fmt_ts(now))
906 .fetch_all(&self.pool)
907 .await
908 .map_err(backend)?;
909 let mut count = 0usize;
910 for r in &rows {
911 let body: String = r.try_get("body").map_err(backend)?;
912 let mut rec = sql::decode_body(&body)?;
913 rec.status = RunStatus::Failed;
914 rec.finished_at = Some(now);
915 rec.error = Some(
916 "owning serve instance's lease expired before the run finished".into(),
917 );
918 if rec.elapsed_secs.is_none()
919 && let Some(started) = rec.started_at
920 {
921 rec.elapsed_secs = (now - started).to_std().ok().map(|d| d.as_secs_f64());
922 }
923 self.upsert(&rec).await?;
924 count += 1;
925 }
926 Ok(count)
927 }
928
929 async fn renew_leases(&self) -> Result<usize, $crate::serve::history::HistoryError> {
930 use $crate::serve::history::HistoryError;
931 use $crate::serve::history::sql;
932 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
933 let new_lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
934 let renewed = sqlx::query(&self.stmts.renew_leases)
935 .bind(&new_lease)
936 .bind(&self.instance_id)
937 .execute(&self.pool)
938 .await
939 .map_err(backend)?
940 .rows_affected() as usize;
941 Ok(renewed)
942 }
943
944 async fn claim_pending(
945 &self,
946 limit: usize,
947 ) -> Result<Vec<$crate::serve::history::RunRecord>, $crate::serve::history::HistoryError>
948 {
949 use sqlx::Row as _;
950 use $crate::serve::history::HistoryError;
951 use $crate::serve::history::RunStatus;
952 use $crate::serve::history::sql;
953 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
954 if limit == 0 {
955 return Ok(Vec::new());
956 }
957 let now = chrono::Utc::now();
958 let lease = sql::fmt_ts(now + self.lease_ttl);
959
960 let rows = sqlx::query(&self.stmts.select_pending)
962 .bind(limit as i64)
963 .fetch_all(&self.pool)
964 .await
965 .map_err(backend)?;
966
967 let mut claimed = Vec::new();
972 for row in &rows {
973 let run_id: String = row.try_get("run_id").map_err(backend)?;
974 let body: String = row.try_get("body").map_err(backend)?;
975 let mut r = sql::decode_body(&body)?;
979 r.status = RunStatus::Running;
980 let new_body = sql::encode_body(&r)?;
981 let won = sqlx::query(&self.stmts.claim_one)
983 .bind(&self.instance_id)
984 .bind(&lease)
985 .bind(&new_body)
986 .bind(&run_id)
987 .execute(&self.pool)
988 .await
989 .map_err(backend)?
990 .rows_affected();
991 if won == 1 {
992 claimed.push(r);
993 }
994 }
995 Ok(claimed)
996 }
997
998 async fn reclaim_orphans(
999 &self,
1000 max_attempts: u32,
1001 ) -> Result<$crate::serve::history::ReclaimReport, $crate::serve::history::HistoryError>
1002 {
1003 use sqlx::Row as _;
1004 use $crate::serve::history::HistoryError;
1005 use $crate::serve::history::ReclaimReport;
1006 use $crate::serve::history::RunStatus;
1007 use $crate::serve::history::sql;
1008 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1009 let now = chrono::Utc::now();
1010 let now_s = sql::fmt_ts(now);
1011
1012 let rows = sqlx::query(&self.stmts.reclaim_select)
1013 .bind(&now_s)
1014 .fetch_all(&self.pool)
1015 .await
1016 .map_err(backend)?;
1017
1018 let mut report = ReclaimReport::default();
1019 for row in &rows {
1020 let body: String = row.try_get("body").map_err(backend)?;
1021 let mut rec = sql::decode_body(&body)?;
1022 let next_attempt = rec.attempt + 1;
1023 if rec.attempt < max_attempts {
1027 rec.attempt = next_attempt;
1029 rec.status = RunStatus::Pending;
1030 let new_body = sql::encode_body(&rec)?;
1031 let n = sqlx::query(&self.stmts.reclaim_requeue)
1032 .bind(&new_body)
1033 .bind(&rec.run_id)
1034 .bind(&now_s)
1035 .execute(&self.pool)
1036 .await
1037 .map_err(backend)?
1038 .rows_affected();
1039 if n == 1 {
1040 report.requeued += 1;
1041 }
1042 } else {
1043 rec.attempt = next_attempt;
1045 rec.status = RunStatus::Failed;
1046 rec.finished_at = Some(now);
1047 rec.error = Some(format!(
1048 "run reclaimed {next_attempt} times after its owning instance's \
1049 lease expired; giving up (poison run)"
1050 ));
1051 if rec.elapsed_secs.is_none()
1052 && let Some(started) = rec.started_at
1053 {
1054 rec.elapsed_secs =
1055 (now - started).to_std().ok().map(|d| d.as_secs_f64());
1056 }
1057 let new_body = sql::encode_body(&rec)?;
1058 let n = sqlx::query(&self.stmts.reclaim_fail)
1059 .bind(&now_s)
1060 .bind(&new_body)
1061 .bind(&rec.run_id)
1062 .bind(&now_s)
1063 .execute(&self.pool)
1064 .await
1065 .map_err(backend)?
1066 .rows_affected();
1067 if n == 1 {
1068 report.failed += 1;
1069 }
1070 }
1071 }
1072 Ok(report)
1073 }
1074
1075 async fn finalize_owned(
1076 &self,
1077 rec: &$crate::serve::history::RunRecord,
1078 ) -> Result<bool, $crate::serve::history::HistoryError> {
1079 use $crate::serve::history::HistoryError;
1080 use $crate::serve::history::sql;
1081 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1082 let mut rec = rec.clone();
1086 if rec.status.is_terminal() && rec.finished_at.is_none() {
1087 rec.finished_at = Some(chrono::Utc::now());
1088 }
1089 let body = sql::encode_body(&rec)?;
1090 let finished = rec.finished_at.map(sql::fmt_ts);
1091 let lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
1092 let n = sqlx::query(&self.stmts.finalize_owned)
1093 .bind(rec.status.as_str())
1094 .bind(finished.as_deref())
1095 .bind(&lease)
1096 .bind(&body)
1097 .bind(&rec.run_id)
1098 .bind(&self.instance_id)
1099 .execute(&self.pool)
1100 .await
1101 .map_err(backend)?
1102 .rows_affected();
1103 Ok(n == 1)
1104 }
1105
1106 async fn finalize_sharded_parent(
1107 &self,
1108 run_id: &str,
1109 status: $crate::serve::history::RunStatus,
1110 finished_at: chrono::DateTime<chrono::Utc>,
1111 error: Option<String>,
1112 ) -> Result<bool, $crate::serve::history::HistoryError> {
1113 use sqlx::Row as _;
1114 use $crate::serve::history::HistoryError;
1115 use $crate::serve::history::RunStatus;
1116 use $crate::serve::history::sql;
1117 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1118 let Some(row) = sqlx::query(&self.stmts.select_body)
1123 .bind(run_id)
1124 .fetch_optional(&self.pool)
1125 .await
1126 .map_err(backend)?
1127 else {
1128 return Ok(false);
1129 };
1130 let body: String = row.try_get("body").map_err(backend)?;
1131 let mut rec = sql::decode_body(&body)?;
1132 if rec.status != RunStatus::Sharded {
1133 return Ok(false);
1134 }
1135 rec.status = status;
1136 rec.finished_at = Some(finished_at);
1137 rec.error = error;
1138 let new_body = sql::encode_body(&rec)?;
1139 let n = sqlx::query(&self.stmts.finalize_sharded_parent)
1140 .bind(status.as_str())
1141 .bind(sql::fmt_ts(finished_at))
1142 .bind(&new_body)
1143 .bind(run_id)
1144 .execute(&self.pool)
1145 .await
1146 .map_err(backend)?
1147 .rows_affected();
1148 Ok(n == 1)
1149 }
1150
1151 async fn cancel_pending(
1152 &self,
1153 run_id: &str,
1154 ) -> Result<bool, $crate::serve::history::HistoryError> {
1155 use sqlx::Row as _;
1156 use $crate::serve::history::HistoryError;
1157 use $crate::serve::history::RunStatus;
1158 use $crate::serve::history::sql;
1159 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1160 let Some(row) = sqlx::query(&self.stmts.select_body)
1163 .bind(run_id)
1164 .fetch_optional(&self.pool)
1165 .await
1166 .map_err(backend)?
1167 else {
1168 return Ok(false);
1169 };
1170 let body: String = row.try_get("body").map_err(backend)?;
1171 let mut rec = sql::decode_body(&body)?;
1172 if rec.status != RunStatus::Pending {
1173 return Ok(false);
1174 }
1175 let now = chrono::Utc::now();
1176 rec.status = RunStatus::Cancelled;
1177 rec.finished_at = Some(now);
1178 let new_body = sql::encode_body(&rec)?;
1179 let n = sqlx::query(&self.stmts.cancel_pending)
1180 .bind(sql::fmt_ts(now))
1181 .bind(&new_body)
1182 .bind(run_id)
1183 .execute(&self.pool)
1184 .await
1185 .map_err(backend)?
1186 .rows_affected();
1187 Ok(n == 1)
1188 }
1189
1190 async fn request_cancel(
1191 &self,
1192 run_id: &str,
1193 ) -> Result<(), $crate::serve::history::HistoryError> {
1194 use $crate::serve::history::HistoryError;
1195 use $crate::serve::history::sql;
1196 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1197 sqlx::query(&self.stmts.request_cancel)
1198 .bind(sql::fmt_ts(chrono::Utc::now()))
1199 .bind(run_id)
1200 .execute(&self.pool)
1201 .await
1202 .map_err(backend)?;
1203 Ok(())
1204 }
1205
1206 async fn pending_cancellations(
1207 &self,
1208 ) -> Result<Vec<String>, $crate::serve::history::HistoryError> {
1209 use sqlx::Row as _;
1210 use $crate::serve::history::HistoryError;
1211 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1212 let rows = sqlx::query(&self.stmts.pending_cancellations)
1213 .bind(&self.instance_id)
1214 .fetch_all(&self.pool)
1215 .await
1216 .map_err(backend)?;
1217 let mut ids = Vec::with_capacity(rows.len());
1218 for r in &rows {
1219 ids.push(r.try_get::<String, _>("run_id").map_err(backend)?);
1220 }
1221 Ok(ids)
1222 }
1223
1224 async fn heartbeat_instance(
1225 &self,
1226 beat: &$crate::serve::history::InstanceHeartbeat,
1227 ) -> Result<(), $crate::serve::history::HistoryError> {
1228 use $crate::serve::history::HistoryError;
1229 use $crate::serve::history::sql;
1230 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1231 let now = sql::fmt_ts(chrono::Utc::now());
1232 sqlx::query(&self.stmts.heartbeat_instance)
1233 .bind(&self.instance_id)
1234 .bind(sql::fmt_ts(beat.started_at))
1235 .bind(&now)
1236 .bind(beat.listen.as_deref())
1237 .bind(beat.max_concurrent.to_string())
1238 .bind(beat.in_flight.to_string())
1239 .execute(&self.pool)
1240 .await
1241 .map_err(backend)?;
1242 Ok(())
1243 }
1244
1245 async fn live_instances(
1246 &self,
1247 ttl: std::time::Duration,
1248 ) -> Result<Vec<$crate::serve::history::InstanceRecord>, $crate::serve::history::HistoryError>
1249 {
1250 use sqlx::Row as _;
1251 use $crate::serve::history::HistoryError;
1252 use $crate::serve::history::InstanceRecord;
1253 use $crate::serve::history::sql;
1254 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1255 let now = chrono::Utc::now();
1256 let rows = sqlx::query(&self.stmts.live_instances)
1257 .bind(sql::threshold(now, ttl))
1258 .fetch_all(&self.pool)
1259 .await
1260 .map_err(backend)?;
1261 let parse_dt = |s: &str| {
1262 chrono::DateTime::parse_from_rfc3339(s)
1263 .map(|d| d.to_utc())
1264 .unwrap_or(now)
1265 };
1266 let mut out = Vec::with_capacity(rows.len());
1267 for r in &rows {
1268 let started: String = r.try_get("started_at").map_err(backend)?;
1269 let hb: String = r.try_get("last_heartbeat").map_err(backend)?;
1270 let mc: Option<String> = r.try_get("max_concurrent").map_err(backend)?;
1271 let inf: Option<String> = r.try_get("in_flight").map_err(backend)?;
1272 out.push(InstanceRecord {
1273 instance_id: r.try_get("instance_id").map_err(backend)?,
1274 started_at: parse_dt(&started),
1275 last_heartbeat: parse_dt(&hb),
1276 listen: r.try_get("listen").map_err(backend)?,
1277 max_concurrent: mc.and_then(|s| s.parse().ok()).unwrap_or(0),
1278 in_flight: inf.and_then(|s| s.parse().ok()).unwrap_or(0),
1279 });
1280 }
1281 Ok(out)
1282 }
1283
1284 async fn insert_shards(
1287 &self,
1288 run_id: &str,
1289 shards: &[$crate::serve::history::ShardInsert],
1290 ) -> Result<usize, $crate::serve::history::HistoryError> {
1291 use $crate::serve::history::HistoryError;
1292 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1293 let mut inserted = 0usize;
1294 for s in shards {
1295 let descriptor = serde_json::to_string(&s.descriptor).map_err(|e| {
1296 HistoryError::Backend(format!("encode shard descriptor: {e}"))
1297 })?;
1298 let size = s.size_estimate.map(|n| n.to_string());
1299 let n = sqlx::query(&self.stmts.insert_shard)
1300 .bind(run_id)
1301 .bind(&s.shard_id)
1302 .bind(&descriptor)
1303 .bind(size.as_deref())
1304 .execute(&self.pool)
1305 .await
1306 .map_err(backend)?
1307 .rows_affected();
1308 inserted += n as usize;
1309 }
1310 Ok(inserted)
1311 }
1312
1313 async fn claim_shards(
1314 &self,
1315 limit: usize,
1316 ) -> Result<
1317 Vec<$crate::serve::history::ClaimedShard>,
1318 $crate::serve::history::HistoryError,
1319 > {
1320 use sqlx::Row as _;
1321 use $crate::serve::history::ClaimedShard;
1322 use $crate::serve::history::HistoryError;
1323 use $crate::serve::history::sql;
1324 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1325 if limit == 0 {
1326 return Ok(Vec::new());
1327 }
1328 let lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
1329
1330 let rows = sqlx::query(&self.stmts.claim_shards_select)
1333 .bind(limit as i64)
1334 .fetch_all(&self.pool)
1335 .await
1336 .map_err(backend)?;
1337
1338 let mut claimed = Vec::new();
1340 for row in &rows {
1341 let run_id: String = row.try_get("run_id").map_err(backend)?;
1342 let shard_id: String = row.try_get("shard_id").map_err(backend)?;
1343 let descriptor_s: String = row.try_get("descriptor").map_err(backend)?;
1344 let body: String = row.try_get("body").map_err(backend)?;
1345 let won = sqlx::query(&self.stmts.claim_shard_one)
1346 .bind(&self.instance_id)
1347 .bind(&lease)
1348 .bind(&run_id)
1349 .bind(&shard_id)
1350 .execute(&self.pool)
1351 .await
1352 .map_err(backend)?
1353 .rows_affected();
1354 if won == 1 {
1355 let descriptor: serde_json::Value = serde_json::from_str(&descriptor_s)
1356 .map_err(|e| {
1357 HistoryError::Backend(format!("decode shard descriptor: {e}"))
1358 })?;
1359 let run = sql::decode_body(&body)?;
1360 claimed.push(ClaimedShard {
1361 run_id,
1362 shard_id,
1363 descriptor,
1364 run,
1365 });
1366 }
1367 }
1368 Ok(claimed)
1369 }
1370
1371 async fn renew_shard_leases(
1372 &self,
1373 ) -> Result<usize, $crate::serve::history::HistoryError> {
1374 use $crate::serve::history::HistoryError;
1375 use $crate::serve::history::sql;
1376 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1377 let lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
1378 let n = sqlx::query(&self.stmts.renew_shard_leases)
1379 .bind(&lease)
1380 .bind(&self.instance_id)
1381 .execute(&self.pool)
1382 .await
1383 .map_err(backend)?
1384 .rows_affected() as usize;
1385 Ok(n)
1386 }
1387
1388 async fn reclaim_shards(
1389 &self,
1390 max_attempts: u32,
1391 ) -> Result<$crate::serve::history::ReclaimReport, $crate::serve::history::HistoryError>
1392 {
1393 use sqlx::Row as _;
1394 use $crate::serve::history::HistoryError;
1395 use $crate::serve::history::ReclaimReport;
1396 use $crate::serve::history::sql;
1397 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1398 let now_s = sql::fmt_ts(chrono::Utc::now());
1399
1400 let rows = sqlx::query(&self.stmts.reclaim_shards_select)
1401 .bind(&now_s)
1402 .fetch_all(&self.pool)
1403 .await
1404 .map_err(backend)?;
1405
1406 let mut report = ReclaimReport::default();
1407 for row in &rows {
1408 let run_id: String = row.try_get("run_id").map_err(backend)?;
1409 let shard_id: String = row.try_get("shard_id").map_err(backend)?;
1410 let attempt_s: String = row.try_get("attempt").map_err(backend)?;
1411 let attempt: u32 = attempt_s.parse().unwrap_or(0);
1412 if attempt < max_attempts {
1413 let next = (attempt + 1).to_string();
1414 let n = sqlx::query(&self.stmts.reclaim_shard_requeue)
1415 .bind(&next)
1416 .bind(&run_id)
1417 .bind(&shard_id)
1418 .bind(&now_s)
1419 .execute(&self.pool)
1420 .await
1421 .map_err(backend)?
1422 .rows_affected();
1423 if n == 1 {
1424 report.requeued += 1;
1425 }
1426 } else {
1427 let n = sqlx::query(&self.stmts.reclaim_shard_fail)
1428 .bind(&now_s)
1429 .bind(&run_id)
1430 .bind(&shard_id)
1431 .bind(&now_s)
1432 .execute(&self.pool)
1433 .await
1434 .map_err(backend)?
1435 .rows_affected();
1436 if n == 1 {
1437 report.failed += 1;
1438 }
1439 }
1440 }
1441 Ok(report)
1442 }
1443
1444 async fn finalize_shard(
1445 &self,
1446 run_id: &str,
1447 shard_id: &str,
1448 success: bool,
1449 ) -> Result<bool, $crate::serve::history::HistoryError> {
1450 use $crate::serve::history::HistoryError;
1451 use $crate::serve::history::sql;
1452 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1453 let status = if success { "completed" } else { "failed" };
1454 let now_s = sql::fmt_ts(chrono::Utc::now());
1455 let n = sqlx::query(&self.stmts.finalize_shard)
1456 .bind(status)
1457 .bind(&now_s)
1458 .bind(run_id)
1459 .bind(shard_id)
1460 .bind(&self.instance_id)
1461 .execute(&self.pool)
1462 .await
1463 .map_err(backend)?
1464 .rows_affected();
1465 Ok(n == 1)
1466 }
1467
1468 async fn shard_progress(
1469 &self,
1470 run_id: &str,
1471 ) -> Result<$crate::serve::history::ShardProgress, $crate::serve::history::HistoryError>
1472 {
1473 use sqlx::Row as _;
1474 use $crate::serve::history::HistoryError;
1475 use $crate::serve::history::ShardProgress;
1476 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1477 let rows = sqlx::query(&self.stmts.shard_progress)
1478 .bind(run_id)
1479 .fetch_all(&self.pool)
1480 .await
1481 .map_err(backend)?;
1482 let mut p = ShardProgress::default();
1483 for row in &rows {
1484 let status: String = row.try_get("status").map_err(backend)?;
1485 let n: i64 = row.try_get("n").map_err(backend)?;
1486 let n = n.max(0) as usize;
1487 p.total += n;
1488 match status.as_str() {
1489 "completed" => p.completed += n,
1490 "failed" => p.failed += n,
1491 "running" => p.running += n,
1492 _ => p.pending += n,
1493 }
1494 }
1495 Ok(p)
1496 }
1497
1498 async fn pending_shard_cancellations(
1499 &self,
1500 ) -> Result<Vec<String>, $crate::serve::history::HistoryError> {
1501 use sqlx::Row as _;
1502 use $crate::serve::history::HistoryError;
1503 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1504 let rows = sqlx::query(&self.stmts.pending_shard_cancellations)
1505 .bind(&self.instance_id)
1506 .fetch_all(&self.pool)
1507 .await
1508 .map_err(backend)?;
1509 let mut ids = Vec::with_capacity(rows.len());
1510 for r in &rows {
1511 ids.push(r.try_get::<String, _>("run_id").map_err(backend)?);
1512 }
1513 Ok(ids)
1514 }
1515
1516 async fn finalize_completed_sharded_parents(
1517 &self,
1518 ) -> Result<usize, $crate::serve::history::HistoryError> {
1519 use sqlx::Row as _;
1520 use $crate::serve::history::HistoryError;
1521 use $crate::serve::history::RunStatus;
1522 use $crate::serve::history::sql;
1523 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1524
1525 let rows = sqlx::query(&self.stmts.select_sharded_parents)
1529 .fetch_all(&self.pool)
1530 .await
1531 .map_err(backend)?;
1532
1533 let mut finalized = 0usize;
1534 for row in &rows {
1535 let run_id: String = row.try_get("run_id").map_err(backend)?;
1536 let progress = self.shard_progress(&run_id).await?;
1537 if !progress.all_terminal() {
1538 continue;
1539 }
1540 let success = progress.failed == 0;
1541 let Some(body_row) = sqlx::query(&self.stmts.select_body)
1544 .bind(&run_id)
1545 .fetch_optional(&self.pool)
1546 .await
1547 .map_err(backend)?
1548 else {
1549 continue;
1550 };
1551 let body: String = body_row.try_get("body").map_err(backend)?;
1552 let mut rec = sql::decode_body(&body)?;
1553 if rec.status != RunStatus::Sharded {
1556 continue;
1557 }
1558 let now = chrono::Utc::now();
1559 rec.status = if success {
1560 RunStatus::Completed
1561 } else {
1562 RunStatus::Failed
1563 };
1564 rec.finished_at = Some(now);
1565 if !success {
1566 rec.error = Some(format!(
1567 "{}/{} shard(s) failed",
1568 progress.failed, progress.total
1569 ));
1570 }
1571 let new_body = sql::encode_body(&rec)?;
1572 let n = sqlx::query(&self.stmts.finalize_sharded_parent)
1573 .bind(rec.status.as_str())
1574 .bind(sql::fmt_ts(now))
1575 .bind(&new_body)
1576 .bind(&run_id)
1577 .execute(&self.pool)
1578 .await
1579 .map_err(backend)?
1580 .rows_affected();
1581 if n == 1 {
1582 finalized += 1;
1583 $crate::serve::metrics::record_run_finished(
1584 rec.status,
1585 if success { "ok" } else { "error" },
1586 );
1587 tracing::info!(
1588 run_id,
1589 shards = progress.total,
1590 failed = progress.failed,
1591 "sharded run finalized by sweep (F11)"
1592 );
1593 }
1594 }
1595 Ok(finalized)
1596 }
1597
1598 fn degraded(&self) -> bool {
1599 false
1602 }
1603 }
1604 };
1605}
1606
1607pub(crate) use impl_sql_history;
1608
1609#[cfg(test)]
1610mod tests {
1611 use super::*;
1612
1613 #[test]
1614 fn postgres_shard_statements_are_built() {
1615 let s = Stmts::new(Dialect::Postgres);
1618 assert!(s.insert_shard.contains("faucet_serve_shards"));
1619 assert!(s.insert_shard.contains("ON CONFLICT"));
1620 assert!(s.claim_shards_select.contains("JOIN faucet_serve_runs"));
1621 assert!(s.claim_shard_one.contains("'running'"));
1622 assert!(s.renew_shard_leases.contains("lease_expires_at"));
1623 assert!(s.reclaim_shards_select.contains("'running'"));
1624 assert!(s.reclaim_shard_requeue.contains("'pending'"));
1625 assert!(s.reclaim_shard_fail.contains("'failed'"));
1626 assert!(s.finalize_shard.contains("owner"));
1627 assert!(s.shard_progress.contains("GROUP BY"));
1628 }
1629
1630 #[test]
1631 fn fmt_ts_is_fixed_width_and_sortable() {
1632 let a = fmt_ts(
1633 DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
1634 .unwrap()
1635 .to_utc(),
1636 );
1637 let b = fmt_ts(
1638 DateTime::parse_from_rfc3339("2026-01-01T00:00:01Z")
1639 .unwrap()
1640 .to_utc(),
1641 );
1642 assert!(a.ends_with('Z'));
1643 assert_eq!(a.len(), b.len(), "fixed width");
1644 assert!(a < b, "lexicographic order matches chronological order");
1645 }
1646
1647 #[test]
1648 fn is_expired_respects_window() {
1649 let now = Utc::now();
1650 let old = fmt_ts(now - chrono::Duration::seconds(120));
1651 assert!(is_expired(&old, now, Duration::from_secs(60)));
1652 assert!(!is_expired(&old, now, Duration::from_secs(600)));
1653 assert!(!is_expired("not-a-timestamp", now, Duration::ZERO));
1655 }
1656
1657 #[test]
1658 fn parse_status_round_trips_known_and_defaults_failed() {
1659 for s in [
1660 RunStatus::Queued,
1661 RunStatus::Pending,
1662 RunStatus::Running,
1663 RunStatus::Completed,
1664 RunStatus::Failed,
1665 RunStatus::Cancelled,
1666 ] {
1667 assert_eq!(parse_status(s.as_str()), s);
1668 }
1669 assert_eq!(parse_status("garbage"), RunStatus::Failed);
1670 }
1671
1672 #[test]
1673 fn body_round_trips() {
1674 let rec = RunRecord::queued(
1675 "r1".into(),
1676 Some("n".into()),
1677 Default::default(),
1678 Some("idem".into()),
1679 Utc::now(),
1680 );
1681 let encoded = encode_body(&rec).unwrap();
1682 let decoded = decode_body(&encoded).unwrap();
1683 assert_eq!(decoded.run_id, "r1");
1684 assert_eq!(decoded.idempotency_key.as_deref(), Some("idem"));
1685 }
1686
1687 #[test]
1688 fn postgres_and_sqlite_statements_differ_only_in_placeholders() {
1689 let pg = Stmts::new(Dialect::Postgres);
1690 let lite = Stmts::new(Dialect::Sqlite);
1691 assert!(pg.upsert.contains("$1") && lite.upsert.contains('?'));
1692 assert!(pg.list.contains("$13") && lite.list.contains('?'));
1693 assert!(pg.insert_idem.contains("ON CONFLICT (key) DO NOTHING"));
1695 assert!(lite.insert_idem.contains("ON CONFLICT (key) DO NOTHING"));
1696 assert!(pg.claim_one.contains("$3") && lite.claim_one.contains('?'));
1697 assert!(pg.heartbeat_instance.contains("faucet_serve_instances"));
1698 assert!(lite.heartbeat_instance.contains("faucet_serve_instances"));
1699 }
1700}