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