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 "CREATE TABLE IF NOT EXISTS faucet_serve_audit (\
86 id TEXT PRIMARY KEY,\
87 ts TEXT NOT NULL,\
88 principal TEXT NOT NULL,\
89 role TEXT NOT NULL,\
90 action TEXT NOT NULL,\
91 run_id TEXT,\
92 config_fingerprint TEXT,\
93 source_ip TEXT,\
94 result TEXT NOT NULL)",
95 "CREATE INDEX IF NOT EXISTS faucet_serve_audit_ts_idx \
96 ON faucet_serve_audit (ts)",
97 "CREATE TABLE IF NOT EXISTS faucet_catalog_datasets (\
102 id TEXT PRIMARY KEY,\
103 uri TEXT NOT NULL,\
104 kind TEXT NOT NULL,\
105 last_seen TEXT NOT NULL,\
106 body TEXT NOT NULL)",
107 "CREATE TABLE IF NOT EXISTS faucet_catalog_schema_versions (\
111 dataset_id TEXT NOT NULL,\
112 version TEXT NOT NULL,\
113 recorded_at TEXT NOT NULL,\
114 body TEXT NOT NULL,\
115 PRIMARY KEY (dataset_id, version))",
116 "CREATE TABLE IF NOT EXISTS faucet_catalog_edges (\
118 src_id TEXT NOT NULL,\
119 dst_id TEXT NOT NULL,\
120 last_seen TEXT NOT NULL,\
121 body TEXT NOT NULL,\
122 PRIMARY KEY (src_id, dst_id))",
123 "CREATE TABLE IF NOT EXISTS faucet_catalog_stats (\
125 dataset_id TEXT NOT NULL,\
126 recorded_at TEXT NOT NULL,\
127 run_id TEXT NOT NULL,\
128 records TEXT NOT NULL,\
129 PRIMARY KEY (dataset_id, recorded_at))",
130 "CREATE TABLE IF NOT EXISTS faucet_config_snapshots (\
134 pipeline TEXT PRIMARY KEY,\
135 recorded_at TEXT NOT NULL,\
136 faucet_version TEXT NOT NULL,\
137 body TEXT NOT NULL)",
138];
139
140#[derive(Clone, Copy, Debug)]
142pub enum Dialect {
143 Postgres,
144 Sqlite,
145}
146
147pub struct Stmts {
149 pub upsert: String,
153 pub select_body: String,
154 pub select_status: String,
155 pub select_submitted: String,
156 pub delete: String,
157 pub list: String,
158 pub purge_runs: String,
159 pub purge_idem: String,
160 pub select_orphans: String,
163 pub renew_leases: String,
166 pub insert_idem: String,
167 pub select_idem: String,
168 pub takeover_idem: String,
169 pub delete_idem_by_run: String,
174 pub select_pending: String,
176 pub claim_one: String,
178 pub reclaim_select: String,
182 pub reclaim_requeue: String,
184 pub reclaim_fail: String,
186 pub finalize_owned: String,
188 pub cancel_pending: String,
190 pub request_cancel: String,
192 pub pending_cancellations: String,
194 pub heartbeat_instance: String,
196 pub live_instances: String,
198 pub prune_instances: String,
200 pub insert_shard: String,
203 pub claim_shards_select: String,
205 pub claim_shard_one: String,
207 pub renew_shard_leases: String,
209 pub reclaim_shards_select: String,
211 pub reclaim_shard_requeue: String,
213 pub reclaim_shard_fail: String,
215 pub finalize_shard: String,
217 pub shard_progress: String,
219 pub pending_shard_cancellations: String,
223 pub select_sharded_parents: String,
226 pub finalize_sharded_parent: String,
230 pub delete_shards_by_run: String,
233 pub purge_orphan_shards: String,
236 pub insert_audit: String,
239 pub list_audit: String,
242 pub purge_audit: String,
244 pub catalog_select_dataset: String,
247 pub catalog_upsert_dataset: String,
250 pub catalog_select_datasets: String,
254 pub catalog_insert_schema_version: String,
257 pub catalog_select_schema_versions: String,
259 pub catalog_upsert_edge: String,
261 pub catalog_select_edges: String,
263 pub catalog_insert_stat: String,
265 pub catalog_select_stats: String,
267 pub catalog_prune_stats: String,
270 pub catalog_upsert_config_snapshot: String,
273 pub catalog_select_config_snapshot: String,
275}
276
277impl Stmts {
278 pub fn new(dialect: Dialect) -> Self {
279 match dialect {
280 Dialect::Postgres => Self::postgres(),
281 Dialect::Sqlite => Self::sqlite(),
282 }
283 }
284
285 fn postgres() -> Self {
286 Self {
287 upsert: "INSERT INTO faucet_serve_runs \
288 (run_id,name,status,submitted_at,finished_at,idempotency_key,owner,lease_expires_at,body) \
289 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) \
290 ON CONFLICT (run_id) DO UPDATE SET \
291 name=excluded.name,status=excluded.status,submitted_at=excluded.submitted_at,\
292 finished_at=excluded.finished_at,idempotency_key=excluded.idempotency_key,\
293 owner=excluded.owner,lease_expires_at=excluded.lease_expires_at,\
294 body=excluded.body"
295 .into(),
296 select_body: "SELECT body FROM faucet_serve_runs WHERE run_id=$1".into(),
297 select_status: "SELECT status FROM faucet_serve_runs WHERE run_id=$1".into(),
298 select_submitted: "SELECT submitted_at FROM faucet_serve_runs WHERE run_id=$1".into(),
299 delete: "DELETE FROM faucet_serve_runs WHERE run_id=$1".into(),
300 list: "SELECT body FROM faucet_serve_runs \
303 WHERE ($1::text IS NULL OR status = $2::text) \
304 AND ($3::text IS NULL OR name = $4::text) \
305 AND ($5::text IS NULL OR submitted_at >= $6::text) \
306 AND ($7::text IS NULL OR submitted_at <= $8::text) \
307 AND ($9::text IS NULL OR (submitted_at < $10::text \
308 OR (submitted_at = $11::text AND run_id < $12::text))) \
309 ORDER BY submitted_at DESC, run_id DESC LIMIT $13"
310 .into(),
311 purge_runs: "DELETE FROM faucet_serve_runs \
312 WHERE status IN ('completed','failed','cancelled') \
313 AND finished_at IS NOT NULL AND finished_at < $1"
314 .into(),
315 purge_idem: "DELETE FROM faucet_serve_idem WHERE claimed_at < $1".into(),
316 select_orphans: "SELECT body FROM faucet_serve_runs \
317 WHERE status IN ('queued','running') \
318 AND (lease_expires_at IS NULL OR lease_expires_at < $1)"
319 .into(),
320 renew_leases: "UPDATE faucet_serve_runs SET lease_expires_at = $1 \
321 WHERE owner = $2 AND status IN ('queued','running')"
322 .into(),
323 insert_idem: "INSERT INTO faucet_serve_idem (key,run_id,fingerprint,claimed_at) \
324 VALUES ($1,$2,$3,$4) ON CONFLICT (key) DO NOTHING"
325 .into(),
326 select_idem: "SELECT run_id,fingerprint,claimed_at FROM faucet_serve_idem WHERE key=$1"
327 .into(),
328 takeover_idem: "UPDATE faucet_serve_idem \
329 SET run_id=$1,fingerprint=$2,claimed_at=$3 WHERE key=$4 AND claimed_at=$5"
330 .into(),
331 delete_idem_by_run: "DELETE FROM faucet_serve_idem WHERE run_id=$1".into(),
332 select_pending: "SELECT run_id, body FROM faucet_serve_runs \
333 WHERE status = 'pending' ORDER BY submitted_at ASC LIMIT $1"
334 .into(),
335 claim_one: "UPDATE faucet_serve_runs \
336 SET owner = $1, status = 'running', lease_expires_at = $2, body = $3 \
337 WHERE run_id = $4 AND status = 'pending'"
338 .into(),
339 reclaim_select: "SELECT body FROM faucet_serve_runs \
340 WHERE status = 'running' \
341 AND (lease_expires_at IS NULL OR lease_expires_at < $1)"
342 .into(),
343 reclaim_requeue: "UPDATE faucet_serve_runs \
348 SET status = 'pending', owner = NULL, lease_expires_at = NULL, \
349 body = $1 \
350 WHERE run_id = $2 AND status = 'running' \
351 AND (lease_expires_at IS NULL OR lease_expires_at < $3)"
352 .into(),
353 reclaim_fail: "UPDATE faucet_serve_runs \
354 SET status = 'failed', finished_at = $1, body = $2, owner = NULL \
355 WHERE run_id = $3 AND status = 'running' \
356 AND (lease_expires_at IS NULL OR lease_expires_at < $4)"
357 .into(),
358 finalize_owned: "UPDATE faucet_serve_runs \
363 SET status = $1, finished_at = $2, lease_expires_at = $3, body = $4 \
364 WHERE run_id = $5 AND owner = $6 \
365 AND status NOT IN ('completed','failed','cancelled')"
366 .into(),
367 cancel_pending: "UPDATE faucet_serve_runs \
368 SET status = 'cancelled', finished_at = $1, body = $2 \
369 WHERE run_id = $3 AND status = 'pending'"
370 .into(),
371 request_cancel: "UPDATE faucet_serve_runs \
372 SET cancel_requested = $1 WHERE run_id = $2 AND status IN ('running','sharded')"
373 .into(),
374 pending_cancellations: "SELECT run_id FROM faucet_serve_runs \
375 WHERE status = 'running' AND owner = $1 AND cancel_requested IS NOT NULL"
376 .into(),
377 heartbeat_instance: "INSERT INTO faucet_serve_instances \
378 (instance_id, started_at, last_heartbeat, listen, max_concurrent, in_flight) \
379 VALUES ($1,$2,$3,$4,$5,$6) \
380 ON CONFLICT (instance_id) DO UPDATE SET \
381 last_heartbeat = excluded.last_heartbeat, listen = excluded.listen, \
382 max_concurrent = excluded.max_concurrent, in_flight = excluded.in_flight"
383 .into(),
384 live_instances: "SELECT instance_id, started_at, last_heartbeat, listen, \
385 max_concurrent, in_flight FROM faucet_serve_instances \
386 WHERE last_heartbeat >= $1"
387 .into(),
388 prune_instances: "DELETE FROM faucet_serve_instances WHERE last_heartbeat < $1".into(),
389 insert_shard: "INSERT INTO faucet_serve_shards \
390 (run_id, shard_id, descriptor, size_estimate, status, attempt) \
391 VALUES ($1,$2,$3,$4,'pending','0') \
392 ON CONFLICT (run_id, shard_id) DO NOTHING"
393 .into(),
394 claim_shards_select: "SELECT s.run_id, s.shard_id, s.descriptor, r.body \
395 FROM faucet_serve_shards s JOIN faucet_serve_runs r ON r.run_id = s.run_id \
396 WHERE s.status = 'pending' \
397 ORDER BY CAST(COALESCE(s.size_estimate, '0') AS BIGINT) DESC, s.run_id, s.shard_id \
398 LIMIT $1"
399 .into(),
400 claim_shard_one: "UPDATE faucet_serve_shards \
401 SET owner = $1, status = 'running', lease_expires_at = $2 \
402 WHERE run_id = $3 AND shard_id = $4 AND status = 'pending'"
403 .into(),
404 renew_shard_leases: "UPDATE faucet_serve_shards SET lease_expires_at = $1 \
405 WHERE owner = $2 AND status = 'running'"
406 .into(),
407 reclaim_shards_select: "SELECT run_id, shard_id, attempt FROM faucet_serve_shards \
408 WHERE status = 'running' \
409 AND (lease_expires_at IS NULL OR lease_expires_at < $1)"
410 .into(),
411 reclaim_shard_requeue: "UPDATE faucet_serve_shards \
412 SET status = 'pending', owner = NULL, lease_expires_at = NULL, attempt = $1 \
413 WHERE run_id = $2 AND shard_id = $3 AND status = 'running' \
414 AND (lease_expires_at IS NULL OR lease_expires_at < $4)"
415 .into(),
416 reclaim_shard_fail: "UPDATE faucet_serve_shards \
417 SET status = 'failed', finished_at = $1, owner = NULL \
418 WHERE run_id = $2 AND shard_id = $3 AND status = 'running' \
419 AND (lease_expires_at IS NULL OR lease_expires_at < $4)"
420 .into(),
421 finalize_shard: "UPDATE faucet_serve_shards \
422 SET status = $1, finished_at = $2 \
423 WHERE run_id = $3 AND shard_id = $4 AND owner = $5 AND status = 'running'"
424 .into(),
425 shard_progress: "SELECT status, COUNT(*) AS n FROM faucet_serve_shards \
426 WHERE run_id = $1 GROUP BY status"
427 .into(),
428 pending_shard_cancellations: "SELECT DISTINCT s.run_id \
429 FROM faucet_serve_shards s \
430 JOIN faucet_serve_runs r ON r.run_id = s.run_id \
431 WHERE s.owner = $1 AND s.status = 'running' \
432 AND r.cancel_requested IS NOT NULL"
433 .into(),
434 select_sharded_parents: "SELECT run_id FROM faucet_serve_runs \
435 WHERE status = 'sharded'"
436 .into(),
437 finalize_sharded_parent: "UPDATE faucet_serve_runs \
438 SET status = $1, finished_at = $2, body = $3 \
439 WHERE run_id = $4 AND status = 'sharded'"
440 .into(),
441 delete_shards_by_run: "DELETE FROM faucet_serve_shards WHERE run_id = $1".into(),
442 purge_orphan_shards: "DELETE FROM faucet_serve_shards \
443 WHERE run_id NOT IN (SELECT run_id FROM faucet_serve_runs)"
444 .into(),
445 insert_audit: "INSERT INTO faucet_serve_audit \
446 (id, ts, principal, role, action, run_id, config_fingerprint, source_ip, result) \
447 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)"
448 .into(),
449 list_audit: "SELECT id, ts, principal, role, action, run_id, config_fingerprint, \
450 source_ip, result FROM faucet_serve_audit \
451 WHERE ($1::text IS NULL OR principal = $2::text) \
452 AND ($3::text IS NULL OR action = $4::text) \
453 AND ($5::text IS NULL OR ts >= $6::text) \
454 AND ($7::text IS NULL OR ts <= $8::text) \
455 ORDER BY ts DESC, id DESC LIMIT $9"
456 .into(),
457 purge_audit: "DELETE FROM faucet_serve_audit WHERE ts < $1".into(),
458 catalog_select_dataset: "SELECT body FROM faucet_catalog_datasets WHERE id=$1".into(),
459 catalog_upsert_dataset: "INSERT INTO faucet_catalog_datasets \
460 (id, uri, kind, last_seen, body) VALUES ($1,$2,$3,$4,$5) \
461 ON CONFLICT (id) DO UPDATE SET uri=excluded.uri, kind=excluded.kind, \
462 last_seen=excluded.last_seen, body=excluded.body"
463 .into(),
464 catalog_select_datasets: "SELECT body FROM faucet_catalog_datasets".into(),
465 catalog_insert_schema_version: "INSERT INTO faucet_catalog_schema_versions \
466 (dataset_id, version, recorded_at, body) VALUES ($1,$2,$3,$4) \
467 ON CONFLICT (dataset_id, version) DO NOTHING"
468 .into(),
469 catalog_select_schema_versions: "SELECT body FROM faucet_catalog_schema_versions \
470 WHERE dataset_id=$1 ORDER BY CAST(version AS BIGINT) ASC"
471 .into(),
472 catalog_upsert_edge: "INSERT INTO faucet_catalog_edges \
473 (src_id, dst_id, last_seen, body) VALUES ($1,$2,$3,$4) \
474 ON CONFLICT (src_id, dst_id) DO UPDATE SET \
475 last_seen=excluded.last_seen, body=excluded.body"
476 .into(),
477 catalog_select_edges: "SELECT body FROM faucet_catalog_edges \
478 ORDER BY last_seen DESC, src_id, dst_id"
479 .into(),
480 catalog_insert_stat: "INSERT INTO faucet_catalog_stats \
481 (dataset_id, recorded_at, run_id, records) VALUES ($1,$2,$3,$4) \
482 ON CONFLICT (dataset_id, recorded_at) DO NOTHING"
483 .into(),
484 catalog_select_stats: "SELECT recorded_at, run_id, records \
485 FROM faucet_catalog_stats WHERE dataset_id=$1 \
486 ORDER BY recorded_at DESC LIMIT $2"
487 .into(),
488 catalog_prune_stats: "DELETE FROM faucet_catalog_stats \
489 WHERE dataset_id=$1 AND recorded_at NOT IN (\
490 SELECT recorded_at FROM faucet_catalog_stats WHERE dataset_id=$2 \
491 ORDER BY recorded_at DESC LIMIT $3)"
492 .into(),
493 catalog_upsert_config_snapshot: "INSERT INTO faucet_config_snapshots \
494 (pipeline, recorded_at, faucet_version, body) VALUES ($1,$2,$3,$4) \
495 ON CONFLICT (pipeline) DO UPDATE SET recorded_at=excluded.recorded_at, \
496 faucet_version=excluded.faucet_version, body=excluded.body"
497 .into(),
498 catalog_select_config_snapshot:
499 "SELECT body FROM faucet_config_snapshots WHERE pipeline=$1".into(),
500 }
501 }
502
503 fn sqlite() -> Self {
504 Self {
505 upsert: "INSERT INTO faucet_serve_runs \
506 (run_id,name,status,submitted_at,finished_at,idempotency_key,owner,lease_expires_at,body) \
507 VALUES (?,?,?,?,?,?,?,?,?) \
508 ON CONFLICT (run_id) DO UPDATE SET \
509 name=excluded.name,status=excluded.status,submitted_at=excluded.submitted_at,\
510 finished_at=excluded.finished_at,idempotency_key=excluded.idempotency_key,\
511 owner=excluded.owner,lease_expires_at=excluded.lease_expires_at,\
512 body=excluded.body"
513 .into(),
514 select_body: "SELECT body FROM faucet_serve_runs WHERE run_id=?".into(),
515 select_status: "SELECT status FROM faucet_serve_runs WHERE run_id=?".into(),
516 select_submitted: "SELECT submitted_at FROM faucet_serve_runs WHERE run_id=?".into(),
517 delete: "DELETE FROM faucet_serve_runs WHERE run_id=?".into(),
518 list: "SELECT body FROM faucet_serve_runs \
519 WHERE (? IS NULL OR status = ?) \
520 AND (? IS NULL OR name = ?) \
521 AND (? IS NULL OR submitted_at >= ?) \
522 AND (? IS NULL OR submitted_at <= ?) \
523 AND (? IS NULL OR (submitted_at < ? \
524 OR (submitted_at = ? AND run_id < ?))) \
525 ORDER BY submitted_at DESC, run_id DESC LIMIT ?"
526 .into(),
527 purge_runs: "DELETE FROM faucet_serve_runs \
528 WHERE status IN ('completed','failed','cancelled') \
529 AND finished_at IS NOT NULL AND finished_at < ?"
530 .into(),
531 purge_idem: "DELETE FROM faucet_serve_idem WHERE claimed_at < ?".into(),
532 select_orphans: "SELECT body FROM faucet_serve_runs \
533 WHERE status IN ('queued','running') \
534 AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
535 .into(),
536 renew_leases: "UPDATE faucet_serve_runs SET lease_expires_at = ? \
537 WHERE owner = ? AND status IN ('queued','running')"
538 .into(),
539 insert_idem: "INSERT INTO faucet_serve_idem (key,run_id,fingerprint,claimed_at) \
540 VALUES (?,?,?,?) ON CONFLICT (key) DO NOTHING"
541 .into(),
542 select_idem: "SELECT run_id,fingerprint,claimed_at FROM faucet_serve_idem WHERE key=?"
543 .into(),
544 takeover_idem: "UPDATE faucet_serve_idem \
545 SET run_id=?,fingerprint=?,claimed_at=? WHERE key=? AND claimed_at=?"
546 .into(),
547 delete_idem_by_run: "DELETE FROM faucet_serve_idem WHERE run_id=?".into(),
548 select_pending: "SELECT run_id, body FROM faucet_serve_runs \
549 WHERE status = 'pending' ORDER BY submitted_at ASC LIMIT ?"
550 .into(),
551 claim_one: "UPDATE faucet_serve_runs \
552 SET owner = ?, status = 'running', lease_expires_at = ?, body = ? \
553 WHERE run_id = ? AND status = 'pending'"
554 .into(),
555 reclaim_select: "SELECT body FROM faucet_serve_runs \
556 WHERE status = 'running' \
557 AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
558 .into(),
559 reclaim_requeue: "UPDATE faucet_serve_runs \
561 SET status = 'pending', owner = NULL, lease_expires_at = NULL, \
562 body = ? \
563 WHERE run_id = ? AND status = 'running' \
564 AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
565 .into(),
566 reclaim_fail: "UPDATE faucet_serve_runs \
567 SET status = 'failed', finished_at = ?, body = ?, owner = NULL \
568 WHERE run_id = ? AND status = 'running' \
569 AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
570 .into(),
571 finalize_owned: "UPDATE faucet_serve_runs \
573 SET status = ?, finished_at = ?, lease_expires_at = ?, body = ? \
574 WHERE run_id = ? AND owner = ? \
575 AND status NOT IN ('completed','failed','cancelled')"
576 .into(),
577 cancel_pending: "UPDATE faucet_serve_runs \
578 SET status = 'cancelled', finished_at = ?, body = ? \
579 WHERE run_id = ? AND status = 'pending'"
580 .into(),
581 request_cancel: "UPDATE faucet_serve_runs \
582 SET cancel_requested = ? WHERE run_id = ? AND status IN ('running','sharded')"
583 .into(),
584 pending_cancellations: "SELECT run_id FROM faucet_serve_runs \
585 WHERE status = 'running' AND owner = ? AND cancel_requested IS NOT NULL"
586 .into(),
587 heartbeat_instance: "INSERT INTO faucet_serve_instances \
588 (instance_id, started_at, last_heartbeat, listen, max_concurrent, in_flight) \
589 VALUES (?,?,?,?,?,?) \
590 ON CONFLICT (instance_id) DO UPDATE SET \
591 last_heartbeat = excluded.last_heartbeat, listen = excluded.listen, \
592 max_concurrent = excluded.max_concurrent, in_flight = excluded.in_flight"
593 .into(),
594 live_instances: "SELECT instance_id, started_at, last_heartbeat, listen, \
595 max_concurrent, in_flight FROM faucet_serve_instances \
596 WHERE last_heartbeat >= ?"
597 .into(),
598 prune_instances: "DELETE FROM faucet_serve_instances WHERE last_heartbeat < ?".into(),
599 insert_shard: "INSERT INTO faucet_serve_shards \
600 (run_id, shard_id, descriptor, size_estimate, status, attempt) \
601 VALUES (?,?,?,?,'pending','0') \
602 ON CONFLICT (run_id, shard_id) DO NOTHING"
603 .into(),
604 claim_shards_select: "SELECT s.run_id, s.shard_id, s.descriptor, r.body \
605 FROM faucet_serve_shards s JOIN faucet_serve_runs r ON r.run_id = s.run_id \
606 WHERE s.status = 'pending' \
607 ORDER BY CAST(COALESCE(s.size_estimate, '0') AS INTEGER) DESC, s.run_id, s.shard_id \
608 LIMIT ?"
609 .into(),
610 claim_shard_one: "UPDATE faucet_serve_shards \
611 SET owner = ?, status = 'running', lease_expires_at = ? \
612 WHERE run_id = ? AND shard_id = ? AND status = 'pending'"
613 .into(),
614 renew_shard_leases: "UPDATE faucet_serve_shards SET lease_expires_at = ? \
615 WHERE owner = ? AND status = 'running'"
616 .into(),
617 reclaim_shards_select: "SELECT run_id, shard_id, attempt FROM faucet_serve_shards \
618 WHERE status = 'running' \
619 AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
620 .into(),
621 reclaim_shard_requeue: "UPDATE faucet_serve_shards \
622 SET status = 'pending', owner = NULL, lease_expires_at = NULL, attempt = ? \
623 WHERE run_id = ? AND shard_id = ? AND status = 'running' \
624 AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
625 .into(),
626 reclaim_shard_fail: "UPDATE faucet_serve_shards \
627 SET status = 'failed', finished_at = ?, owner = NULL \
628 WHERE run_id = ? AND shard_id = ? AND status = 'running' \
629 AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
630 .into(),
631 finalize_shard: "UPDATE faucet_serve_shards \
632 SET status = ?, finished_at = ? \
633 WHERE run_id = ? AND shard_id = ? AND owner = ? AND status = 'running'"
634 .into(),
635 shard_progress: "SELECT status, COUNT(*) AS n FROM faucet_serve_shards \
636 WHERE run_id = ? GROUP BY status"
637 .into(),
638 pending_shard_cancellations: "SELECT DISTINCT s.run_id \
639 FROM faucet_serve_shards s \
640 JOIN faucet_serve_runs r ON r.run_id = s.run_id \
641 WHERE s.owner = ? AND s.status = 'running' \
642 AND r.cancel_requested IS NOT NULL"
643 .into(),
644 select_sharded_parents: "SELECT run_id FROM faucet_serve_runs \
645 WHERE status = 'sharded'"
646 .into(),
647 finalize_sharded_parent: "UPDATE faucet_serve_runs \
648 SET status = ?, finished_at = ?, body = ? \
649 WHERE run_id = ? AND status = 'sharded'"
650 .into(),
651 delete_shards_by_run: "DELETE FROM faucet_serve_shards WHERE run_id = ?".into(),
652 purge_orphan_shards: "DELETE FROM faucet_serve_shards \
653 WHERE run_id NOT IN (SELECT run_id FROM faucet_serve_runs)"
654 .into(),
655 insert_audit: "INSERT INTO faucet_serve_audit \
656 (id, ts, principal, role, action, run_id, config_fingerprint, source_ip, result) \
657 VALUES (?,?,?,?,?,?,?,?,?)"
658 .into(),
659 list_audit: "SELECT id, ts, principal, role, action, run_id, config_fingerprint, \
660 source_ip, result FROM faucet_serve_audit \
661 WHERE (? IS NULL OR principal = ?) \
662 AND (? IS NULL OR action = ?) \
663 AND (? IS NULL OR ts >= ?) \
664 AND (? IS NULL OR ts <= ?) \
665 ORDER BY ts DESC, id DESC LIMIT ?"
666 .into(),
667 purge_audit: "DELETE FROM faucet_serve_audit WHERE ts < ?".into(),
668 catalog_select_dataset: "SELECT body FROM faucet_catalog_datasets WHERE id=?".into(),
669 catalog_upsert_dataset: "INSERT INTO faucet_catalog_datasets \
670 (id, uri, kind, last_seen, body) VALUES (?,?,?,?,?) \
671 ON CONFLICT (id) DO UPDATE SET uri=excluded.uri, kind=excluded.kind, \
672 last_seen=excluded.last_seen, body=excluded.body"
673 .into(),
674 catalog_select_datasets: "SELECT body FROM faucet_catalog_datasets".into(),
675 catalog_insert_schema_version: "INSERT INTO faucet_catalog_schema_versions \
676 (dataset_id, version, recorded_at, body) VALUES (?,?,?,?) \
677 ON CONFLICT (dataset_id, version) DO NOTHING"
678 .into(),
679 catalog_select_schema_versions: "SELECT body FROM faucet_catalog_schema_versions \
680 WHERE dataset_id=? ORDER BY CAST(version AS INTEGER) ASC"
681 .into(),
682 catalog_upsert_edge: "INSERT INTO faucet_catalog_edges \
683 (src_id, dst_id, last_seen, body) VALUES (?,?,?,?) \
684 ON CONFLICT (src_id, dst_id) DO UPDATE SET \
685 last_seen=excluded.last_seen, body=excluded.body"
686 .into(),
687 catalog_select_edges: "SELECT body FROM faucet_catalog_edges \
688 ORDER BY last_seen DESC, src_id, dst_id"
689 .into(),
690 catalog_insert_stat: "INSERT INTO faucet_catalog_stats \
691 (dataset_id, recorded_at, run_id, records) VALUES (?,?,?,?) \
692 ON CONFLICT (dataset_id, recorded_at) DO NOTHING"
693 .into(),
694 catalog_select_stats: "SELECT recorded_at, run_id, records \
695 FROM faucet_catalog_stats WHERE dataset_id=? \
696 ORDER BY recorded_at DESC LIMIT ?"
697 .into(),
698 catalog_prune_stats: "DELETE FROM faucet_catalog_stats \
699 WHERE dataset_id=? AND recorded_at NOT IN (\
700 SELECT recorded_at FROM faucet_catalog_stats WHERE dataset_id=? \
701 ORDER BY recorded_at DESC LIMIT ?)"
702 .into(),
703 catalog_upsert_config_snapshot: "INSERT INTO faucet_config_snapshots \
704 (pipeline, recorded_at, faucet_version, body) VALUES (?,?,?,?) \
705 ON CONFLICT (pipeline) DO UPDATE SET recorded_at=excluded.recorded_at, \
706 faucet_version=excluded.faucet_version, body=excluded.body"
707 .into(),
708 catalog_select_config_snapshot:
709 "SELECT body FROM faucet_config_snapshots WHERE pipeline=?".into(),
710 }
711 }
712}
713
714pub const CLAIM_ATTEMPTS: usize = 4;
717
718pub fn fmt_ts(dt: DateTime<Utc>) -> String {
720 dt.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true)
721}
722
723pub fn is_expired(claimed_at: &str, now: DateTime<Utc>, window: Duration) -> bool {
727 match DateTime::parse_from_rfc3339(claimed_at) {
728 Ok(t) => now
729 .signed_duration_since(t.with_timezone(&Utc))
730 .to_std()
731 .map(|age| age >= window)
732 .unwrap_or(false),
733 Err(_) => false,
734 }
735}
736
737pub fn threshold(now: DateTime<Utc>, window: Duration) -> String {
739 let delta =
740 chrono::Duration::from_std(window).unwrap_or_else(|_| chrono::Duration::days(36_500));
741 fmt_ts(now - delta)
742}
743
744pub fn encode_body(rec: &RunRecord) -> Result<String, HistoryError> {
745 serde_json::to_string(rec).map_err(|e| HistoryError::Backend(format!("encode run record: {e}")))
746}
747
748pub fn decode_body(body: &str) -> Result<RunRecord, HistoryError> {
749 serde_json::from_str(body).map_err(|e| HistoryError::Backend(format!("decode run record: {e}")))
750}
751
752pub fn encode_json<T: serde::Serialize>(value: &T, what: &str) -> Result<String, HistoryError> {
754 serde_json::to_string(value).map_err(|e| HistoryError::Backend(format!("encode {what}: {e}")))
755}
756
757pub fn decode_json<T: serde::de::DeserializeOwned>(
758 body: &str,
759 what: &str,
760) -> Result<T, HistoryError> {
761 serde_json::from_str(body).map_err(|e| HistoryError::Backend(format!("decode {what}: {e}")))
762}
763
764pub fn parse_status(s: &str) -> RunStatus {
765 match s {
766 "queued" => RunStatus::Queued,
767 "pending" => RunStatus::Pending,
768 "running" => RunStatus::Running,
769 "sharded" => RunStatus::Sharded,
770 "completed" => RunStatus::Completed,
771 "cancelled" => RunStatus::Cancelled,
772 _ => RunStatus::Failed,
773 }
774}
775
776macro_rules! impl_sql_history {
780 ($name:ident, $pool:ty) => {
781 pub struct $name {
784 pool: $pool,
785 idem_retention: std::time::Duration,
786 instance_id: String,
788 lease_ttl: std::time::Duration,
790 stmts: $crate::serve::history::sql::Stmts,
791 }
792
793 impl $name {
794 pub fn from_parts(
796 pool: $pool,
797 idem_retention: std::time::Duration,
798 lease_ttl: std::time::Duration,
799 instance_id: String,
800 stmts: $crate::serve::history::sql::Stmts,
801 ) -> Self {
802 Self {
803 pool,
804 idem_retention,
805 instance_id,
806 lease_ttl,
807 stmts,
808 }
809 }
810
811 pub fn pool(&self) -> &$pool {
813 &self.pool
814 }
815 }
816
817 #[async_trait::async_trait]
818 impl $crate::serve::history::RunHistory for $name {
819 async fn claim_idempotency(
820 &self,
821 key: &str,
822 fingerprint: &str,
823 run_id: &str,
824 window: std::time::Duration,
825 ) -> Result<$crate::serve::history::Claim, $crate::serve::history::HistoryError> {
826 use sqlx::Row as _;
827 use $crate::serve::history::Claim;
828 use $crate::serve::history::HistoryError;
829 use $crate::serve::history::sql;
830
831 let now = chrono::Utc::now();
832 let now_s = sql::fmt_ts(now);
833 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
834
835 for _ in 0..sql::CLAIM_ATTEMPTS {
836 let inserted = sqlx::query(&self.stmts.insert_idem)
838 .bind(key)
839 .bind(run_id)
840 .bind(fingerprint)
841 .bind(&now_s)
842 .execute(&self.pool)
843 .await
844 .map_err(backend)?
845 .rows_affected();
846 if inserted == 1 {
847 return Ok(Claim::Fresh);
848 }
849 let Some(row) = sqlx::query(&self.stmts.select_idem)
851 .bind(key)
852 .fetch_optional(&self.pool)
853 .await
854 .map_err(backend)?
855 else {
856 continue;
858 };
859 let existing_run: String = row.try_get("run_id").map_err(backend)?;
860 let existing_fp: String = row.try_get("fingerprint").map_err(backend)?;
861 let claimed_at: String = row.try_get("claimed_at").map_err(backend)?;
862
863 if sql::is_expired(&claimed_at, now, window) {
864 let took = sqlx::query(&self.stmts.takeover_idem)
867 .bind(run_id)
868 .bind(fingerprint)
869 .bind(&now_s)
870 .bind(key)
871 .bind(&claimed_at)
872 .execute(&self.pool)
873 .await
874 .map_err(backend)?
875 .rows_affected();
876 if took == 1 {
877 return Ok(Claim::Fresh);
878 }
879 continue; }
881 return Ok(if existing_fp == fingerprint {
882 Claim::Replay(existing_run)
883 } else {
884 Claim::Conflict
885 });
886 }
887 tracing::warn!(
890 key,
891 "idempotency claim exhausted retries; reporting conflict"
892 );
893 Ok(Claim::Conflict)
894 }
895
896 async fn upsert(
897 &self,
898 rec: &$crate::serve::history::RunRecord,
899 ) -> Result<(), $crate::serve::history::HistoryError> {
900 use $crate::serve::history::HistoryError;
901 use $crate::serve::history::sql;
902 let body = sql::encode_body(rec)?;
903 let submitted = sql::fmt_ts(rec.submitted_at);
904 let finished = rec.finished_at.map(sql::fmt_ts);
905 let lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
910 sqlx::query(&self.stmts.upsert)
911 .bind(&rec.run_id)
912 .bind(rec.name.as_deref())
913 .bind(rec.status.as_str())
914 .bind(&submitted)
915 .bind(finished.as_deref())
916 .bind(rec.idempotency_key.as_deref())
917 .bind(&self.instance_id)
918 .bind(&lease)
919 .bind(&body)
920 .execute(&self.pool)
921 .await
922 .map_err(|e| HistoryError::Backend(e.to_string()))?;
923 Ok(())
924 }
925
926 async fn get(
927 &self,
928 id: &str,
929 ) -> Result<
930 Option<$crate::serve::history::RunRecord>,
931 $crate::serve::history::HistoryError,
932 > {
933 use sqlx::Row as _;
934 use $crate::serve::history::HistoryError;
935 use $crate::serve::history::sql;
936 let row = sqlx::query(&self.stmts.select_body)
937 .bind(id)
938 .fetch_optional(&self.pool)
939 .await
940 .map_err(|e| HistoryError::Backend(e.to_string()))?;
941 match row {
942 None => Ok(None),
943 Some(r) => {
944 let body: String = r
945 .try_get("body")
946 .map_err(|e| HistoryError::Backend(e.to_string()))?;
947 Ok(Some(sql::decode_body(&body)?))
948 }
949 }
950 }
951
952 async fn list(
953 &self,
954 filter: &$crate::serve::history::ListFilter,
955 ) -> Result<$crate::serve::history::ListPage, $crate::serve::history::HistoryError>
956 {
957 use sqlx::Row as _;
958 use $crate::serve::history::HistoryError;
959 use $crate::serve::history::ListPage;
960 use $crate::serve::history::sql;
961 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
962
963 let cursor_ts: Option<String> = match &filter.cursor {
967 None => None,
968 Some(c) => sqlx::query(&self.stmts.select_submitted)
969 .bind(c)
970 .fetch_optional(&self.pool)
971 .await
972 .map_err(backend)?
973 .map(|r| r.try_get::<String, _>("submitted_at"))
974 .transpose()
975 .map_err(backend)?,
976 };
977 let cur_id = if cursor_ts.is_some() {
978 filter.cursor.as_deref()
979 } else {
980 None
981 };
982
983 let status_s = filter.status.map(|s| s.as_str());
984 let name_s = filter.name.as_deref();
985 let since_s = filter.since.map(sql::fmt_ts);
986 let until_s = filter.until.map(sql::fmt_ts);
987 let limit = filter.limit.max(1);
988 let fetch_n = limit as i64 + 1; let rows = sqlx::query(&self.stmts.list)
991 .bind(status_s)
992 .bind(status_s)
993 .bind(name_s)
994 .bind(name_s)
995 .bind(since_s.as_deref())
996 .bind(since_s.as_deref())
997 .bind(until_s.as_deref())
998 .bind(until_s.as_deref())
999 .bind(cursor_ts.as_deref())
1000 .bind(cursor_ts.as_deref())
1001 .bind(cursor_ts.as_deref())
1002 .bind(cur_id)
1003 .bind(fetch_n)
1004 .fetch_all(&self.pool)
1005 .await
1006 .map_err(backend)?;
1007
1008 let mut runs = Vec::with_capacity(rows.len());
1009 for r in &rows {
1010 let body: String = r.try_get("body").map_err(backend)?;
1011 runs.push(sql::decode_body(&body)?);
1012 }
1013 let next_cursor = if runs.len() > limit {
1014 Some(runs[limit - 1].run_id.clone())
1015 } else {
1016 None
1017 };
1018 runs.truncate(limit);
1019 Ok(ListPage { runs, next_cursor })
1020 }
1021
1022 async fn delete(
1023 &self,
1024 id: &str,
1025 ) -> Result<$crate::serve::history::DeleteOutcome, $crate::serve::history::HistoryError>
1026 {
1027 use sqlx::Row as _;
1028 use $crate::serve::history::DeleteOutcome;
1029 use $crate::serve::history::HistoryError;
1030 use $crate::serve::history::sql;
1031 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1032 let status: Option<String> = sqlx::query(&self.stmts.select_status)
1033 .bind(id)
1034 .fetch_optional(&self.pool)
1035 .await
1036 .map_err(backend)?
1037 .map(|r| r.try_get::<String, _>("status"))
1038 .transpose()
1039 .map_err(backend)?;
1040 match status {
1041 None => Ok(DeleteOutcome::NotFound),
1042 Some(s) if !sql::parse_status(&s).is_terminal() => {
1043 Ok(DeleteOutcome::StillRunning)
1044 }
1045 Some(_) => {
1046 sqlx::query(&self.stmts.delete)
1047 .bind(id)
1048 .execute(&self.pool)
1049 .await
1050 .map_err(backend)?;
1051 sqlx::query(&self.stmts.delete_idem_by_run)
1057 .bind(id)
1058 .execute(&self.pool)
1059 .await
1060 .map_err(backend)?;
1061 sqlx::query(&self.stmts.delete_shards_by_run)
1065 .bind(id)
1066 .execute(&self.pool)
1067 .await
1068 .map_err(backend)?;
1069 Ok(DeleteOutcome::Deleted)
1070 }
1071 }
1072 }
1073
1074 async fn release_idempotency(
1075 &self,
1076 run_id: &str,
1077 ) -> Result<(), $crate::serve::history::HistoryError> {
1078 use $crate::serve::history::HistoryError;
1079 sqlx::query(&self.stmts.delete_idem_by_run)
1080 .bind(run_id)
1081 .execute(&self.pool)
1082 .await
1083 .map_err(|e| HistoryError::Backend(e.to_string()))?;
1084 Ok(())
1085 }
1086
1087 async fn purge_expired(
1088 &self,
1089 retain_for: std::time::Duration,
1090 ) -> Result<usize, $crate::serve::history::HistoryError> {
1091 use $crate::serve::history::HistoryError;
1092 use $crate::serve::history::sql;
1093 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1094 let now = chrono::Utc::now();
1095 let removed = sqlx::query(&self.stmts.purge_runs)
1096 .bind(sql::threshold(now, retain_for))
1097 .execute(&self.pool)
1098 .await
1099 .map_err(backend)?
1100 .rows_affected() as usize;
1101 let _ = sqlx::query(&self.stmts.purge_idem)
1103 .bind(sql::threshold(now, self.idem_retention))
1104 .execute(&self.pool)
1105 .await;
1106 let _ = sqlx::query(&self.stmts.prune_instances)
1110 .bind(sql::threshold(now, retain_for))
1111 .execute(&self.pool)
1112 .await;
1113 let _ = sqlx::query(&self.stmts.purge_orphan_shards)
1117 .execute(&self.pool)
1118 .await;
1119 let _ = sqlx::query(&self.stmts.purge_audit)
1121 .bind(sql::threshold(now, retain_for))
1122 .execute(&self.pool)
1123 .await;
1124 Ok(removed)
1125 }
1126
1127 async fn recover_orphans(&self) -> Result<usize, $crate::serve::history::HistoryError> {
1128 use sqlx::Row as _;
1129 use $crate::serve::history::HistoryError;
1130 use $crate::serve::history::RunStatus;
1131 use $crate::serve::history::sql;
1132 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1133 let now = chrono::Utc::now();
1134 let rows = sqlx::query(&self.stmts.select_orphans)
1139 .bind(sql::fmt_ts(now))
1140 .fetch_all(&self.pool)
1141 .await
1142 .map_err(backend)?;
1143 let mut count = 0usize;
1144 for r in &rows {
1145 let body: String = r.try_get("body").map_err(backend)?;
1146 let mut rec = sql::decode_body(&body)?;
1147 rec.status = RunStatus::Failed;
1148 rec.finished_at = Some(now);
1149 rec.error = Some(
1150 "owning serve instance's lease expired before the run finished".into(),
1151 );
1152 if rec.elapsed_secs.is_none()
1153 && let Some(started) = rec.started_at
1154 {
1155 rec.elapsed_secs = (now - started).to_std().ok().map(|d| d.as_secs_f64());
1156 }
1157 self.upsert(&rec).await?;
1158 count += 1;
1159 }
1160 Ok(count)
1161 }
1162
1163 async fn renew_leases(&self) -> Result<usize, $crate::serve::history::HistoryError> {
1164 use $crate::serve::history::HistoryError;
1165 use $crate::serve::history::sql;
1166 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1167 let new_lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
1168 let renewed = sqlx::query(&self.stmts.renew_leases)
1169 .bind(&new_lease)
1170 .bind(&self.instance_id)
1171 .execute(&self.pool)
1172 .await
1173 .map_err(backend)?
1174 .rows_affected() as usize;
1175 Ok(renewed)
1176 }
1177
1178 async fn claim_pending(
1179 &self,
1180 limit: usize,
1181 ) -> Result<Vec<$crate::serve::history::RunRecord>, $crate::serve::history::HistoryError>
1182 {
1183 use sqlx::Row as _;
1184 use $crate::serve::history::HistoryError;
1185 use $crate::serve::history::RunStatus;
1186 use $crate::serve::history::sql;
1187 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1188 if limit == 0 {
1189 return Ok(Vec::new());
1190 }
1191 let now = chrono::Utc::now();
1192 let lease = sql::fmt_ts(now + self.lease_ttl);
1193
1194 let rows = sqlx::query(&self.stmts.select_pending)
1196 .bind(limit as i64)
1197 .fetch_all(&self.pool)
1198 .await
1199 .map_err(backend)?;
1200
1201 let mut claimed = Vec::new();
1206 for row in &rows {
1207 let run_id: String = row.try_get("run_id").map_err(backend)?;
1208 let body: String = row.try_get("body").map_err(backend)?;
1209 let mut r = sql::decode_body(&body)?;
1213 r.status = RunStatus::Running;
1214 let new_body = sql::encode_body(&r)?;
1215 let won = sqlx::query(&self.stmts.claim_one)
1217 .bind(&self.instance_id)
1218 .bind(&lease)
1219 .bind(&new_body)
1220 .bind(&run_id)
1221 .execute(&self.pool)
1222 .await
1223 .map_err(backend)?
1224 .rows_affected();
1225 if won == 1 {
1226 claimed.push(r);
1227 }
1228 }
1229 Ok(claimed)
1230 }
1231
1232 async fn reclaim_orphans(
1233 &self,
1234 max_attempts: u32,
1235 ) -> Result<$crate::serve::history::ReclaimReport, $crate::serve::history::HistoryError>
1236 {
1237 use sqlx::Row as _;
1238 use $crate::serve::history::HistoryError;
1239 use $crate::serve::history::ReclaimReport;
1240 use $crate::serve::history::RunStatus;
1241 use $crate::serve::history::sql;
1242 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1243 let now = chrono::Utc::now();
1244 let now_s = sql::fmt_ts(now);
1245
1246 let rows = sqlx::query(&self.stmts.reclaim_select)
1247 .bind(&now_s)
1248 .fetch_all(&self.pool)
1249 .await
1250 .map_err(backend)?;
1251
1252 let mut report = ReclaimReport::default();
1253 for row in &rows {
1254 let body: String = row.try_get("body").map_err(backend)?;
1255 let mut rec = sql::decode_body(&body)?;
1256 let next_attempt = rec.attempt + 1;
1257 if rec.attempt < max_attempts {
1261 rec.attempt = next_attempt;
1263 rec.status = RunStatus::Pending;
1264 let new_body = sql::encode_body(&rec)?;
1265 let n = sqlx::query(&self.stmts.reclaim_requeue)
1266 .bind(&new_body)
1267 .bind(&rec.run_id)
1268 .bind(&now_s)
1269 .execute(&self.pool)
1270 .await
1271 .map_err(backend)?
1272 .rows_affected();
1273 if n == 1 {
1274 report.requeued += 1;
1275 }
1276 } else {
1277 rec.attempt = next_attempt;
1279 rec.status = RunStatus::Failed;
1280 rec.finished_at = Some(now);
1281 rec.error = Some(format!(
1282 "run reclaimed {next_attempt} times after its owning instance's \
1283 lease expired; giving up (poison run)"
1284 ));
1285 if rec.elapsed_secs.is_none()
1286 && let Some(started) = rec.started_at
1287 {
1288 rec.elapsed_secs =
1289 (now - started).to_std().ok().map(|d| d.as_secs_f64());
1290 }
1291 let new_body = sql::encode_body(&rec)?;
1292 let n = sqlx::query(&self.stmts.reclaim_fail)
1293 .bind(&now_s)
1294 .bind(&new_body)
1295 .bind(&rec.run_id)
1296 .bind(&now_s)
1297 .execute(&self.pool)
1298 .await
1299 .map_err(backend)?
1300 .rows_affected();
1301 if n == 1 {
1302 report.failed += 1;
1303 }
1304 }
1305 }
1306 Ok(report)
1307 }
1308
1309 async fn finalize_owned(
1310 &self,
1311 rec: &$crate::serve::history::RunRecord,
1312 ) -> Result<bool, $crate::serve::history::HistoryError> {
1313 use $crate::serve::history::HistoryError;
1314 use $crate::serve::history::sql;
1315 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1316 let mut rec = rec.clone();
1320 if rec.status.is_terminal() && rec.finished_at.is_none() {
1321 rec.finished_at = Some(chrono::Utc::now());
1322 }
1323 let body = sql::encode_body(&rec)?;
1324 let finished = rec.finished_at.map(sql::fmt_ts);
1325 let lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
1326 let n = sqlx::query(&self.stmts.finalize_owned)
1327 .bind(rec.status.as_str())
1328 .bind(finished.as_deref())
1329 .bind(&lease)
1330 .bind(&body)
1331 .bind(&rec.run_id)
1332 .bind(&self.instance_id)
1333 .execute(&self.pool)
1334 .await
1335 .map_err(backend)?
1336 .rows_affected();
1337 Ok(n == 1)
1338 }
1339
1340 async fn finalize_sharded_parent(
1341 &self,
1342 run_id: &str,
1343 status: $crate::serve::history::RunStatus,
1344 finished_at: chrono::DateTime<chrono::Utc>,
1345 error: Option<String>,
1346 ) -> Result<bool, $crate::serve::history::HistoryError> {
1347 use sqlx::Row as _;
1348 use $crate::serve::history::HistoryError;
1349 use $crate::serve::history::RunStatus;
1350 use $crate::serve::history::sql;
1351 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1352 let Some(row) = sqlx::query(&self.stmts.select_body)
1357 .bind(run_id)
1358 .fetch_optional(&self.pool)
1359 .await
1360 .map_err(backend)?
1361 else {
1362 return Ok(false);
1363 };
1364 let body: String = row.try_get("body").map_err(backend)?;
1365 let mut rec = sql::decode_body(&body)?;
1366 if rec.status != RunStatus::Sharded {
1367 return Ok(false);
1368 }
1369 rec.status = status;
1370 rec.finished_at = Some(finished_at);
1371 rec.error = error;
1372 let new_body = sql::encode_body(&rec)?;
1373 let n = sqlx::query(&self.stmts.finalize_sharded_parent)
1374 .bind(status.as_str())
1375 .bind(sql::fmt_ts(finished_at))
1376 .bind(&new_body)
1377 .bind(run_id)
1378 .execute(&self.pool)
1379 .await
1380 .map_err(backend)?
1381 .rows_affected();
1382 Ok(n == 1)
1383 }
1384
1385 async fn cancel_pending(
1386 &self,
1387 run_id: &str,
1388 ) -> Result<bool, $crate::serve::history::HistoryError> {
1389 use sqlx::Row as _;
1390 use $crate::serve::history::HistoryError;
1391 use $crate::serve::history::RunStatus;
1392 use $crate::serve::history::sql;
1393 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1394 let Some(row) = sqlx::query(&self.stmts.select_body)
1397 .bind(run_id)
1398 .fetch_optional(&self.pool)
1399 .await
1400 .map_err(backend)?
1401 else {
1402 return Ok(false);
1403 };
1404 let body: String = row.try_get("body").map_err(backend)?;
1405 let mut rec = sql::decode_body(&body)?;
1406 if rec.status != RunStatus::Pending {
1407 return Ok(false);
1408 }
1409 let now = chrono::Utc::now();
1410 rec.status = RunStatus::Cancelled;
1411 rec.finished_at = Some(now);
1412 let new_body = sql::encode_body(&rec)?;
1413 let n = sqlx::query(&self.stmts.cancel_pending)
1414 .bind(sql::fmt_ts(now))
1415 .bind(&new_body)
1416 .bind(run_id)
1417 .execute(&self.pool)
1418 .await
1419 .map_err(backend)?
1420 .rows_affected();
1421 Ok(n == 1)
1422 }
1423
1424 async fn request_cancel(
1425 &self,
1426 run_id: &str,
1427 ) -> Result<(), $crate::serve::history::HistoryError> {
1428 use $crate::serve::history::HistoryError;
1429 use $crate::serve::history::sql;
1430 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1431 sqlx::query(&self.stmts.request_cancel)
1432 .bind(sql::fmt_ts(chrono::Utc::now()))
1433 .bind(run_id)
1434 .execute(&self.pool)
1435 .await
1436 .map_err(backend)?;
1437 Ok(())
1438 }
1439
1440 async fn pending_cancellations(
1441 &self,
1442 ) -> Result<Vec<String>, $crate::serve::history::HistoryError> {
1443 use sqlx::Row as _;
1444 use $crate::serve::history::HistoryError;
1445 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1446 let rows = sqlx::query(&self.stmts.pending_cancellations)
1447 .bind(&self.instance_id)
1448 .fetch_all(&self.pool)
1449 .await
1450 .map_err(backend)?;
1451 let mut ids = Vec::with_capacity(rows.len());
1452 for r in &rows {
1453 ids.push(r.try_get::<String, _>("run_id").map_err(backend)?);
1454 }
1455 Ok(ids)
1456 }
1457
1458 async fn heartbeat_instance(
1459 &self,
1460 beat: &$crate::serve::history::InstanceHeartbeat,
1461 ) -> Result<(), $crate::serve::history::HistoryError> {
1462 use $crate::serve::history::HistoryError;
1463 use $crate::serve::history::sql;
1464 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1465 let now = sql::fmt_ts(chrono::Utc::now());
1466 sqlx::query(&self.stmts.heartbeat_instance)
1467 .bind(&self.instance_id)
1468 .bind(sql::fmt_ts(beat.started_at))
1469 .bind(&now)
1470 .bind(beat.listen.as_deref())
1471 .bind(beat.max_concurrent.to_string())
1472 .bind(beat.in_flight.to_string())
1473 .execute(&self.pool)
1474 .await
1475 .map_err(backend)?;
1476 Ok(())
1477 }
1478
1479 async fn live_instances(
1480 &self,
1481 ttl: std::time::Duration,
1482 ) -> Result<Vec<$crate::serve::history::InstanceRecord>, $crate::serve::history::HistoryError>
1483 {
1484 use sqlx::Row as _;
1485 use $crate::serve::history::HistoryError;
1486 use $crate::serve::history::InstanceRecord;
1487 use $crate::serve::history::sql;
1488 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1489 let now = chrono::Utc::now();
1490 let rows = sqlx::query(&self.stmts.live_instances)
1491 .bind(sql::threshold(now, ttl))
1492 .fetch_all(&self.pool)
1493 .await
1494 .map_err(backend)?;
1495 let parse_dt = |s: &str| {
1496 chrono::DateTime::parse_from_rfc3339(s)
1497 .map(|d| d.to_utc())
1498 .unwrap_or(now)
1499 };
1500 let mut out = Vec::with_capacity(rows.len());
1501 for r in &rows {
1502 let started: String = r.try_get("started_at").map_err(backend)?;
1503 let hb: String = r.try_get("last_heartbeat").map_err(backend)?;
1504 let mc: Option<String> = r.try_get("max_concurrent").map_err(backend)?;
1505 let inf: Option<String> = r.try_get("in_flight").map_err(backend)?;
1506 out.push(InstanceRecord {
1507 instance_id: r.try_get("instance_id").map_err(backend)?,
1508 started_at: parse_dt(&started),
1509 last_heartbeat: parse_dt(&hb),
1510 listen: r.try_get("listen").map_err(backend)?,
1511 max_concurrent: mc.and_then(|s| s.parse().ok()).unwrap_or(0),
1512 in_flight: inf.and_then(|s| s.parse().ok()).unwrap_or(0),
1513 });
1514 }
1515 Ok(out)
1516 }
1517
1518 async fn insert_shards(
1521 &self,
1522 run_id: &str,
1523 shards: &[$crate::serve::history::ShardInsert],
1524 ) -> Result<usize, $crate::serve::history::HistoryError> {
1525 use $crate::serve::history::HistoryError;
1526 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1527 let mut inserted = 0usize;
1528 for s in shards {
1529 let descriptor = serde_json::to_string(&s.descriptor).map_err(|e| {
1530 HistoryError::Backend(format!("encode shard descriptor: {e}"))
1531 })?;
1532 let size = s.size_estimate.map(|n| n.to_string());
1533 let n = sqlx::query(&self.stmts.insert_shard)
1534 .bind(run_id)
1535 .bind(&s.shard_id)
1536 .bind(&descriptor)
1537 .bind(size.as_deref())
1538 .execute(&self.pool)
1539 .await
1540 .map_err(backend)?
1541 .rows_affected();
1542 inserted += n as usize;
1543 }
1544 Ok(inserted)
1545 }
1546
1547 async fn claim_shards(
1548 &self,
1549 limit: usize,
1550 ) -> Result<
1551 Vec<$crate::serve::history::ClaimedShard>,
1552 $crate::serve::history::HistoryError,
1553 > {
1554 use sqlx::Row as _;
1555 use $crate::serve::history::ClaimedShard;
1556 use $crate::serve::history::HistoryError;
1557 use $crate::serve::history::sql;
1558 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1559 if limit == 0 {
1560 return Ok(Vec::new());
1561 }
1562 let lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
1563
1564 let rows = sqlx::query(&self.stmts.claim_shards_select)
1567 .bind(limit as i64)
1568 .fetch_all(&self.pool)
1569 .await
1570 .map_err(backend)?;
1571
1572 let mut claimed = Vec::new();
1574 for row in &rows {
1575 let run_id: String = row.try_get("run_id").map_err(backend)?;
1576 let shard_id: String = row.try_get("shard_id").map_err(backend)?;
1577 let descriptor_s: String = row.try_get("descriptor").map_err(backend)?;
1578 let body: String = row.try_get("body").map_err(backend)?;
1579 let won = sqlx::query(&self.stmts.claim_shard_one)
1580 .bind(&self.instance_id)
1581 .bind(&lease)
1582 .bind(&run_id)
1583 .bind(&shard_id)
1584 .execute(&self.pool)
1585 .await
1586 .map_err(backend)?
1587 .rows_affected();
1588 if won == 1 {
1589 let descriptor: serde_json::Value = serde_json::from_str(&descriptor_s)
1590 .map_err(|e| {
1591 HistoryError::Backend(format!("decode shard descriptor: {e}"))
1592 })?;
1593 let run = sql::decode_body(&body)?;
1594 claimed.push(ClaimedShard {
1595 run_id,
1596 shard_id,
1597 descriptor,
1598 run,
1599 });
1600 }
1601 }
1602 Ok(claimed)
1603 }
1604
1605 async fn renew_shard_leases(
1606 &self,
1607 ) -> Result<usize, $crate::serve::history::HistoryError> {
1608 use $crate::serve::history::HistoryError;
1609 use $crate::serve::history::sql;
1610 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1611 let lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
1612 let n = sqlx::query(&self.stmts.renew_shard_leases)
1613 .bind(&lease)
1614 .bind(&self.instance_id)
1615 .execute(&self.pool)
1616 .await
1617 .map_err(backend)?
1618 .rows_affected() as usize;
1619 Ok(n)
1620 }
1621
1622 async fn reclaim_shards(
1623 &self,
1624 max_attempts: u32,
1625 ) -> Result<$crate::serve::history::ReclaimReport, $crate::serve::history::HistoryError>
1626 {
1627 use sqlx::Row as _;
1628 use $crate::serve::history::HistoryError;
1629 use $crate::serve::history::ReclaimReport;
1630 use $crate::serve::history::sql;
1631 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1632 let now_s = sql::fmt_ts(chrono::Utc::now());
1633
1634 let rows = sqlx::query(&self.stmts.reclaim_shards_select)
1635 .bind(&now_s)
1636 .fetch_all(&self.pool)
1637 .await
1638 .map_err(backend)?;
1639
1640 let mut report = ReclaimReport::default();
1641 for row in &rows {
1642 let run_id: String = row.try_get("run_id").map_err(backend)?;
1643 let shard_id: String = row.try_get("shard_id").map_err(backend)?;
1644 let attempt_s: String = row.try_get("attempt").map_err(backend)?;
1645 let attempt: u32 = attempt_s.parse().unwrap_or(0);
1646 if attempt < max_attempts {
1647 let next = (attempt + 1).to_string();
1648 let n = sqlx::query(&self.stmts.reclaim_shard_requeue)
1649 .bind(&next)
1650 .bind(&run_id)
1651 .bind(&shard_id)
1652 .bind(&now_s)
1653 .execute(&self.pool)
1654 .await
1655 .map_err(backend)?
1656 .rows_affected();
1657 if n == 1 {
1658 report.requeued += 1;
1659 }
1660 } else {
1661 let n = sqlx::query(&self.stmts.reclaim_shard_fail)
1662 .bind(&now_s)
1663 .bind(&run_id)
1664 .bind(&shard_id)
1665 .bind(&now_s)
1666 .execute(&self.pool)
1667 .await
1668 .map_err(backend)?
1669 .rows_affected();
1670 if n == 1 {
1671 report.failed += 1;
1672 }
1673 }
1674 }
1675 Ok(report)
1676 }
1677
1678 async fn finalize_shard(
1679 &self,
1680 run_id: &str,
1681 shard_id: &str,
1682 success: bool,
1683 ) -> Result<bool, $crate::serve::history::HistoryError> {
1684 use $crate::serve::history::HistoryError;
1685 use $crate::serve::history::sql;
1686 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1687 let status = if success { "completed" } else { "failed" };
1688 let now_s = sql::fmt_ts(chrono::Utc::now());
1689 let n = sqlx::query(&self.stmts.finalize_shard)
1690 .bind(status)
1691 .bind(&now_s)
1692 .bind(run_id)
1693 .bind(shard_id)
1694 .bind(&self.instance_id)
1695 .execute(&self.pool)
1696 .await
1697 .map_err(backend)?
1698 .rows_affected();
1699 Ok(n == 1)
1700 }
1701
1702 async fn shard_progress(
1703 &self,
1704 run_id: &str,
1705 ) -> Result<$crate::serve::history::ShardProgress, $crate::serve::history::HistoryError>
1706 {
1707 use sqlx::Row as _;
1708 use $crate::serve::history::HistoryError;
1709 use $crate::serve::history::ShardProgress;
1710 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1711 let rows = sqlx::query(&self.stmts.shard_progress)
1712 .bind(run_id)
1713 .fetch_all(&self.pool)
1714 .await
1715 .map_err(backend)?;
1716 let mut p = ShardProgress::default();
1717 for row in &rows {
1718 let status: String = row.try_get("status").map_err(backend)?;
1719 let n: i64 = row.try_get("n").map_err(backend)?;
1720 let n = n.max(0) as usize;
1721 p.total += n;
1722 match status.as_str() {
1723 "completed" => p.completed += n,
1724 "failed" => p.failed += n,
1725 "running" => p.running += n,
1726 _ => p.pending += n,
1727 }
1728 }
1729 Ok(p)
1730 }
1731
1732 async fn pending_shard_cancellations(
1733 &self,
1734 ) -> Result<Vec<String>, $crate::serve::history::HistoryError> {
1735 use sqlx::Row as _;
1736 use $crate::serve::history::HistoryError;
1737 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1738 let rows = sqlx::query(&self.stmts.pending_shard_cancellations)
1739 .bind(&self.instance_id)
1740 .fetch_all(&self.pool)
1741 .await
1742 .map_err(backend)?;
1743 let mut ids = Vec::with_capacity(rows.len());
1744 for r in &rows {
1745 ids.push(r.try_get::<String, _>("run_id").map_err(backend)?);
1746 }
1747 Ok(ids)
1748 }
1749
1750 async fn finalize_completed_sharded_parents(
1751 &self,
1752 ) -> Result<usize, $crate::serve::history::HistoryError> {
1753 use sqlx::Row as _;
1754 use $crate::serve::history::HistoryError;
1755 use $crate::serve::history::RunStatus;
1756 use $crate::serve::history::sql;
1757 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1758
1759 let rows = sqlx::query(&self.stmts.select_sharded_parents)
1763 .fetch_all(&self.pool)
1764 .await
1765 .map_err(backend)?;
1766
1767 let mut finalized = 0usize;
1768 for row in &rows {
1769 let run_id: String = row.try_get("run_id").map_err(backend)?;
1770 let progress = self.shard_progress(&run_id).await?;
1771 if !progress.all_terminal() {
1772 continue;
1773 }
1774 let success = progress.failed == 0;
1775 let Some(body_row) = sqlx::query(&self.stmts.select_body)
1778 .bind(&run_id)
1779 .fetch_optional(&self.pool)
1780 .await
1781 .map_err(backend)?
1782 else {
1783 continue;
1784 };
1785 let body: String = body_row.try_get("body").map_err(backend)?;
1786 let mut rec = sql::decode_body(&body)?;
1787 if rec.status != RunStatus::Sharded {
1790 continue;
1791 }
1792 let now = chrono::Utc::now();
1793 rec.status = if success {
1794 RunStatus::Completed
1795 } else {
1796 RunStatus::Failed
1797 };
1798 rec.finished_at = Some(now);
1799 if !success {
1800 rec.error = Some(format!(
1801 "{}/{} shard(s) failed",
1802 progress.failed, progress.total
1803 ));
1804 }
1805 let new_body = sql::encode_body(&rec)?;
1806 let n = sqlx::query(&self.stmts.finalize_sharded_parent)
1807 .bind(rec.status.as_str())
1808 .bind(sql::fmt_ts(now))
1809 .bind(&new_body)
1810 .bind(&run_id)
1811 .execute(&self.pool)
1812 .await
1813 .map_err(backend)?
1814 .rows_affected();
1815 if n == 1 {
1816 finalized += 1;
1817 $crate::serve::metrics::record_run_finished(
1818 rec.status,
1819 if success { "ok" } else { "error" },
1820 );
1821 tracing::info!(
1822 run_id,
1823 shards = progress.total,
1824 failed = progress.failed,
1825 "sharded run finalized by sweep (F11)"
1826 );
1827 }
1828 }
1829 Ok(finalized)
1830 }
1831
1832 async fn record_audit(
1835 &self,
1836 entry: &$crate::serve::history::AuditEntry,
1837 ) -> Result<(), $crate::serve::history::HistoryError> {
1838 use $crate::serve::history::HistoryError;
1839 use $crate::serve::history::sql;
1840 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1841 sqlx::query(&self.stmts.insert_audit)
1842 .bind(&entry.id)
1843 .bind(sql::fmt_ts(entry.timestamp))
1844 .bind(&entry.principal)
1845 .bind(&entry.role)
1846 .bind(&entry.action)
1847 .bind(entry.run_id.as_deref())
1848 .bind(entry.config_fingerprint.as_deref())
1849 .bind(entry.source_ip.as_deref())
1850 .bind(&entry.result)
1851 .execute(&self.pool)
1852 .await
1853 .map_err(backend)?;
1854 Ok(())
1855 }
1856
1857 async fn list_audit(
1858 &self,
1859 filter: &$crate::serve::history::AuditFilter,
1860 ) -> Result<
1861 Vec<$crate::serve::history::AuditEntry>,
1862 $crate::serve::history::HistoryError,
1863 > {
1864 use sqlx::Row as _;
1865 use $crate::serve::history::AuditEntry;
1866 use $crate::serve::history::HistoryError;
1867 use $crate::serve::history::sql;
1868 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1869 let principal = filter.principal.as_deref();
1870 let action = filter.action.as_deref();
1871 let since = filter.since.map(sql::fmt_ts);
1872 let until = filter.until.map(sql::fmt_ts);
1873 let limit = filter.limit.max(1) as i64;
1874 let rows = sqlx::query(&self.stmts.list_audit)
1875 .bind(principal)
1876 .bind(principal)
1877 .bind(action)
1878 .bind(action)
1879 .bind(since.as_deref())
1880 .bind(since.as_deref())
1881 .bind(until.as_deref())
1882 .bind(until.as_deref())
1883 .bind(limit)
1884 .fetch_all(&self.pool)
1885 .await
1886 .map_err(backend)?;
1887 let mut out = Vec::with_capacity(rows.len());
1888 for r in &rows {
1889 let ts: String = r.try_get("ts").map_err(backend)?;
1890 let timestamp = chrono::DateTime::parse_from_rfc3339(&ts)
1891 .map(|d| d.to_utc())
1892 .unwrap_or_else(|_| chrono::Utc::now());
1893 out.push(AuditEntry {
1894 id: r.try_get("id").map_err(backend)?,
1895 timestamp,
1896 principal: r.try_get("principal").map_err(backend)?,
1897 role: r.try_get("role").map_err(backend)?,
1898 action: r.try_get("action").map_err(backend)?,
1899 run_id: r.try_get("run_id").map_err(backend)?,
1900 config_fingerprint: r.try_get("config_fingerprint").map_err(backend)?,
1901 source_ip: r.try_get("source_ip").map_err(backend)?,
1902 result: r.try_get("result").map_err(backend)?,
1903 });
1904 }
1905 Ok(out)
1906 }
1907
1908 async fn catalog_record(
1911 &self,
1912 update: &$crate::serve::history::catalog::CatalogUpdate,
1913 ) -> Result<(), $crate::serve::history::HistoryError> {
1914 use sqlx::Row as _;
1915 use $crate::serve::history::HistoryError;
1916 use $crate::serve::history::catalog;
1917 use $crate::serve::history::sql;
1918 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1919 let now_s = sql::fmt_ts(update.recorded_at);
1920
1921 for obs in [&update.source, &update.sink] {
1922 let id = catalog::dataset_id(&obs.uri);
1923 let existing = sqlx::query(&self.stmts.catalog_select_dataset)
1927 .bind(&id)
1928 .fetch_optional(&self.pool)
1929 .await
1930 .map_err(backend)?
1931 .map(|r| r.try_get::<String, _>("body"))
1932 .transpose()
1933 .map_err(backend)?
1934 .map(|b| {
1935 sql::decode_json::<catalog::CatalogDataset>(&b, "catalog dataset")
1936 })
1937 .transpose()?;
1938 let (ds, new_version) = catalog::apply_observation(
1939 existing.as_ref(),
1940 obs,
1941 &update.run_id,
1942 &update.pipeline,
1943 &update.row,
1944 update.recorded_at,
1945 );
1946 sqlx::query(&self.stmts.catalog_upsert_dataset)
1947 .bind(&ds.id)
1948 .bind(&ds.uri)
1949 .bind(&ds.kind)
1950 .bind(&now_s)
1951 .bind(sql::encode_json(&ds, "catalog dataset")?)
1952 .execute(&self.pool)
1953 .await
1954 .map_err(backend)?;
1955 if let Some(v) = new_version {
1956 sqlx::query(&self.stmts.catalog_insert_schema_version)
1957 .bind(&v.dataset_id)
1958 .bind(v.version.to_string())
1959 .bind(sql::fmt_ts(v.recorded_at))
1960 .bind(sql::encode_json(&v, "catalog schema version")?)
1961 .execute(&self.pool)
1962 .await
1963 .map_err(backend)?;
1964 }
1965 sqlx::query(&self.stmts.catalog_insert_stat)
1966 .bind(&id)
1967 .bind(&now_s)
1968 .bind(&update.run_id)
1969 .bind(obs.records.to_string())
1970 .execute(&self.pool)
1971 .await
1972 .map_err(backend)?;
1973 sqlx::query(&self.stmts.catalog_prune_stats)
1974 .bind(&id)
1975 .bind(&id)
1976 .bind(catalog::STATS_RETAIN as i64)
1977 .execute(&self.pool)
1978 .await
1979 .map_err(backend)?;
1980 }
1981
1982 let src_id = catalog::dataset_id(&update.source.uri);
1983 let dst_id = catalog::dataset_id(&update.sink.uri);
1984 let existing_edges = self.catalog_all_edges().await?;
1985 let existing = existing_edges
1986 .iter()
1987 .find(|e| e.src_id == src_id && e.dst_id == dst_id);
1988 let edge = catalog::apply_edge(existing, update);
1989 sqlx::query(&self.stmts.catalog_upsert_edge)
1990 .bind(&edge.src_id)
1991 .bind(&edge.dst_id)
1992 .bind(&now_s)
1993 .bind(sql::encode_json(&edge, "catalog edge")?)
1994 .execute(&self.pool)
1995 .await
1996 .map_err(backend)?;
1997 Ok(())
1998 }
1999
2000 async fn catalog_list_datasets(
2001 &self,
2002 filter: &$crate::serve::history::catalog::CatalogListFilter,
2003 ) -> Result<
2004 $crate::serve::history::catalog::CatalogDatasetPage,
2005 $crate::serve::history::HistoryError,
2006 > {
2007 use sqlx::Row as _;
2008 use $crate::serve::history::HistoryError;
2009 use $crate::serve::history::catalog;
2010 use $crate::serve::history::sql;
2011 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
2012 let rows = sqlx::query(&self.stmts.catalog_select_datasets)
2013 .fetch_all(&self.pool)
2014 .await
2015 .map_err(backend)?;
2016 let mut all = Vec::with_capacity(rows.len());
2017 for r in &rows {
2018 let body: String = r.try_get("body").map_err(backend)?;
2019 all.push(sql::decode_json(&body, "catalog dataset")?);
2020 }
2021 Ok(catalog::filter_datasets(all, filter))
2022 }
2023
2024 async fn catalog_get_dataset(
2025 &self,
2026 id: &str,
2027 ) -> Result<
2028 Option<$crate::serve::history::catalog::CatalogDatasetDetail>,
2029 $crate::serve::history::HistoryError,
2030 > {
2031 use sqlx::Row as _;
2032 use $crate::serve::history::HistoryError;
2033 use $crate::serve::history::catalog;
2034 use $crate::serve::history::sql;
2035 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
2036 let Some(row) = sqlx::query(&self.stmts.catalog_select_dataset)
2037 .bind(id)
2038 .fetch_optional(&self.pool)
2039 .await
2040 .map_err(backend)?
2041 else {
2042 return Ok(None);
2043 };
2044 let body: String = row.try_get("body").map_err(backend)?;
2045 let dataset: catalog::CatalogDataset =
2046 sql::decode_json(&body, "catalog dataset")?;
2047
2048 let rows = sqlx::query(&self.stmts.catalog_select_schema_versions)
2049 .bind(id)
2050 .fetch_all(&self.pool)
2051 .await
2052 .map_err(backend)?;
2053 let mut schema_timeline = Vec::with_capacity(rows.len());
2054 for r in &rows {
2055 let body: String = r.try_get("body").map_err(backend)?;
2056 schema_timeline.push(sql::decode_json(&body, "catalog schema version")?);
2057 }
2058
2059 let rows = sqlx::query(&self.stmts.catalog_select_stats)
2060 .bind(id)
2061 .bind(catalog::STATS_DETAIL_LIMIT as i64)
2062 .fetch_all(&self.pool)
2063 .await
2064 .map_err(backend)?;
2065 let mut stats = Vec::with_capacity(rows.len());
2066 for r in &rows {
2067 let recorded: String = r.try_get("recorded_at").map_err(backend)?;
2068 let run_id: String = r.try_get("run_id").map_err(backend)?;
2069 let records: String = r.try_get("records").map_err(backend)?;
2070 stats.push(catalog::CatalogStatsPoint {
2071 recorded_at: chrono::DateTime::parse_from_rfc3339(&recorded)
2072 .map(|d| d.to_utc())
2073 .unwrap_or_else(|_| chrono::Utc::now()),
2074 run_id,
2075 records: records.parse().unwrap_or(0),
2076 });
2077 }
2078
2079 let edges = self.catalog_all_edges().await?;
2080 let (downstream, rest): (Vec<_>, Vec<_>) =
2081 edges.into_iter().partition(|e| e.src_id == id);
2082 let upstream = rest.into_iter().filter(|e| e.dst_id == id).collect();
2083 Ok(Some(catalog::CatalogDatasetDetail {
2084 dataset,
2085 schema_timeline,
2086 stats,
2087 upstream,
2088 downstream,
2089 }))
2090 }
2091
2092 async fn catalog_lineage(
2093 &self,
2094 root: Option<&str>,
2095 depth: u32,
2096 ) -> Result<
2097 Vec<$crate::serve::history::catalog::CatalogLineageEdge>,
2098 $crate::serve::history::HistoryError,
2099 > {
2100 use $crate::serve::history::catalog;
2101 let edges = self.catalog_all_edges().await?;
2102 Ok(catalog::lineage_slice(edges, root, depth))
2103 }
2104
2105 async fn catalog_record_config_snapshot(
2106 &self,
2107 snapshot: &$crate::serve::history::catalog::ConfigSnapshot,
2108 ) -> Result<(), $crate::serve::history::HistoryError> {
2109 use $crate::serve::history::HistoryError;
2110 use $crate::serve::history::sql;
2111 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
2112 sqlx::query(&self.stmts.catalog_upsert_config_snapshot)
2113 .bind(&snapshot.pipeline)
2114 .bind(sql::fmt_ts(snapshot.recorded_at))
2115 .bind(&snapshot.faucet_version)
2116 .bind(sql::encode_json(snapshot, "config snapshot")?)
2117 .execute(&self.pool)
2118 .await
2119 .map_err(backend)?;
2120 Ok(())
2121 }
2122
2123 async fn catalog_last_config_snapshot(
2124 &self,
2125 pipeline: &str,
2126 ) -> Result<
2127 Option<$crate::serve::history::catalog::ConfigSnapshot>,
2128 $crate::serve::history::HistoryError,
2129 > {
2130 use sqlx::Row as _;
2131 use $crate::serve::history::HistoryError;
2132 use $crate::serve::history::sql;
2133 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
2134 let Some(row) = sqlx::query(&self.stmts.catalog_select_config_snapshot)
2135 .bind(pipeline)
2136 .fetch_optional(&self.pool)
2137 .await
2138 .map_err(backend)?
2139 else {
2140 return Ok(None);
2141 };
2142 let body: String = row.try_get("body").map_err(backend)?;
2143 Ok(Some(sql::decode_json(&body, "config snapshot")?))
2144 }
2145
2146 fn degraded(&self) -> bool {
2147 false
2150 }
2151 }
2152
2153 impl $name {
2154 async fn catalog_all_edges(
2156 &self,
2157 ) -> Result<
2158 Vec<$crate::serve::history::catalog::CatalogLineageEdge>,
2159 $crate::serve::history::HistoryError,
2160 > {
2161 use sqlx::Row as _;
2162 use $crate::serve::history::HistoryError;
2163 use $crate::serve::history::sql;
2164 let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
2165 let rows = sqlx::query(&self.stmts.catalog_select_edges)
2166 .fetch_all(&self.pool)
2167 .await
2168 .map_err(backend)?;
2169 let mut edges = Vec::with_capacity(rows.len());
2170 for r in &rows {
2171 let body: String = r.try_get("body").map_err(backend)?;
2172 edges.push(sql::decode_json(&body, "catalog edge")?);
2173 }
2174 Ok(edges)
2175 }
2176 }
2177 };
2178}
2179
2180pub(crate) use impl_sql_history;
2181
2182#[cfg(test)]
2183mod tests {
2184 use super::*;
2185
2186 #[test]
2187 fn postgres_shard_statements_are_built() {
2188 let s = Stmts::new(Dialect::Postgres);
2191 assert!(s.insert_shard.contains("faucet_serve_shards"));
2192 assert!(s.insert_shard.contains("ON CONFLICT"));
2193 assert!(s.claim_shards_select.contains("JOIN faucet_serve_runs"));
2194 assert!(s.claim_shard_one.contains("'running'"));
2195 assert!(s.renew_shard_leases.contains("lease_expires_at"));
2196 assert!(s.reclaim_shards_select.contains("'running'"));
2197 assert!(s.reclaim_shard_requeue.contains("'pending'"));
2198 assert!(s.reclaim_shard_fail.contains("'failed'"));
2199 assert!(s.finalize_shard.contains("owner"));
2200 assert!(s.shard_progress.contains("GROUP BY"));
2201 }
2202
2203 #[test]
2204 fn fmt_ts_is_fixed_width_and_sortable() {
2205 let a = fmt_ts(
2206 DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
2207 .unwrap()
2208 .to_utc(),
2209 );
2210 let b = fmt_ts(
2211 DateTime::parse_from_rfc3339("2026-01-01T00:00:01Z")
2212 .unwrap()
2213 .to_utc(),
2214 );
2215 assert!(a.ends_with('Z'));
2216 assert_eq!(a.len(), b.len(), "fixed width");
2217 assert!(a < b, "lexicographic order matches chronological order");
2218 }
2219
2220 #[test]
2221 fn is_expired_respects_window() {
2222 let now = Utc::now();
2223 let old = fmt_ts(now - chrono::Duration::seconds(120));
2224 assert!(is_expired(&old, now, Duration::from_secs(60)));
2225 assert!(!is_expired(&old, now, Duration::from_secs(600)));
2226 assert!(!is_expired("not-a-timestamp", now, Duration::ZERO));
2228 }
2229
2230 #[test]
2231 fn parse_status_round_trips_known_and_defaults_failed() {
2232 for s in [
2233 RunStatus::Queued,
2234 RunStatus::Pending,
2235 RunStatus::Running,
2236 RunStatus::Completed,
2237 RunStatus::Failed,
2238 RunStatus::Cancelled,
2239 ] {
2240 assert_eq!(parse_status(s.as_str()), s);
2241 }
2242 assert_eq!(parse_status("garbage"), RunStatus::Failed);
2243 }
2244
2245 #[test]
2246 fn body_round_trips() {
2247 let rec = RunRecord::queued(
2248 "r1".into(),
2249 Some("n".into()),
2250 Default::default(),
2251 Some("idem".into()),
2252 Utc::now(),
2253 );
2254 let encoded = encode_body(&rec).unwrap();
2255 let decoded = decode_body(&encoded).unwrap();
2256 assert_eq!(decoded.run_id, "r1");
2257 assert_eq!(decoded.idempotency_key.as_deref(), Some("idem"));
2258 }
2259
2260 #[test]
2261 fn postgres_and_sqlite_statements_differ_only_in_placeholders() {
2262 let pg = Stmts::new(Dialect::Postgres);
2263 let lite = Stmts::new(Dialect::Sqlite);
2264 assert!(pg.upsert.contains("$1") && lite.upsert.contains('?'));
2265 assert!(pg.list.contains("$13") && lite.list.contains('?'));
2266 assert!(pg.insert_idem.contains("ON CONFLICT (key) DO NOTHING"));
2268 assert!(lite.insert_idem.contains("ON CONFLICT (key) DO NOTHING"));
2269 assert!(pg.claim_one.contains("$3") && lite.claim_one.contains('?'));
2270 assert!(pg.heartbeat_instance.contains("faucet_serve_instances"));
2271 assert!(lite.heartbeat_instance.contains("faucet_serve_instances"));
2272 }
2273}