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