Skip to main content

faucet_cli/serve/history/
sql.rs

1//! Shared SQL run-history machinery for the Postgres and SQLite backends
2//! (Phase 5, #127). Both backends are identical except for the connection setup
3//! and the placeholder dialect (`$n` vs `?`), so the schema, prepared-statement
4//! text, pure helpers, and the entire `RunHistory` impl live here once and are
5//! instantiated for each concrete `sqlx` pool via the `impl_sql_history!` macro.
6//!
7//! **Portability:** every column is `TEXT` (timestamps are stored as fixed-width
8//! RFC3339 with nanosecond precision + `Z`, which sorts lexicographically in
9//! chronological order, so keyset pagination and expiry comparisons work without
10//! any database date type — and thus without the `sqlx` `chrono` feature). The
11//! whole `RunRecord` is serialized into the `body` column (the source of truth on
12//! read); the dedicated columns exist only for filtering, ordering, and expiry.
13//!
14//! **Idempotency** lives in a separate `faucet_serve_idem` table whose `key`
15//! primary key is the required unique index (spec §10/§11). The claim is atomic
16//! via `INSERT … ON CONFLICT DO NOTHING` plus an optimistic, expiry-guarded
17//! takeover `UPDATE`, mirroring the memory backend's shard-locked semantics.
18
19use super::{HistoryError, RunRecord, RunStatus};
20use chrono::{DateTime, Utc};
21use std::time::Duration;
22
23/// DDL run at connect time. Valid verbatim on both Postgres and SQLite (only
24/// `TEXT` columns, `IF NOT EXISTS`, and standard indexes).
25pub const DDL: &[&str] = &[
26    // `owner` is the id of the serve instance that owns the run; `lease_expires_at`
27    // is the RFC3339 instant past which that ownership is presumed dead. Together
28    // they fence orphan recovery: an instance only fails a non-terminal run whose
29    // lease has expired, never another live instance's heartbeated runs (#146 H7).
30    "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    // Speeds the per-tick orphan scan / lease renewal, which filter on
44    // (status, owner, lease_expires_at).
45    "CREATE INDEX IF NOT EXISTS faucet_serve_runs_status_lease_idx \
46        ON faucet_serve_runs (status, lease_expires_at)",
47    // Speeds the cluster dispatcher's pending-run query (ordered by submitted_at).
48    "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    // Source shards for clustered Mode B (#230). One row per (run, shard);
65    // `owner`/`lease_expires_at`/`attempt` reuse Mode A's lease-fencing semantics
66    // at shard granularity. `size_estimate` (an integer stored as TEXT) drives
67    // skew-aware, largest-first claiming. `descriptor` is the opaque connector
68    // shard spec, replayed to the worker that claims the shard.
69    "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    // Audit log for RBAC (#205). One row per mutating (or denied) control-plane
83    // action. `id` is a time-ordered UUIDv7; `ts` is fixed-width RFC3339 so the
84    // newest-first ordering and retention purge sort lexicographically.
85    "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    // Data Movement Catalog (#279). Accumulating cross-run state, deliberately
98    // NOT covered by `purge_expired` (the history is the value). Same
99    // TEXT-columns + JSON `body` convention as the run tables: the dedicated
100    // columns exist for filtering only; `body` is the source of truth on read.
101    "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    // One row per (dataset, schema version); appended only on content change.
108    // `version` is an integer stored as TEXT (cast on ORDER BY), matching the
109    // shard table's `size_estimate` convention.
110    "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    // One row per (source dataset, sink dataset) lineage edge.
117    "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    // Per-run volume points, capped per dataset at `catalog::STATS_RETAIN`.
124    "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    // Resolved+expanded config snapshots for `faucet plan --diff` (#374). One
131    // row per pipeline (latest-wins upsert); `body` is the redacted
132    // `ConfigSnapshot` JSON — no secret material is ever stored.
133    "CREATE TABLE IF NOT EXISTS faucet_config_snapshots (\
134        pipeline TEXT PRIMARY KEY,\
135        recorded_at TEXT NOT NULL,\
136        faucet_version TEXT NOT NULL,\
137        body TEXT NOT NULL)",
138];
139
140/// SQL placeholder dialect.
141#[derive(Clone, Copy, Debug)]
142pub enum Dialect {
143    Postgres,
144    Sqlite,
145}
146
147/// Prepared-statement text for a backend, built once per dialect at connect time.
148pub struct Stmts {
149    /// (`cancel_requested` is intentionally NOT written by `upsert` — it is set
150    /// only via `request_cancel` and cleared by `reclaim_requeue`; it defaults to
151    /// NULL on insert.)
152    pub upsert: String,
153    pub select_body: String,
154    pub select_status: String,
155    pub select_submitted: String,
156    pub delete: String,
157    pub list: String,
158    pub purge_runs: String,
159    pub purge_idem: String,
160    /// Select non-terminal runs whose owning instance's lease has expired (or
161    /// is unset) — the orphans this instance may safely fail. Param: `now`.
162    pub select_orphans: String,
163    /// Extend the lease of this instance's own non-terminal runs (heartbeat).
164    /// Params: `new_lease_expiry`, `instance_id`.
165    pub renew_leases: String,
166    pub insert_idem: String,
167    pub select_idem: String,
168    pub takeover_idem: String,
169    /// Delete the idempotency claim(s) that point at a given run — used when a
170    /// run is deleted so a replay of the key starts fresh rather than 404-ing
171    /// on the missing record (#146 M8). Scoped by `run_id`, so a newer run that
172    /// re-claimed the same key keeps its claim.
173    pub delete_idem_by_run: String,
174    /// Cluster dispatcher: fetch oldest pending runs up to a given limit.
175    pub select_pending: String,
176    /// Cluster dispatcher: atomically claim a pending run (set owner + running).
177    pub claim_one: String,
178    /// Cluster reclaimer: select expired running runs for requeue/fail evaluation.
179    /// NOTE: `'queued'` is the single-instance status; cluster runs flow
180    /// `pending → running`, so the failover reclaimer covers `'running'` only.
181    pub reclaim_select: String,
182    /// Cluster reclaimer: requeue an expired running run back to pending.
183    pub reclaim_requeue: String,
184    /// Cluster reclaimer: fail an expired running run that cannot be requeued.
185    pub reclaim_fail: String,
186    /// Finalize a run owned by this instance (terminal status update).
187    pub finalize_owned: String,
188    /// Cancel a pending run directly (transition pending → cancelled).
189    pub cancel_pending: String,
190    /// Request cancellation of an in-flight run owned by another instance.
191    pub request_cancel: String,
192    /// List run IDs owned by this instance that have a pending cancellation request.
193    pub pending_cancellations: String,
194    /// Upsert this instance's membership heartbeat into `faucet_serve_instances`.
195    pub heartbeat_instance: String,
196    /// List instances whose last heartbeat is at or after a given threshold.
197    pub live_instances: String,
198    /// Prune instances whose last heartbeat is before a given threshold.
199    pub prune_instances: String,
200    // ── Source shards (Mode B, #230) ─────────────────────────────────────────
201    /// Idempotent shard insert (`ON CONFLICT (run_id, shard_id) DO NOTHING`).
202    pub insert_shard: String,
203    /// Select claimable pending shards joined to their run body, largest first.
204    pub claim_shards_select: String,
205    /// Atomically claim one pending shard for this instance.
206    pub claim_shard_one: String,
207    /// Heartbeat this instance's running shards.
208    pub renew_shard_leases: String,
209    /// Select expired-lease running shards for requeue/fail evaluation.
210    pub reclaim_shards_select: String,
211    /// Requeue an expired running shard back to pending (attempt++).
212    pub reclaim_shard_requeue: String,
213    /// Fail an expired running shard that exhausted its attempts (poison).
214    pub reclaim_shard_fail: String,
215    /// Owner-fenced terminal write for one shard.
216    pub finalize_shard: String,
217    /// Status counts for a run's shards.
218    pub shard_progress: String,
219    /// Distinct run_ids for which THIS instance owns a `running` shard whose
220    /// parent run has a pending cancellation request (cross-instance shard
221    /// cancel, F10). Param: `instance_id`.
222    pub pending_shard_cancellations: String,
223    /// Select run_ids of `sharded` parents (candidates to finalize once all
224    /// their shards are terminal, F11).
225    pub select_sharded_parents: String,
226    /// Status-fenced terminal write for a `sharded` parent (F11). A benign
227    /// double-finalize across instances is a no-op: the guard requires the
228    /// parent to still be `sharded`. Does NOT re-arm owner/lease.
229    pub finalize_sharded_parent: String,
230    /// Delete a run's shard rows (paired with [`delete`](Self::delete) so a
231    /// deleted run leaves no orphaned shard rows behind, F25). Param: `run_id`.
232    pub delete_shards_by_run: String,
233    /// Purge shard rows whose parent run no longer exists (run-record purged by
234    /// retention, F25). No params — a set-difference against `faucet_serve_runs`.
235    pub purge_orphan_shards: String,
236    // ── Audit log (RBAC, #205) ───────────────────────────────────────────────
237    /// Append one audit record.
238    pub insert_audit: String,
239    /// Newest-first audit records matching the (nullable) filters. Param order:
240    /// principal, action, since, until, limit.
241    pub list_audit: String,
242    /// Purge audit records older than a threshold (retention).
243    pub purge_audit: String,
244    // ── Data Movement Catalog (#279) ─────────────────────────────────────────
245    /// One dataset body by id (the merge read + the detail head).
246    pub catalog_select_dataset: String,
247    /// Upsert one dataset row (filter columns + body). Params: id, uri, kind,
248    /// last_seen, body.
249    pub catalog_upsert_dataset: String,
250    /// Every dataset body — filtering/ordering happens in shared pure code
251    /// ([`catalog::filter_datasets`](super::catalog::filter_datasets)), so the
252    /// memory and SQL backends can never disagree on semantics.
253    pub catalog_select_datasets: String,
254    /// Append one schema-timeline entry; `ON CONFLICT DO NOTHING` so a cluster
255    /// replay of the same (dataset, version) is idempotent.
256    pub catalog_insert_schema_version: String,
257    /// A dataset's schema timeline, oldest first.
258    pub catalog_select_schema_versions: String,
259    /// Upsert one lineage edge. Params: src_id, dst_id, last_seen, body.
260    pub catalog_upsert_edge: String,
261    /// Every edge body, newest activity first.
262    pub catalog_select_edges: String,
263    /// Append one volume point. Params: dataset_id, recorded_at, run_id, records.
264    pub catalog_insert_stat: String,
265    /// A dataset's most recent volume points. Params: dataset_id, limit.
266    pub catalog_select_stats: String,
267    /// Drop volume points beyond the newest `STATS_RETAIN` for one dataset.
268    /// Params: dataset_id, dataset_id, keep-limit.
269    pub catalog_prune_stats: String,
270    /// Upsert the latest config snapshot for a pipeline (#374).
271    /// Params: pipeline, recorded_at, faucet_version, body.
272    pub catalog_upsert_config_snapshot: String,
273    /// The latest config snapshot body for a pipeline. Param: pipeline.
274    pub catalog_select_config_snapshot: String,
275}
276
277impl Stmts {
278    pub fn new(dialect: Dialect) -> Self {
279        match dialect {
280            Dialect::Postgres => Self::postgres(),
281            Dialect::Sqlite => Self::sqlite(),
282        }
283    }
284
285    fn postgres() -> Self {
286        Self {
287            upsert: "INSERT INTO faucet_serve_runs \
288                (run_id,name,status,submitted_at,finished_at,idempotency_key,owner,lease_expires_at,body) \
289                VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) \
290                ON CONFLICT (run_id) DO UPDATE SET \
291                name=excluded.name,status=excluded.status,submitted_at=excluded.submitted_at,\
292                finished_at=excluded.finished_at,idempotency_key=excluded.idempotency_key,\
293                owner=excluded.owner,lease_expires_at=excluded.lease_expires_at,\
294                body=excluded.body"
295                .into(),
296            select_body: "SELECT body FROM faucet_serve_runs WHERE run_id=$1".into(),
297            select_status: "SELECT status FROM faucet_serve_runs WHERE run_id=$1".into(),
298            select_submitted: "SELECT submitted_at FROM faucet_serve_runs WHERE run_id=$1".into(),
299            delete: "DELETE FROM faucet_serve_runs WHERE run_id=$1".into(),
300            // Casts make the parameter types explicit so `$n IS NULL` cannot trip
301            // Postgres' "could not determine data type of parameter" check.
302            list: "SELECT body FROM faucet_serve_runs \
303                WHERE ($1::text IS NULL OR status = $2::text) \
304                AND ($3::text IS NULL OR name = $4::text) \
305                AND ($5::text IS NULL OR submitted_at >= $6::text) \
306                AND ($7::text IS NULL OR submitted_at <= $8::text) \
307                AND ($9::text IS NULL OR (submitted_at < $10::text \
308                    OR (submitted_at = $11::text AND run_id < $12::text))) \
309                ORDER BY submitted_at DESC, run_id DESC LIMIT $13"
310                .into(),
311            purge_runs: "DELETE FROM faucet_serve_runs \
312                WHERE status IN ('completed','failed','cancelled') \
313                AND finished_at IS NOT NULL AND finished_at < $1"
314                .into(),
315            purge_idem: "DELETE FROM faucet_serve_idem WHERE claimed_at < $1".into(),
316            select_orphans: "SELECT body FROM faucet_serve_runs \
317                WHERE status IN ('queued','running') \
318                AND (lease_expires_at IS NULL OR lease_expires_at < $1)"
319                .into(),
320            renew_leases: "UPDATE faucet_serve_runs SET lease_expires_at = $1 \
321                WHERE owner = $2 AND status IN ('queued','running')"
322                .into(),
323            insert_idem: "INSERT INTO faucet_serve_idem (key,run_id,fingerprint,claimed_at) \
324                VALUES ($1,$2,$3,$4) ON CONFLICT (key) DO NOTHING"
325                .into(),
326            select_idem: "SELECT run_id,fingerprint,claimed_at FROM faucet_serve_idem WHERE key=$1"
327                .into(),
328            takeover_idem: "UPDATE faucet_serve_idem \
329                SET run_id=$1,fingerprint=$2,claimed_at=$3 WHERE key=$4 AND claimed_at=$5"
330                .into(),
331            delete_idem_by_run: "DELETE FROM faucet_serve_idem WHERE run_id=$1".into(),
332            select_pending: "SELECT run_id, body FROM faucet_serve_runs \
333                WHERE status = 'pending' ORDER BY submitted_at ASC LIMIT $1"
334                .into(),
335            claim_one: "UPDATE faucet_serve_runs \
336                SET owner = $1, status = 'running', lease_expires_at = $2, body = $3 \
337                WHERE run_id = $4 AND status = 'pending'"
338                .into(),
339            reclaim_select: "SELECT body FROM faucet_serve_runs \
340                WHERE status = 'running' \
341                AND (lease_expires_at IS NULL OR lease_expires_at < $1)"
342                .into(),
343            // Preserve `cancel_requested` across a requeue (audit #321 M7): a
344            // cross-instance cancel acknowledged while the owner was partitioned
345            // must survive re-queueing so the next owner still honours it, rather
346            // than the run silently running to completion despite the cancel.
347            reclaim_requeue: "UPDATE faucet_serve_runs \
348                SET status = 'pending', owner = NULL, lease_expires_at = NULL, \
349                    body = $1 \
350                WHERE run_id = $2 AND status = 'running' \
351                AND (lease_expires_at IS NULL OR lease_expires_at < $3)"
352                .into(),
353            reclaim_fail: "UPDATE faucet_serve_runs \
354                SET status = 'failed', finished_at = $1, body = $2, owner = NULL \
355                WHERE run_id = $3 AND status = 'running' \
356                AND (lease_expires_at IS NULL OR lease_expires_at < $4)"
357                .into(),
358            // Status-fenced (audit #321 L5): only finalize a still-non-terminal
359            // record, so a stale zombie execution that shares the same owner (a
360            // lease-lapse re-claim by the same instance) can never overwrite the
361            // terminal record written by the live execution. First finalizer wins.
362            finalize_owned: "UPDATE faucet_serve_runs \
363                SET status = $1, finished_at = $2, lease_expires_at = $3, body = $4 \
364                WHERE run_id = $5 AND owner = $6 \
365                AND status NOT IN ('completed','failed','cancelled')"
366                .into(),
367            cancel_pending: "UPDATE faucet_serve_runs \
368                SET status = 'cancelled', finished_at = $1, body = $2 \
369                WHERE run_id = $3 AND status = 'pending'"
370                .into(),
371            request_cancel: "UPDATE faucet_serve_runs \
372                SET cancel_requested = $1 WHERE run_id = $2 AND status IN ('running','sharded')"
373                .into(),
374            pending_cancellations: "SELECT run_id FROM faucet_serve_runs \
375                WHERE status = 'running' AND owner = $1 AND cancel_requested IS NOT NULL"
376                .into(),
377            heartbeat_instance: "INSERT INTO faucet_serve_instances \
378                (instance_id, started_at, last_heartbeat, listen, max_concurrent, in_flight) \
379                VALUES ($1,$2,$3,$4,$5,$6) \
380                ON CONFLICT (instance_id) DO UPDATE SET \
381                last_heartbeat = excluded.last_heartbeat, listen = excluded.listen, \
382                max_concurrent = excluded.max_concurrent, in_flight = excluded.in_flight"
383                .into(),
384            live_instances: "SELECT instance_id, started_at, last_heartbeat, listen, \
385                max_concurrent, in_flight FROM faucet_serve_instances \
386                WHERE last_heartbeat >= $1"
387                .into(),
388            prune_instances: "DELETE FROM faucet_serve_instances WHERE last_heartbeat < $1".into(),
389            insert_shard: "INSERT INTO faucet_serve_shards \
390                (run_id, shard_id, descriptor, size_estimate, status, attempt) \
391                VALUES ($1,$2,$3,$4,'pending','0') \
392                ON CONFLICT (run_id, shard_id) DO NOTHING"
393                .into(),
394            claim_shards_select: "SELECT s.run_id, s.shard_id, s.descriptor, r.body \
395                FROM faucet_serve_shards s JOIN faucet_serve_runs r ON r.run_id = s.run_id \
396                WHERE s.status = 'pending' \
397                ORDER BY CAST(COALESCE(s.size_estimate, '0') AS BIGINT) DESC, s.run_id, s.shard_id \
398                LIMIT $1"
399                .into(),
400            claim_shard_one: "UPDATE faucet_serve_shards \
401                SET owner = $1, status = 'running', lease_expires_at = $2 \
402                WHERE run_id = $3 AND shard_id = $4 AND status = 'pending'"
403                .into(),
404            renew_shard_leases: "UPDATE faucet_serve_shards SET lease_expires_at = $1 \
405                WHERE owner = $2 AND status = 'running'"
406                .into(),
407            reclaim_shards_select: "SELECT run_id, shard_id, attempt FROM faucet_serve_shards \
408                WHERE status = 'running' \
409                AND (lease_expires_at IS NULL OR lease_expires_at < $1)"
410                .into(),
411            reclaim_shard_requeue: "UPDATE faucet_serve_shards \
412                SET status = 'pending', owner = NULL, lease_expires_at = NULL, attempt = $1 \
413                WHERE run_id = $2 AND shard_id = $3 AND status = 'running' \
414                AND (lease_expires_at IS NULL OR lease_expires_at < $4)"
415                .into(),
416            reclaim_shard_fail: "UPDATE faucet_serve_shards \
417                SET status = 'failed', finished_at = $1, owner = NULL \
418                WHERE run_id = $2 AND shard_id = $3 AND status = 'running' \
419                AND (lease_expires_at IS NULL OR lease_expires_at < $4)"
420                .into(),
421            finalize_shard: "UPDATE faucet_serve_shards \
422                SET status = $1, finished_at = $2 \
423                WHERE run_id = $3 AND shard_id = $4 AND owner = $5 AND status = 'running'"
424                .into(),
425            shard_progress: "SELECT status, COUNT(*) AS n FROM faucet_serve_shards \
426                WHERE run_id = $1 GROUP BY status"
427                .into(),
428            pending_shard_cancellations: "SELECT DISTINCT s.run_id \
429                FROM faucet_serve_shards s \
430                JOIN faucet_serve_runs r ON r.run_id = s.run_id \
431                WHERE s.owner = $1 AND s.status = 'running' \
432                AND r.cancel_requested IS NOT NULL"
433                .into(),
434            select_sharded_parents: "SELECT run_id FROM faucet_serve_runs \
435                WHERE status = 'sharded'"
436                .into(),
437            finalize_sharded_parent: "UPDATE faucet_serve_runs \
438                SET status = $1, finished_at = $2, body = $3 \
439                WHERE run_id = $4 AND status = 'sharded'"
440                .into(),
441            delete_shards_by_run: "DELETE FROM faucet_serve_shards WHERE run_id = $1".into(),
442            purge_orphan_shards: "DELETE FROM faucet_serve_shards \
443                WHERE run_id NOT IN (SELECT run_id FROM faucet_serve_runs)"
444                .into(),
445            insert_audit: "INSERT INTO faucet_serve_audit \
446                (id, ts, principal, role, action, run_id, config_fingerprint, source_ip, result) \
447                VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)"
448                .into(),
449            list_audit: "SELECT id, ts, principal, role, action, run_id, config_fingerprint, \
450                source_ip, result FROM faucet_serve_audit \
451                WHERE ($1::text IS NULL OR principal = $2::text) \
452                AND ($3::text IS NULL OR action = $4::text) \
453                AND ($5::text IS NULL OR ts >= $6::text) \
454                AND ($7::text IS NULL OR ts <= $8::text) \
455                ORDER BY ts DESC, id DESC LIMIT $9"
456                .into(),
457            purge_audit: "DELETE FROM faucet_serve_audit WHERE ts < $1".into(),
458            catalog_select_dataset: "SELECT body FROM faucet_catalog_datasets WHERE id=$1".into(),
459            catalog_upsert_dataset: "INSERT INTO faucet_catalog_datasets \
460                (id, uri, kind, last_seen, body) VALUES ($1,$2,$3,$4,$5) \
461                ON CONFLICT (id) DO UPDATE SET uri=excluded.uri, kind=excluded.kind, \
462                last_seen=excluded.last_seen, body=excluded.body"
463                .into(),
464            catalog_select_datasets: "SELECT body FROM faucet_catalog_datasets".into(),
465            catalog_insert_schema_version: "INSERT INTO faucet_catalog_schema_versions \
466                (dataset_id, version, recorded_at, body) VALUES ($1,$2,$3,$4) \
467                ON CONFLICT (dataset_id, version) DO NOTHING"
468                .into(),
469            catalog_select_schema_versions: "SELECT body FROM faucet_catalog_schema_versions \
470                WHERE dataset_id=$1 ORDER BY CAST(version AS BIGINT) ASC"
471                .into(),
472            catalog_upsert_edge: "INSERT INTO faucet_catalog_edges \
473                (src_id, dst_id, last_seen, body) VALUES ($1,$2,$3,$4) \
474                ON CONFLICT (src_id, dst_id) DO UPDATE SET \
475                last_seen=excluded.last_seen, body=excluded.body"
476                .into(),
477            catalog_select_edges: "SELECT body FROM faucet_catalog_edges \
478                ORDER BY last_seen DESC, src_id, dst_id"
479                .into(),
480            catalog_insert_stat: "INSERT INTO faucet_catalog_stats \
481                (dataset_id, recorded_at, run_id, records) VALUES ($1,$2,$3,$4) \
482                ON CONFLICT (dataset_id, recorded_at) DO NOTHING"
483                .into(),
484            catalog_select_stats: "SELECT recorded_at, run_id, records \
485                FROM faucet_catalog_stats WHERE dataset_id=$1 \
486                ORDER BY recorded_at DESC LIMIT $2"
487                .into(),
488            catalog_prune_stats: "DELETE FROM faucet_catalog_stats \
489                WHERE dataset_id=$1 AND recorded_at NOT IN (\
490                    SELECT recorded_at FROM faucet_catalog_stats WHERE dataset_id=$2 \
491                    ORDER BY recorded_at DESC LIMIT $3)"
492                .into(),
493            catalog_upsert_config_snapshot: "INSERT INTO faucet_config_snapshots \
494                (pipeline, recorded_at, faucet_version, body) VALUES ($1,$2,$3,$4) \
495                ON CONFLICT (pipeline) DO UPDATE SET recorded_at=excluded.recorded_at, \
496                faucet_version=excluded.faucet_version, body=excluded.body"
497                .into(),
498            catalog_select_config_snapshot:
499                "SELECT body FROM faucet_config_snapshots WHERE pipeline=$1".into(),
500        }
501    }
502
503    fn sqlite() -> Self {
504        Self {
505            upsert: "INSERT INTO faucet_serve_runs \
506                (run_id,name,status,submitted_at,finished_at,idempotency_key,owner,lease_expires_at,body) \
507                VALUES (?,?,?,?,?,?,?,?,?) \
508                ON CONFLICT (run_id) DO UPDATE SET \
509                name=excluded.name,status=excluded.status,submitted_at=excluded.submitted_at,\
510                finished_at=excluded.finished_at,idempotency_key=excluded.idempotency_key,\
511                owner=excluded.owner,lease_expires_at=excluded.lease_expires_at,\
512                body=excluded.body"
513                .into(),
514            select_body: "SELECT body FROM faucet_serve_runs WHERE run_id=?".into(),
515            select_status: "SELECT status FROM faucet_serve_runs WHERE run_id=?".into(),
516            select_submitted: "SELECT submitted_at FROM faucet_serve_runs WHERE run_id=?".into(),
517            delete: "DELETE FROM faucet_serve_runs WHERE run_id=?".into(),
518            list: "SELECT body FROM faucet_serve_runs \
519                WHERE (? IS NULL OR status = ?) \
520                AND (? IS NULL OR name = ?) \
521                AND (? IS NULL OR submitted_at >= ?) \
522                AND (? IS NULL OR submitted_at <= ?) \
523                AND (? IS NULL OR (submitted_at < ? \
524                    OR (submitted_at = ? AND run_id < ?))) \
525                ORDER BY submitted_at DESC, run_id DESC LIMIT ?"
526                .into(),
527            purge_runs: "DELETE FROM faucet_serve_runs \
528                WHERE status IN ('completed','failed','cancelled') \
529                AND finished_at IS NOT NULL AND finished_at < ?"
530                .into(),
531            purge_idem: "DELETE FROM faucet_serve_idem WHERE claimed_at < ?".into(),
532            select_orphans: "SELECT body FROM faucet_serve_runs \
533                WHERE status IN ('queued','running') \
534                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
535                .into(),
536            renew_leases: "UPDATE faucet_serve_runs SET lease_expires_at = ? \
537                WHERE owner = ? AND status IN ('queued','running')"
538                .into(),
539            insert_idem: "INSERT INTO faucet_serve_idem (key,run_id,fingerprint,claimed_at) \
540                VALUES (?,?,?,?) ON CONFLICT (key) DO NOTHING"
541                .into(),
542            select_idem: "SELECT run_id,fingerprint,claimed_at FROM faucet_serve_idem WHERE key=?"
543                .into(),
544            takeover_idem: "UPDATE faucet_serve_idem \
545                SET run_id=?,fingerprint=?,claimed_at=? WHERE key=? AND claimed_at=?"
546                .into(),
547            delete_idem_by_run: "DELETE FROM faucet_serve_idem WHERE run_id=?".into(),
548            select_pending: "SELECT run_id, body FROM faucet_serve_runs \
549                WHERE status = 'pending' ORDER BY submitted_at ASC LIMIT ?"
550                .into(),
551            claim_one: "UPDATE faucet_serve_runs \
552                SET owner = ?, status = 'running', lease_expires_at = ?, body = ? \
553                WHERE run_id = ? AND status = 'pending'"
554                .into(),
555            reclaim_select: "SELECT body FROM faucet_serve_runs \
556                WHERE status = 'running' \
557                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
558                .into(),
559            // Preserve `cancel_requested` across a requeue (audit #321 M7).
560            reclaim_requeue: "UPDATE faucet_serve_runs \
561                SET status = 'pending', owner = NULL, lease_expires_at = NULL, \
562                    body = ? \
563                WHERE run_id = ? AND status = 'running' \
564                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
565                .into(),
566            reclaim_fail: "UPDATE faucet_serve_runs \
567                SET status = 'failed', finished_at = ?, body = ?, owner = NULL \
568                WHERE run_id = ? AND status = 'running' \
569                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
570                .into(),
571            // Status-fenced (audit #321 L5): first finalizer wins.
572            finalize_owned: "UPDATE faucet_serve_runs \
573                SET status = ?, finished_at = ?, lease_expires_at = ?, body = ? \
574                WHERE run_id = ? AND owner = ? \
575                AND status NOT IN ('completed','failed','cancelled')"
576                .into(),
577            cancel_pending: "UPDATE faucet_serve_runs \
578                SET status = 'cancelled', finished_at = ?, body = ? \
579                WHERE run_id = ? AND status = 'pending'"
580                .into(),
581            request_cancel: "UPDATE faucet_serve_runs \
582                SET cancel_requested = ? WHERE run_id = ? AND status IN ('running','sharded')"
583                .into(),
584            pending_cancellations: "SELECT run_id FROM faucet_serve_runs \
585                WHERE status = 'running' AND owner = ? AND cancel_requested IS NOT NULL"
586                .into(),
587            heartbeat_instance: "INSERT INTO faucet_serve_instances \
588                (instance_id, started_at, last_heartbeat, listen, max_concurrent, in_flight) \
589                VALUES (?,?,?,?,?,?) \
590                ON CONFLICT (instance_id) DO UPDATE SET \
591                last_heartbeat = excluded.last_heartbeat, listen = excluded.listen, \
592                max_concurrent = excluded.max_concurrent, in_flight = excluded.in_flight"
593                .into(),
594            live_instances: "SELECT instance_id, started_at, last_heartbeat, listen, \
595                max_concurrent, in_flight FROM faucet_serve_instances \
596                WHERE last_heartbeat >= ?"
597                .into(),
598            prune_instances: "DELETE FROM faucet_serve_instances WHERE last_heartbeat < ?".into(),
599            insert_shard: "INSERT INTO faucet_serve_shards \
600                (run_id, shard_id, descriptor, size_estimate, status, attempt) \
601                VALUES (?,?,?,?,'pending','0') \
602                ON CONFLICT (run_id, shard_id) DO NOTHING"
603                .into(),
604            claim_shards_select: "SELECT s.run_id, s.shard_id, s.descriptor, r.body \
605                FROM faucet_serve_shards s JOIN faucet_serve_runs r ON r.run_id = s.run_id \
606                WHERE s.status = 'pending' \
607                ORDER BY CAST(COALESCE(s.size_estimate, '0') AS INTEGER) DESC, s.run_id, s.shard_id \
608                LIMIT ?"
609                .into(),
610            claim_shard_one: "UPDATE faucet_serve_shards \
611                SET owner = ?, status = 'running', lease_expires_at = ? \
612                WHERE run_id = ? AND shard_id = ? AND status = 'pending'"
613                .into(),
614            renew_shard_leases: "UPDATE faucet_serve_shards SET lease_expires_at = ? \
615                WHERE owner = ? AND status = 'running'"
616                .into(),
617            reclaim_shards_select: "SELECT run_id, shard_id, attempt FROM faucet_serve_shards \
618                WHERE status = 'running' \
619                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
620                .into(),
621            reclaim_shard_requeue: "UPDATE faucet_serve_shards \
622                SET status = 'pending', owner = NULL, lease_expires_at = NULL, attempt = ? \
623                WHERE run_id = ? AND shard_id = ? AND status = 'running' \
624                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
625                .into(),
626            reclaim_shard_fail: "UPDATE faucet_serve_shards \
627                SET status = 'failed', finished_at = ?, owner = NULL \
628                WHERE run_id = ? AND shard_id = ? AND status = 'running' \
629                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
630                .into(),
631            finalize_shard: "UPDATE faucet_serve_shards \
632                SET status = ?, finished_at = ? \
633                WHERE run_id = ? AND shard_id = ? AND owner = ? AND status = 'running'"
634                .into(),
635            shard_progress: "SELECT status, COUNT(*) AS n FROM faucet_serve_shards \
636                WHERE run_id = ? GROUP BY status"
637                .into(),
638            pending_shard_cancellations: "SELECT DISTINCT s.run_id \
639                FROM faucet_serve_shards s \
640                JOIN faucet_serve_runs r ON r.run_id = s.run_id \
641                WHERE s.owner = ? AND s.status = 'running' \
642                AND r.cancel_requested IS NOT NULL"
643                .into(),
644            select_sharded_parents: "SELECT run_id FROM faucet_serve_runs \
645                WHERE status = 'sharded'"
646                .into(),
647            finalize_sharded_parent: "UPDATE faucet_serve_runs \
648                SET status = ?, finished_at = ?, body = ? \
649                WHERE run_id = ? AND status = 'sharded'"
650                .into(),
651            delete_shards_by_run: "DELETE FROM faucet_serve_shards WHERE run_id = ?".into(),
652            purge_orphan_shards: "DELETE FROM faucet_serve_shards \
653                WHERE run_id NOT IN (SELECT run_id FROM faucet_serve_runs)"
654                .into(),
655            insert_audit: "INSERT INTO faucet_serve_audit \
656                (id, ts, principal, role, action, run_id, config_fingerprint, source_ip, result) \
657                VALUES (?,?,?,?,?,?,?,?,?)"
658                .into(),
659            list_audit: "SELECT id, ts, principal, role, action, run_id, config_fingerprint, \
660                source_ip, result FROM faucet_serve_audit \
661                WHERE (? IS NULL OR principal = ?) \
662                AND (? IS NULL OR action = ?) \
663                AND (? IS NULL OR ts >= ?) \
664                AND (? IS NULL OR ts <= ?) \
665                ORDER BY ts DESC, id DESC LIMIT ?"
666                .into(),
667            purge_audit: "DELETE FROM faucet_serve_audit WHERE ts < ?".into(),
668            catalog_select_dataset: "SELECT body FROM faucet_catalog_datasets WHERE id=?".into(),
669            catalog_upsert_dataset: "INSERT INTO faucet_catalog_datasets \
670                (id, uri, kind, last_seen, body) VALUES (?,?,?,?,?) \
671                ON CONFLICT (id) DO UPDATE SET uri=excluded.uri, kind=excluded.kind, \
672                last_seen=excluded.last_seen, body=excluded.body"
673                .into(),
674            catalog_select_datasets: "SELECT body FROM faucet_catalog_datasets".into(),
675            catalog_insert_schema_version: "INSERT INTO faucet_catalog_schema_versions \
676                (dataset_id, version, recorded_at, body) VALUES (?,?,?,?) \
677                ON CONFLICT (dataset_id, version) DO NOTHING"
678                .into(),
679            catalog_select_schema_versions: "SELECT body FROM faucet_catalog_schema_versions \
680                WHERE dataset_id=? ORDER BY CAST(version AS INTEGER) ASC"
681                .into(),
682            catalog_upsert_edge: "INSERT INTO faucet_catalog_edges \
683                (src_id, dst_id, last_seen, body) VALUES (?,?,?,?) \
684                ON CONFLICT (src_id, dst_id) DO UPDATE SET \
685                last_seen=excluded.last_seen, body=excluded.body"
686                .into(),
687            catalog_select_edges: "SELECT body FROM faucet_catalog_edges \
688                ORDER BY last_seen DESC, src_id, dst_id"
689                .into(),
690            catalog_insert_stat: "INSERT INTO faucet_catalog_stats \
691                (dataset_id, recorded_at, run_id, records) VALUES (?,?,?,?) \
692                ON CONFLICT (dataset_id, recorded_at) DO NOTHING"
693                .into(),
694            catalog_select_stats: "SELECT recorded_at, run_id, records \
695                FROM faucet_catalog_stats WHERE dataset_id=? \
696                ORDER BY recorded_at DESC LIMIT ?"
697                .into(),
698            catalog_prune_stats: "DELETE FROM faucet_catalog_stats \
699                WHERE dataset_id=? AND recorded_at NOT IN (\
700                    SELECT recorded_at FROM faucet_catalog_stats WHERE dataset_id=? \
701                    ORDER BY recorded_at DESC LIMIT ?)"
702                .into(),
703            catalog_upsert_config_snapshot: "INSERT INTO faucet_config_snapshots \
704                (pipeline, recorded_at, faucet_version, body) VALUES (?,?,?,?) \
705                ON CONFLICT (pipeline) DO UPDATE SET recorded_at=excluded.recorded_at, \
706                faucet_version=excluded.faucet_version, body=excluded.body"
707                .into(),
708            catalog_select_config_snapshot:
709                "SELECT body FROM faucet_config_snapshots WHERE pipeline=?".into(),
710        }
711    }
712}
713
714/// Bounded retry count for the atomic idempotency claim (handles a claim being
715/// purged concurrently between the insert attempt and the read-back).
716pub const CLAIM_ATTEMPTS: usize = 4;
717
718/// Fixed-width RFC3339 (nanoseconds + `Z`) — lexicographically sortable.
719pub fn fmt_ts(dt: DateTime<Utc>) -> String {
720    dt.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true)
721}
722
723/// True when a claim timestamped `claimed_at` (RFC3339) is older than `window`.
724/// An unparseable or future timestamp is treated as **not** expired (safe: it
725/// won't be silently re-claimed).
726pub fn is_expired(claimed_at: &str, now: DateTime<Utc>, window: Duration) -> bool {
727    match DateTime::parse_from_rfc3339(claimed_at) {
728        Ok(t) => now
729            .signed_duration_since(t.with_timezone(&Utc))
730            .to_std()
731            .map(|age| age >= window)
732            .unwrap_or(false),
733        Err(_) => false,
734    }
735}
736
737/// RFC3339 timestamp `window` before `now` (the purge / expiry threshold).
738pub fn threshold(now: DateTime<Utc>, window: Duration) -> String {
739    let delta =
740        chrono::Duration::from_std(window).unwrap_or_else(|_| chrono::Duration::days(36_500));
741    fmt_ts(now - delta)
742}
743
744pub fn encode_body(rec: &RunRecord) -> Result<String, HistoryError> {
745    serde_json::to_string(rec).map_err(|e| HistoryError::Backend(format!("encode run record: {e}")))
746}
747
748pub fn decode_body(body: &str) -> Result<RunRecord, HistoryError> {
749    serde_json::from_str(body).map_err(|e| HistoryError::Backend(format!("decode run record: {e}")))
750}
751
752/// Generic body (de)serialization for the catalog tables (#279).
753pub fn encode_json<T: serde::Serialize>(value: &T, what: &str) -> Result<String, HistoryError> {
754    serde_json::to_string(value).map_err(|e| HistoryError::Backend(format!("encode {what}: {e}")))
755}
756
757pub fn decode_json<T: serde::de::DeserializeOwned>(
758    body: &str,
759    what: &str,
760) -> Result<T, HistoryError> {
761    serde_json::from_str(body).map_err(|e| HistoryError::Backend(format!("decode {what}: {e}")))
762}
763
764pub fn parse_status(s: &str) -> RunStatus {
765    match s {
766        "queued" => RunStatus::Queued,
767        "pending" => RunStatus::Pending,
768        "running" => RunStatus::Running,
769        "sharded" => RunStatus::Sharded,
770        "completed" => RunStatus::Completed,
771        "cancelled" => RunStatus::Cancelled,
772        _ => RunStatus::Failed,
773    }
774}
775
776/// Generate a concrete `RunHistory` implementation over a specific `sqlx` pool.
777/// `$name` is the backend struct, `$pool` its `sqlx` pool type. The struct holds
778/// the pool, the idempotency retention window, and the dialect's [`Stmts`].
779macro_rules! impl_sql_history {
780    ($name:ident, $pool:ty) => {
781        /// SQL-backed [`RunHistory`](crate::serve::history::RunHistory). See
782        /// [`crate::serve::history::sql`] for the shared schema + semantics.
783        pub struct $name {
784            pool: $pool,
785            idem_retention: std::time::Duration,
786            /// This serve instance's id, stamped as `owner` on every upsert.
787            instance_id: String,
788            /// How far ahead each upsert / heartbeat pushes a run's lease.
789            lease_ttl: std::time::Duration,
790            stmts: $crate::serve::history::sql::Stmts,
791        }
792
793        impl $name {
794            /// Assemble from an already-connected pool (used by `connect`).
795            pub fn from_parts(
796                pool: $pool,
797                idem_retention: std::time::Duration,
798                lease_ttl: std::time::Duration,
799                instance_id: String,
800                stmts: $crate::serve::history::sql::Stmts,
801            ) -> Self {
802                Self {
803                    pool,
804                    idem_retention,
805                    instance_id,
806                    lease_ttl,
807                    stmts,
808                }
809            }
810
811            /// Borrow the underlying pool (tests close it to exercise fallback).
812            pub fn pool(&self) -> &$pool {
813                &self.pool
814            }
815        }
816
817        #[async_trait::async_trait]
818        impl $crate::serve::history::RunHistory for $name {
819            async fn claim_idempotency(
820                &self,
821                key: &str,
822                fingerprint: &str,
823                run_id: &str,
824                window: std::time::Duration,
825            ) -> Result<$crate::serve::history::Claim, $crate::serve::history::HistoryError> {
826                use sqlx::Row as _;
827                use $crate::serve::history::Claim;
828                use $crate::serve::history::HistoryError;
829                use $crate::serve::history::sql;
830
831                let now = chrono::Utc::now();
832                let now_s = sql::fmt_ts(now);
833                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
834
835                for _ in 0..sql::CLAIM_ATTEMPTS {
836                    // 1) Atomic first-claim: the winner inserts exactly one row.
837                    let inserted = sqlx::query(&self.stmts.insert_idem)
838                        .bind(key)
839                        .bind(run_id)
840                        .bind(fingerprint)
841                        .bind(&now_s)
842                        .execute(&self.pool)
843                        .await
844                        .map_err(backend)?
845                        .rows_affected();
846                    if inserted == 1 {
847                        return Ok(Claim::Fresh);
848                    }
849                    // 2) Conflict: inspect the existing claim.
850                    let Some(row) = sqlx::query(&self.stmts.select_idem)
851                        .bind(key)
852                        .fetch_optional(&self.pool)
853                        .await
854                        .map_err(backend)?
855                    else {
856                        // Vanished between the insert and the read — retry.
857                        continue;
858                    };
859                    let existing_run: String = row.try_get("run_id").map_err(backend)?;
860                    let existing_fp: String = row.try_get("fingerprint").map_err(backend)?;
861                    let claimed_at: String = row.try_get("claimed_at").map_err(backend)?;
862
863                    if sql::is_expired(&claimed_at, now, window) {
864                        // 3) Optimistic, expiry-guarded takeover: only the request
865                        // that still sees `claimed_at` succeeds.
866                        let took = sqlx::query(&self.stmts.takeover_idem)
867                            .bind(run_id)
868                            .bind(fingerprint)
869                            .bind(&now_s)
870                            .bind(key)
871                            .bind(&claimed_at)
872                            .execute(&self.pool)
873                            .await
874                            .map_err(backend)?
875                            .rows_affected();
876                        if took == 1 {
877                            return Ok(Claim::Fresh);
878                        }
879                        continue; // lost the race; re-evaluate
880                    }
881                    return Ok(if existing_fp == fingerprint {
882                        Claim::Replay(existing_run)
883                    } else {
884                        Claim::Conflict
885                    });
886                }
887                // Pathological contention only. Conservative: a 409 is safer than
888                // risking a duplicate run.
889                tracing::warn!(
890                    key,
891                    "idempotency claim exhausted retries; reporting conflict"
892                );
893                Ok(Claim::Conflict)
894            }
895
896            async fn upsert(
897                &self,
898                rec: &$crate::serve::history::RunRecord,
899            ) -> Result<(), $crate::serve::history::HistoryError> {
900                use $crate::serve::history::HistoryError;
901                use $crate::serve::history::sql;
902                let body = sql::encode_body(rec)?;
903                let submitted = sql::fmt_ts(rec.submitted_at);
904                let finished = rec.finished_at.map(sql::fmt_ts);
905                // Stamp this instance as the owner and start/renew the lease.
906                // The owner/lease are SQL-column-only (never in the record body),
907                // so the heartbeat can extend a lease without a body read-modify-
908                // write race (#146 H7).
909                let lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
910                sqlx::query(&self.stmts.upsert)
911                    .bind(&rec.run_id)
912                    .bind(rec.name.as_deref())
913                    .bind(rec.status.as_str())
914                    .bind(&submitted)
915                    .bind(finished.as_deref())
916                    .bind(rec.idempotency_key.as_deref())
917                    .bind(&self.instance_id)
918                    .bind(&lease)
919                    .bind(&body)
920                    .execute(&self.pool)
921                    .await
922                    .map_err(|e| HistoryError::Backend(e.to_string()))?;
923                Ok(())
924            }
925
926            async fn get(
927                &self,
928                id: &str,
929            ) -> Result<
930                Option<$crate::serve::history::RunRecord>,
931                $crate::serve::history::HistoryError,
932            > {
933                use sqlx::Row as _;
934                use $crate::serve::history::HistoryError;
935                use $crate::serve::history::sql;
936                let row = sqlx::query(&self.stmts.select_body)
937                    .bind(id)
938                    .fetch_optional(&self.pool)
939                    .await
940                    .map_err(|e| HistoryError::Backend(e.to_string()))?;
941                match row {
942                    None => Ok(None),
943                    Some(r) => {
944                        let body: String = r
945                            .try_get("body")
946                            .map_err(|e| HistoryError::Backend(e.to_string()))?;
947                        Ok(Some(sql::decode_body(&body)?))
948                    }
949                }
950            }
951
952            async fn list(
953                &self,
954                filter: &$crate::serve::history::ListFilter,
955            ) -> Result<$crate::serve::history::ListPage, $crate::serve::history::HistoryError>
956            {
957                use sqlx::Row as _;
958                use $crate::serve::history::HistoryError;
959                use $crate::serve::history::ListPage;
960                use $crate::serve::history::sql;
961                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
962
963                // Resolve the cursor's submitted_at for keyset pagination. An
964                // unknown cursor is ignored (page starts from the top), matching
965                // the memory backend.
966                let cursor_ts: Option<String> = match &filter.cursor {
967                    None => None,
968                    Some(c) => sqlx::query(&self.stmts.select_submitted)
969                        .bind(c)
970                        .fetch_optional(&self.pool)
971                        .await
972                        .map_err(backend)?
973                        .map(|r| r.try_get::<String, _>("submitted_at"))
974                        .transpose()
975                        .map_err(backend)?,
976                };
977                let cur_id = if cursor_ts.is_some() {
978                    filter.cursor.as_deref()
979                } else {
980                    None
981                };
982
983                let status_s = filter.status.map(|s| s.as_str());
984                let name_s = filter.name.as_deref();
985                let since_s = filter.since.map(sql::fmt_ts);
986                let until_s = filter.until.map(sql::fmt_ts);
987                let limit = filter.limit.max(1);
988                let fetch_n = limit as i64 + 1; // +1 to detect a next page
989
990                let rows = sqlx::query(&self.stmts.list)
991                    .bind(status_s)
992                    .bind(status_s)
993                    .bind(name_s)
994                    .bind(name_s)
995                    .bind(since_s.as_deref())
996                    .bind(since_s.as_deref())
997                    .bind(until_s.as_deref())
998                    .bind(until_s.as_deref())
999                    .bind(cursor_ts.as_deref())
1000                    .bind(cursor_ts.as_deref())
1001                    .bind(cursor_ts.as_deref())
1002                    .bind(cur_id)
1003                    .bind(fetch_n)
1004                    .fetch_all(&self.pool)
1005                    .await
1006                    .map_err(backend)?;
1007
1008                let mut runs = Vec::with_capacity(rows.len());
1009                for r in &rows {
1010                    let body: String = r.try_get("body").map_err(backend)?;
1011                    runs.push(sql::decode_body(&body)?);
1012                }
1013                let next_cursor = if runs.len() > limit {
1014                    Some(runs[limit - 1].run_id.clone())
1015                } else {
1016                    None
1017                };
1018                runs.truncate(limit);
1019                Ok(ListPage { runs, next_cursor })
1020            }
1021
1022            async fn delete(
1023                &self,
1024                id: &str,
1025            ) -> Result<$crate::serve::history::DeleteOutcome, $crate::serve::history::HistoryError>
1026            {
1027                use sqlx::Row as _;
1028                use $crate::serve::history::DeleteOutcome;
1029                use $crate::serve::history::HistoryError;
1030                use $crate::serve::history::sql;
1031                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1032                let status: Option<String> = sqlx::query(&self.stmts.select_status)
1033                    .bind(id)
1034                    .fetch_optional(&self.pool)
1035                    .await
1036                    .map_err(backend)?
1037                    .map(|r| r.try_get::<String, _>("status"))
1038                    .transpose()
1039                    .map_err(backend)?;
1040                match status {
1041                    None => Ok(DeleteOutcome::NotFound),
1042                    Some(s) if !sql::parse_status(&s).is_terminal() => {
1043                        Ok(DeleteOutcome::StillRunning)
1044                    }
1045                    Some(_) => {
1046                        sqlx::query(&self.stmts.delete)
1047                            .bind(id)
1048                            .execute(&self.pool)
1049                            .await
1050                            .map_err(backend)?;
1051                        // Drop the run's idempotency claim too, so a replay of
1052                        // the key starts fresh instead of 404-ing on the deleted
1053                        // record until the claim self-expires (#146 M8). Scoped
1054                        // by run_id, so a newer run that re-claimed the same key
1055                        // keeps its claim.
1056                        sqlx::query(&self.stmts.delete_idem_by_run)
1057                            .bind(id)
1058                            .execute(&self.pool)
1059                            .await
1060                            .map_err(backend)?;
1061                        // Drop the run's shard rows too (Mode B, #230), so a
1062                        // deleted run leaves no orphaned shard rows that would
1063                        // otherwise leak unboundedly (F25).
1064                        sqlx::query(&self.stmts.delete_shards_by_run)
1065                            .bind(id)
1066                            .execute(&self.pool)
1067                            .await
1068                            .map_err(backend)?;
1069                        Ok(DeleteOutcome::Deleted)
1070                    }
1071                }
1072            }
1073
1074            async fn release_idempotency(
1075                &self,
1076                run_id: &str,
1077            ) -> Result<(), $crate::serve::history::HistoryError> {
1078                use $crate::serve::history::HistoryError;
1079                sqlx::query(&self.stmts.delete_idem_by_run)
1080                    .bind(run_id)
1081                    .execute(&self.pool)
1082                    .await
1083                    .map_err(|e| HistoryError::Backend(e.to_string()))?;
1084                Ok(())
1085            }
1086
1087            async fn purge_expired(
1088                &self,
1089                retain_for: std::time::Duration,
1090            ) -> Result<usize, $crate::serve::history::HistoryError> {
1091                use $crate::serve::history::HistoryError;
1092                use $crate::serve::history::sql;
1093                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1094                let now = chrono::Utc::now();
1095                let removed = sqlx::query(&self.stmts.purge_runs)
1096                    .bind(sql::threshold(now, retain_for))
1097                    .execute(&self.pool)
1098                    .await
1099                    .map_err(backend)?
1100                    .rows_affected() as usize;
1101                // Drop expired idempotency claims too (best-effort).
1102                let _ = sqlx::query(&self.stmts.purge_idem)
1103                    .bind(sql::threshold(now, self.idem_retention))
1104                    .execute(&self.pool)
1105                    .await;
1106                // Drop membership rows that have not heartbeated within the
1107                // run-retention window (far longer than the lease, so this never
1108                // prunes a live member — that's `live_instances(ttl)`'s job).
1109                let _ = sqlx::query(&self.stmts.prune_instances)
1110                    .bind(sql::threshold(now, retain_for))
1111                    .execute(&self.pool)
1112                    .await;
1113                // Reclaim shard rows whose parent run was just purged (F25):
1114                // `purge_runs` removed the expired terminal records above, so any
1115                // shard row no longer matching a run is orphaned. Best-effort.
1116                let _ = sqlx::query(&self.stmts.purge_orphan_shards)
1117                    .execute(&self.pool)
1118                    .await;
1119                // Drop audit records older than the run-retention window (#205).
1120                let _ = sqlx::query(&self.stmts.purge_audit)
1121                    .bind(sql::threshold(now, retain_for))
1122                    .execute(&self.pool)
1123                    .await;
1124                Ok(removed)
1125            }
1126
1127            async fn recover_orphans(&self) -> Result<usize, $crate::serve::history::HistoryError> {
1128                use sqlx::Row as _;
1129                use $crate::serve::history::HistoryError;
1130                use $crate::serve::history::RunStatus;
1131                use $crate::serve::history::sql;
1132                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1133                let now = chrono::Utc::now();
1134                // Only non-terminal runs whose lease has expired (the owning
1135                // instance is presumed dead). A live instance heartbeats its
1136                // runs' leases into the future, so this never fails another
1137                // healthy instance's in-flight runs (#146 H7).
1138                let rows = sqlx::query(&self.stmts.select_orphans)
1139                    .bind(sql::fmt_ts(now))
1140                    .fetch_all(&self.pool)
1141                    .await
1142                    .map_err(backend)?;
1143                let mut count = 0usize;
1144                for r in &rows {
1145                    let body: String = r.try_get("body").map_err(backend)?;
1146                    let mut rec = sql::decode_body(&body)?;
1147                    rec.status = RunStatus::Failed;
1148                    rec.finished_at = Some(now);
1149                    rec.error = Some(
1150                        "owning serve instance's lease expired before the run finished".into(),
1151                    );
1152                    if rec.elapsed_secs.is_none()
1153                        && let Some(started) = rec.started_at
1154                    {
1155                        rec.elapsed_secs = (now - started).to_std().ok().map(|d| d.as_secs_f64());
1156                    }
1157                    self.upsert(&rec).await?;
1158                    count += 1;
1159                }
1160                Ok(count)
1161            }
1162
1163            async fn renew_leases(&self) -> Result<usize, $crate::serve::history::HistoryError> {
1164                use $crate::serve::history::HistoryError;
1165                use $crate::serve::history::sql;
1166                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1167                let new_lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
1168                let renewed = sqlx::query(&self.stmts.renew_leases)
1169                    .bind(&new_lease)
1170                    .bind(&self.instance_id)
1171                    .execute(&self.pool)
1172                    .await
1173                    .map_err(backend)?
1174                    .rows_affected() as usize;
1175                Ok(renewed)
1176            }
1177
1178            async fn claim_pending(
1179                &self,
1180                limit: usize,
1181            ) -> Result<Vec<$crate::serve::history::RunRecord>, $crate::serve::history::HistoryError>
1182            {
1183                use sqlx::Row as _;
1184                use $crate::serve::history::HistoryError;
1185                use $crate::serve::history::RunStatus;
1186                use $crate::serve::history::sql;
1187                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1188                if limit == 0 {
1189                    return Ok(Vec::new());
1190                }
1191                let now = chrono::Utc::now();
1192                let lease = sql::fmt_ts(now + self.lease_ttl);
1193
1194                // 1. Candidate pending runs (oldest first), with their bodies.
1195                let rows = sqlx::query(&self.stmts.select_pending)
1196                    .bind(limit as i64)
1197                    .fetch_all(&self.pool)
1198                    .await
1199                    .map_err(backend)?;
1200
1201                // Per-row conditional claim (1 SELECT + N guarded UPDATEs). The
1202                // batch is bounded by the caller's free permits (small), and this
1203                // is portable across Postgres + SQLite — deliberately NOT a
1204                // Postgres-only `FOR UPDATE SKIP LOCKED`.
1205                let mut claimed = Vec::new();
1206                for row in &rows {
1207                    let run_id: String = row.try_get("run_id").map_err(backend)?;
1208                    let body: String = row.try_get("body").map_err(backend)?;
1209                    // Flip the record to Running and rewrite the body so the column
1210                    // and the (source-of-truth) body stay consistent — a GET right
1211                    // after the claim must not show a stale `pending`.
1212                    let mut r = sql::decode_body(&body)?;
1213                    r.status = RunStatus::Running;
1214                    let new_body = sql::encode_body(&r)?;
1215                    // 2. Conditional claim — only the first committer wins.
1216                    let won = sqlx::query(&self.stmts.claim_one)
1217                        .bind(&self.instance_id)
1218                        .bind(&lease)
1219                        .bind(&new_body)
1220                        .bind(&run_id)
1221                        .execute(&self.pool)
1222                        .await
1223                        .map_err(backend)?
1224                        .rows_affected();
1225                    if won == 1 {
1226                        claimed.push(r);
1227                    }
1228                }
1229                Ok(claimed)
1230            }
1231
1232            async fn reclaim_orphans(
1233                &self,
1234                max_attempts: u32,
1235            ) -> Result<$crate::serve::history::ReclaimReport, $crate::serve::history::HistoryError>
1236            {
1237                use sqlx::Row as _;
1238                use $crate::serve::history::HistoryError;
1239                use $crate::serve::history::ReclaimReport;
1240                use $crate::serve::history::RunStatus;
1241                use $crate::serve::history::sql;
1242                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1243                let now = chrono::Utc::now();
1244                let now_s = sql::fmt_ts(now);
1245
1246                let rows = sqlx::query(&self.stmts.reclaim_select)
1247                    .bind(&now_s)
1248                    .fetch_all(&self.pool)
1249                    .await
1250                    .map_err(backend)?;
1251
1252                let mut report = ReclaimReport::default();
1253                for row in &rows {
1254                    let body: String = row.try_get("body").map_err(backend)?;
1255                    let mut rec = sql::decode_body(&body)?;
1256                    let next_attempt = rec.attempt + 1;
1257                    // Cap is on the attempts already made: a run that has been
1258                    // reclaimed fewer than `max_attempts` times gets another try;
1259                    // once it reaches the cap it is poisoned.
1260                    if rec.attempt < max_attempts {
1261                        // Re-queue for another instance to re-run.
1262                        rec.attempt = next_attempt;
1263                        rec.status = RunStatus::Pending;
1264                        let new_body = sql::encode_body(&rec)?;
1265                        let n = sqlx::query(&self.stmts.reclaim_requeue)
1266                            .bind(&new_body)
1267                            .bind(&rec.run_id)
1268                            .bind(&now_s)
1269                            .execute(&self.pool)
1270                            .await
1271                            .map_err(backend)?
1272                            .rows_affected();
1273                        if n == 1 {
1274                            report.requeued += 1;
1275                        }
1276                    } else {
1277                        // Poison: too many attempts.
1278                        rec.attempt = next_attempt;
1279                        rec.status = RunStatus::Failed;
1280                        rec.finished_at = Some(now);
1281                        rec.error = Some(format!(
1282                            "run reclaimed {next_attempt} times after its owning instance's \
1283                             lease expired; giving up (poison run)"
1284                        ));
1285                        if rec.elapsed_secs.is_none()
1286                            && let Some(started) = rec.started_at
1287                        {
1288                            rec.elapsed_secs =
1289                                (now - started).to_std().ok().map(|d| d.as_secs_f64());
1290                        }
1291                        let new_body = sql::encode_body(&rec)?;
1292                        let n = sqlx::query(&self.stmts.reclaim_fail)
1293                            .bind(&now_s)
1294                            .bind(&new_body)
1295                            .bind(&rec.run_id)
1296                            .bind(&now_s)
1297                            .execute(&self.pool)
1298                            .await
1299                            .map_err(backend)?
1300                            .rows_affected();
1301                        if n == 1 {
1302                            report.failed += 1;
1303                        }
1304                    }
1305                }
1306                Ok(report)
1307            }
1308
1309            async fn finalize_owned(
1310                &self,
1311                rec: &$crate::serve::history::RunRecord,
1312            ) -> Result<bool, $crate::serve::history::HistoryError> {
1313                use $crate::serve::history::HistoryError;
1314                use $crate::serve::history::sql;
1315                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1316                // Defensive: a terminal record must carry finished_at, or
1317                // purge_runs (which requires finished_at IS NOT NULL) can never
1318                // reclaim it. Stamp it if a caller left it unset.
1319                let mut rec = rec.clone();
1320                if rec.status.is_terminal() && rec.finished_at.is_none() {
1321                    rec.finished_at = Some(chrono::Utc::now());
1322                }
1323                let body = sql::encode_body(&rec)?;
1324                let finished = rec.finished_at.map(sql::fmt_ts);
1325                let lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
1326                let n = sqlx::query(&self.stmts.finalize_owned)
1327                    .bind(rec.status.as_str())
1328                    .bind(finished.as_deref())
1329                    .bind(&lease)
1330                    .bind(&body)
1331                    .bind(&rec.run_id)
1332                    .bind(&self.instance_id)
1333                    .execute(&self.pool)
1334                    .await
1335                    .map_err(backend)?
1336                    .rows_affected();
1337                Ok(n == 1)
1338            }
1339
1340            async fn finalize_sharded_parent(
1341                &self,
1342                run_id: &str,
1343                status: $crate::serve::history::RunStatus,
1344                finished_at: chrono::DateTime<chrono::Utc>,
1345                error: Option<String>,
1346            ) -> Result<bool, $crate::serve::history::HistoryError> {
1347                use sqlx::Row as _;
1348                use $crate::serve::history::HistoryError;
1349                use $crate::serve::history::RunStatus;
1350                use $crate::serve::history::sql;
1351                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1352                // Read the parent body, apply the terminal status, and write back
1353                // conditional on it still being `sharded` — so a concurrent
1354                // double-finalize from two instances has exactly one winner and
1355                // neither re-stamps owner/lease on the terminal record (F45).
1356                let Some(row) = sqlx::query(&self.stmts.select_body)
1357                    .bind(run_id)
1358                    .fetch_optional(&self.pool)
1359                    .await
1360                    .map_err(backend)?
1361                else {
1362                    return Ok(false);
1363                };
1364                let body: String = row.try_get("body").map_err(backend)?;
1365                let mut rec = sql::decode_body(&body)?;
1366                if rec.status != RunStatus::Sharded {
1367                    return Ok(false);
1368                }
1369                rec.status = status;
1370                rec.finished_at = Some(finished_at);
1371                rec.error = error;
1372                let new_body = sql::encode_body(&rec)?;
1373                let n = sqlx::query(&self.stmts.finalize_sharded_parent)
1374                    .bind(status.as_str())
1375                    .bind(sql::fmt_ts(finished_at))
1376                    .bind(&new_body)
1377                    .bind(run_id)
1378                    .execute(&self.pool)
1379                    .await
1380                    .map_err(backend)?
1381                    .rows_affected();
1382                Ok(n == 1)
1383            }
1384
1385            async fn cancel_pending(
1386                &self,
1387                run_id: &str,
1388            ) -> Result<bool, $crate::serve::history::HistoryError> {
1389                use sqlx::Row as _;
1390                use $crate::serve::history::HistoryError;
1391                use $crate::serve::history::RunStatus;
1392                use $crate::serve::history::sql;
1393                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1394                // Read the pending run's body, flip it to Cancelled, and write back
1395                // conditional on it still being pending (loses the race to a claim).
1396                let Some(row) = sqlx::query(&self.stmts.select_body)
1397                    .bind(run_id)
1398                    .fetch_optional(&self.pool)
1399                    .await
1400                    .map_err(backend)?
1401                else {
1402                    return Ok(false);
1403                };
1404                let body: String = row.try_get("body").map_err(backend)?;
1405                let mut rec = sql::decode_body(&body)?;
1406                if rec.status != RunStatus::Pending {
1407                    return Ok(false);
1408                }
1409                let now = chrono::Utc::now();
1410                rec.status = RunStatus::Cancelled;
1411                rec.finished_at = Some(now);
1412                let new_body = sql::encode_body(&rec)?;
1413                let n = sqlx::query(&self.stmts.cancel_pending)
1414                    .bind(sql::fmt_ts(now))
1415                    .bind(&new_body)
1416                    .bind(run_id)
1417                    .execute(&self.pool)
1418                    .await
1419                    .map_err(backend)?
1420                    .rows_affected();
1421                Ok(n == 1)
1422            }
1423
1424            async fn request_cancel(
1425                &self,
1426                run_id: &str,
1427            ) -> Result<(), $crate::serve::history::HistoryError> {
1428                use $crate::serve::history::HistoryError;
1429                use $crate::serve::history::sql;
1430                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1431                sqlx::query(&self.stmts.request_cancel)
1432                    .bind(sql::fmt_ts(chrono::Utc::now()))
1433                    .bind(run_id)
1434                    .execute(&self.pool)
1435                    .await
1436                    .map_err(backend)?;
1437                Ok(())
1438            }
1439
1440            async fn pending_cancellations(
1441                &self,
1442            ) -> Result<Vec<String>, $crate::serve::history::HistoryError> {
1443                use sqlx::Row as _;
1444                use $crate::serve::history::HistoryError;
1445                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1446                let rows = sqlx::query(&self.stmts.pending_cancellations)
1447                    .bind(&self.instance_id)
1448                    .fetch_all(&self.pool)
1449                    .await
1450                    .map_err(backend)?;
1451                let mut ids = Vec::with_capacity(rows.len());
1452                for r in &rows {
1453                    ids.push(r.try_get::<String, _>("run_id").map_err(backend)?);
1454                }
1455                Ok(ids)
1456            }
1457
1458            async fn heartbeat_instance(
1459                &self,
1460                beat: &$crate::serve::history::InstanceHeartbeat,
1461            ) -> Result<(), $crate::serve::history::HistoryError> {
1462                use $crate::serve::history::HistoryError;
1463                use $crate::serve::history::sql;
1464                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1465                let now = sql::fmt_ts(chrono::Utc::now());
1466                sqlx::query(&self.stmts.heartbeat_instance)
1467                    .bind(&self.instance_id)
1468                    .bind(sql::fmt_ts(beat.started_at))
1469                    .bind(&now)
1470                    .bind(beat.listen.as_deref())
1471                    .bind(beat.max_concurrent.to_string())
1472                    .bind(beat.in_flight.to_string())
1473                    .execute(&self.pool)
1474                    .await
1475                    .map_err(backend)?;
1476                Ok(())
1477            }
1478
1479            async fn live_instances(
1480                &self,
1481                ttl: std::time::Duration,
1482            ) -> Result<Vec<$crate::serve::history::InstanceRecord>, $crate::serve::history::HistoryError>
1483            {
1484                use sqlx::Row as _;
1485                use $crate::serve::history::HistoryError;
1486                use $crate::serve::history::InstanceRecord;
1487                use $crate::serve::history::sql;
1488                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1489                let now = chrono::Utc::now();
1490                let rows = sqlx::query(&self.stmts.live_instances)
1491                    .bind(sql::threshold(now, ttl))
1492                    .fetch_all(&self.pool)
1493                    .await
1494                    .map_err(backend)?;
1495                let parse_dt = |s: &str| {
1496                    chrono::DateTime::parse_from_rfc3339(s)
1497                        .map(|d| d.to_utc())
1498                        .unwrap_or(now)
1499                };
1500                let mut out = Vec::with_capacity(rows.len());
1501                for r in &rows {
1502                    let started: String = r.try_get("started_at").map_err(backend)?;
1503                    let hb: String = r.try_get("last_heartbeat").map_err(backend)?;
1504                    let mc: Option<String> = r.try_get("max_concurrent").map_err(backend)?;
1505                    let inf: Option<String> = r.try_get("in_flight").map_err(backend)?;
1506                    out.push(InstanceRecord {
1507                        instance_id: r.try_get("instance_id").map_err(backend)?,
1508                        started_at: parse_dt(&started),
1509                        last_heartbeat: parse_dt(&hb),
1510                        listen: r.try_get("listen").map_err(backend)?,
1511                        max_concurrent: mc.and_then(|s| s.parse().ok()).unwrap_or(0),
1512                        in_flight: inf.and_then(|s| s.parse().ok()).unwrap_or(0),
1513                    });
1514                }
1515                Ok(out)
1516            }
1517
1518            // ── Source shards (Mode B, #230) ─────────────────────────────────
1519
1520            async fn insert_shards(
1521                &self,
1522                run_id: &str,
1523                shards: &[$crate::serve::history::ShardInsert],
1524            ) -> Result<usize, $crate::serve::history::HistoryError> {
1525                use $crate::serve::history::HistoryError;
1526                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1527                let mut inserted = 0usize;
1528                for s in shards {
1529                    let descriptor = serde_json::to_string(&s.descriptor).map_err(|e| {
1530                        HistoryError::Backend(format!("encode shard descriptor: {e}"))
1531                    })?;
1532                    let size = s.size_estimate.map(|n| n.to_string());
1533                    let n = sqlx::query(&self.stmts.insert_shard)
1534                        .bind(run_id)
1535                        .bind(&s.shard_id)
1536                        .bind(&descriptor)
1537                        .bind(size.as_deref())
1538                        .execute(&self.pool)
1539                        .await
1540                        .map_err(backend)?
1541                        .rows_affected();
1542                    inserted += n as usize;
1543                }
1544                Ok(inserted)
1545            }
1546
1547            async fn claim_shards(
1548                &self,
1549                limit: usize,
1550            ) -> Result<
1551                Vec<$crate::serve::history::ClaimedShard>,
1552                $crate::serve::history::HistoryError,
1553            > {
1554                use sqlx::Row as _;
1555                use $crate::serve::history::ClaimedShard;
1556                use $crate::serve::history::HistoryError;
1557                use $crate::serve::history::sql;
1558                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1559                if limit == 0 {
1560                    return Ok(Vec::new());
1561                }
1562                let lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
1563
1564                // 1. Candidate pending shards (largest estimated size first),
1565                //    joined to their parent run body.
1566                let rows = sqlx::query(&self.stmts.claim_shards_select)
1567                    .bind(limit as i64)
1568                    .fetch_all(&self.pool)
1569                    .await
1570                    .map_err(backend)?;
1571
1572                // 2. Per-row conditional claim (portable; not FOR UPDATE SKIP LOCKED).
1573                let mut claimed = Vec::new();
1574                for row in &rows {
1575                    let run_id: String = row.try_get("run_id").map_err(backend)?;
1576                    let shard_id: String = row.try_get("shard_id").map_err(backend)?;
1577                    let descriptor_s: String = row.try_get("descriptor").map_err(backend)?;
1578                    let body: String = row.try_get("body").map_err(backend)?;
1579                    let won = sqlx::query(&self.stmts.claim_shard_one)
1580                        .bind(&self.instance_id)
1581                        .bind(&lease)
1582                        .bind(&run_id)
1583                        .bind(&shard_id)
1584                        .execute(&self.pool)
1585                        .await
1586                        .map_err(backend)?
1587                        .rows_affected();
1588                    if won == 1 {
1589                        let descriptor: serde_json::Value = serde_json::from_str(&descriptor_s)
1590                            .map_err(|e| {
1591                                HistoryError::Backend(format!("decode shard descriptor: {e}"))
1592                            })?;
1593                        let run = sql::decode_body(&body)?;
1594                        claimed.push(ClaimedShard {
1595                            run_id,
1596                            shard_id,
1597                            descriptor,
1598                            run,
1599                        });
1600                    }
1601                }
1602                Ok(claimed)
1603            }
1604
1605            async fn renew_shard_leases(
1606                &self,
1607            ) -> Result<usize, $crate::serve::history::HistoryError> {
1608                use $crate::serve::history::HistoryError;
1609                use $crate::serve::history::sql;
1610                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1611                let lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
1612                let n = sqlx::query(&self.stmts.renew_shard_leases)
1613                    .bind(&lease)
1614                    .bind(&self.instance_id)
1615                    .execute(&self.pool)
1616                    .await
1617                    .map_err(backend)?
1618                    .rows_affected() as usize;
1619                Ok(n)
1620            }
1621
1622            async fn reclaim_shards(
1623                &self,
1624                max_attempts: u32,
1625            ) -> Result<$crate::serve::history::ReclaimReport, $crate::serve::history::HistoryError>
1626            {
1627                use sqlx::Row as _;
1628                use $crate::serve::history::HistoryError;
1629                use $crate::serve::history::ReclaimReport;
1630                use $crate::serve::history::sql;
1631                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1632                let now_s = sql::fmt_ts(chrono::Utc::now());
1633
1634                let rows = sqlx::query(&self.stmts.reclaim_shards_select)
1635                    .bind(&now_s)
1636                    .fetch_all(&self.pool)
1637                    .await
1638                    .map_err(backend)?;
1639
1640                let mut report = ReclaimReport::default();
1641                for row in &rows {
1642                    let run_id: String = row.try_get("run_id").map_err(backend)?;
1643                    let shard_id: String = row.try_get("shard_id").map_err(backend)?;
1644                    let attempt_s: String = row.try_get("attempt").map_err(backend)?;
1645                    let attempt: u32 = attempt_s.parse().unwrap_or(0);
1646                    if attempt < max_attempts {
1647                        let next = (attempt + 1).to_string();
1648                        let n = sqlx::query(&self.stmts.reclaim_shard_requeue)
1649                            .bind(&next)
1650                            .bind(&run_id)
1651                            .bind(&shard_id)
1652                            .bind(&now_s)
1653                            .execute(&self.pool)
1654                            .await
1655                            .map_err(backend)?
1656                            .rows_affected();
1657                        if n == 1 {
1658                            report.requeued += 1;
1659                        }
1660                    } else {
1661                        let n = sqlx::query(&self.stmts.reclaim_shard_fail)
1662                            .bind(&now_s)
1663                            .bind(&run_id)
1664                            .bind(&shard_id)
1665                            .bind(&now_s)
1666                            .execute(&self.pool)
1667                            .await
1668                            .map_err(backend)?
1669                            .rows_affected();
1670                        if n == 1 {
1671                            report.failed += 1;
1672                        }
1673                    }
1674                }
1675                Ok(report)
1676            }
1677
1678            async fn finalize_shard(
1679                &self,
1680                run_id: &str,
1681                shard_id: &str,
1682                success: bool,
1683            ) -> Result<bool, $crate::serve::history::HistoryError> {
1684                use $crate::serve::history::HistoryError;
1685                use $crate::serve::history::sql;
1686                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1687                let status = if success { "completed" } else { "failed" };
1688                let now_s = sql::fmt_ts(chrono::Utc::now());
1689                let n = sqlx::query(&self.stmts.finalize_shard)
1690                    .bind(status)
1691                    .bind(&now_s)
1692                    .bind(run_id)
1693                    .bind(shard_id)
1694                    .bind(&self.instance_id)
1695                    .execute(&self.pool)
1696                    .await
1697                    .map_err(backend)?
1698                    .rows_affected();
1699                Ok(n == 1)
1700            }
1701
1702            async fn shard_progress(
1703                &self,
1704                run_id: &str,
1705            ) -> Result<$crate::serve::history::ShardProgress, $crate::serve::history::HistoryError>
1706            {
1707                use sqlx::Row as _;
1708                use $crate::serve::history::HistoryError;
1709                use $crate::serve::history::ShardProgress;
1710                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1711                let rows = sqlx::query(&self.stmts.shard_progress)
1712                    .bind(run_id)
1713                    .fetch_all(&self.pool)
1714                    .await
1715                    .map_err(backend)?;
1716                let mut p = ShardProgress::default();
1717                for row in &rows {
1718                    let status: String = row.try_get("status").map_err(backend)?;
1719                    let n: i64 = row.try_get("n").map_err(backend)?;
1720                    let n = n.max(0) as usize;
1721                    p.total += n;
1722                    match status.as_str() {
1723                        "completed" => p.completed += n,
1724                        "failed" => p.failed += n,
1725                        "running" => p.running += n,
1726                        _ => p.pending += n,
1727                    }
1728                }
1729                Ok(p)
1730            }
1731
1732            async fn pending_shard_cancellations(
1733                &self,
1734            ) -> Result<Vec<String>, $crate::serve::history::HistoryError> {
1735                use sqlx::Row as _;
1736                use $crate::serve::history::HistoryError;
1737                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1738                let rows = sqlx::query(&self.stmts.pending_shard_cancellations)
1739                    .bind(&self.instance_id)
1740                    .fetch_all(&self.pool)
1741                    .await
1742                    .map_err(backend)?;
1743                let mut ids = Vec::with_capacity(rows.len());
1744                for r in &rows {
1745                    ids.push(r.try_get::<String, _>("run_id").map_err(backend)?);
1746                }
1747                Ok(ids)
1748            }
1749
1750            async fn finalize_completed_sharded_parents(
1751                &self,
1752            ) -> Result<usize, $crate::serve::history::HistoryError> {
1753                use sqlx::Row as _;
1754                use $crate::serve::history::HistoryError;
1755                use $crate::serve::history::RunStatus;
1756                use $crate::serve::history::sql;
1757                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1758
1759                // Candidate `sharded` parents — finalize each whose shards are all
1760                // terminal. The status-fenced UPDATE makes a concurrent finalize
1761                // (here or in `maybe_finalize_parent`) a benign no-op.
1762                let rows = sqlx::query(&self.stmts.select_sharded_parents)
1763                    .fetch_all(&self.pool)
1764                    .await
1765                    .map_err(backend)?;
1766
1767                let mut finalized = 0usize;
1768                for row in &rows {
1769                    let run_id: String = row.try_get("run_id").map_err(backend)?;
1770                    let progress = self.shard_progress(&run_id).await?;
1771                    if !progress.all_terminal() {
1772                        continue;
1773                    }
1774                    let success = progress.failed == 0;
1775                    // Read-modify-write the body so the surfaced record stays
1776                    // consistent (status, finished_at, error) with the column.
1777                    let Some(body_row) = sqlx::query(&self.stmts.select_body)
1778                        .bind(&run_id)
1779                        .fetch_optional(&self.pool)
1780                        .await
1781                        .map_err(backend)?
1782                    else {
1783                        continue;
1784                    };
1785                    let body: String = body_row.try_get("body").map_err(backend)?;
1786                    let mut rec = sql::decode_body(&body)?;
1787                    // Skip if it raced to terminal already (column says sharded but
1788                    // the body was just updated). The fenced UPDATE is the real guard.
1789                    if rec.status != RunStatus::Sharded {
1790                        continue;
1791                    }
1792                    let now = chrono::Utc::now();
1793                    rec.status = if success {
1794                        RunStatus::Completed
1795                    } else {
1796                        RunStatus::Failed
1797                    };
1798                    rec.finished_at = Some(now);
1799                    if !success {
1800                        rec.error = Some(format!(
1801                            "{}/{} shard(s) failed",
1802                            progress.failed, progress.total
1803                        ));
1804                    }
1805                    let new_body = sql::encode_body(&rec)?;
1806                    let n = sqlx::query(&self.stmts.finalize_sharded_parent)
1807                        .bind(rec.status.as_str())
1808                        .bind(sql::fmt_ts(now))
1809                        .bind(&new_body)
1810                        .bind(&run_id)
1811                        .execute(&self.pool)
1812                        .await
1813                        .map_err(backend)?
1814                        .rows_affected();
1815                    if n == 1 {
1816                        finalized += 1;
1817                        $crate::serve::metrics::record_run_finished(
1818                            rec.status,
1819                            if success { "ok" } else { "error" },
1820                        );
1821                        tracing::info!(
1822                            run_id,
1823                            shards = progress.total,
1824                            failed = progress.failed,
1825                            "sharded run finalized by sweep (F11)"
1826                        );
1827                    }
1828                }
1829                Ok(finalized)
1830            }
1831
1832            // ── Audit log (RBAC, #205) ───────────────────────────────────────
1833
1834            async fn record_audit(
1835                &self,
1836                entry: &$crate::serve::history::AuditEntry,
1837            ) -> Result<(), $crate::serve::history::HistoryError> {
1838                use $crate::serve::history::HistoryError;
1839                use $crate::serve::history::sql;
1840                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1841                sqlx::query(&self.stmts.insert_audit)
1842                    .bind(&entry.id)
1843                    .bind(sql::fmt_ts(entry.timestamp))
1844                    .bind(&entry.principal)
1845                    .bind(&entry.role)
1846                    .bind(&entry.action)
1847                    .bind(entry.run_id.as_deref())
1848                    .bind(entry.config_fingerprint.as_deref())
1849                    .bind(entry.source_ip.as_deref())
1850                    .bind(&entry.result)
1851                    .execute(&self.pool)
1852                    .await
1853                    .map_err(backend)?;
1854                Ok(())
1855            }
1856
1857            async fn list_audit(
1858                &self,
1859                filter: &$crate::serve::history::AuditFilter,
1860            ) -> Result<
1861                Vec<$crate::serve::history::AuditEntry>,
1862                $crate::serve::history::HistoryError,
1863            > {
1864                use sqlx::Row as _;
1865                use $crate::serve::history::AuditEntry;
1866                use $crate::serve::history::HistoryError;
1867                use $crate::serve::history::sql;
1868                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1869                let principal = filter.principal.as_deref();
1870                let action = filter.action.as_deref();
1871                let since = filter.since.map(sql::fmt_ts);
1872                let until = filter.until.map(sql::fmt_ts);
1873                let limit = filter.limit.max(1) as i64;
1874                let rows = sqlx::query(&self.stmts.list_audit)
1875                    .bind(principal)
1876                    .bind(principal)
1877                    .bind(action)
1878                    .bind(action)
1879                    .bind(since.as_deref())
1880                    .bind(since.as_deref())
1881                    .bind(until.as_deref())
1882                    .bind(until.as_deref())
1883                    .bind(limit)
1884                    .fetch_all(&self.pool)
1885                    .await
1886                    .map_err(backend)?;
1887                let mut out = Vec::with_capacity(rows.len());
1888                for r in &rows {
1889                    let ts: String = r.try_get("ts").map_err(backend)?;
1890                    let timestamp = chrono::DateTime::parse_from_rfc3339(&ts)
1891                        .map(|d| d.to_utc())
1892                        .unwrap_or_else(|_| chrono::Utc::now());
1893                    out.push(AuditEntry {
1894                        id: r.try_get("id").map_err(backend)?,
1895                        timestamp,
1896                        principal: r.try_get("principal").map_err(backend)?,
1897                        role: r.try_get("role").map_err(backend)?,
1898                        action: r.try_get("action").map_err(backend)?,
1899                        run_id: r.try_get("run_id").map_err(backend)?,
1900                        config_fingerprint: r.try_get("config_fingerprint").map_err(backend)?,
1901                        source_ip: r.try_get("source_ip").map_err(backend)?,
1902                        result: r.try_get("result").map_err(backend)?,
1903                    });
1904                }
1905                Ok(out)
1906            }
1907
1908            // ── Data Movement Catalog (#279) ─────────────────────────────────
1909
1910            async fn catalog_record(
1911                &self,
1912                update: &$crate::serve::history::catalog::CatalogUpdate,
1913            ) -> Result<(), $crate::serve::history::HistoryError> {
1914                use sqlx::Row as _;
1915                use $crate::serve::history::HistoryError;
1916                use $crate::serve::history::catalog;
1917                use $crate::serve::history::sql;
1918                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1919                let now_s = sql::fmt_ts(update.recorded_at);
1920
1921                for obs in [&update.source, &update.sink] {
1922                    let id = catalog::dataset_id(&obs.uri);
1923                    // Read-merge-write; last-write-wins under cluster concurrency
1924                    // (counters may undercount on a race — acceptable for
1925                    // operational stats, never for correctness).
1926                    let existing = sqlx::query(&self.stmts.catalog_select_dataset)
1927                        .bind(&id)
1928                        .fetch_optional(&self.pool)
1929                        .await
1930                        .map_err(backend)?
1931                        .map(|r| r.try_get::<String, _>("body"))
1932                        .transpose()
1933                        .map_err(backend)?
1934                        .map(|b| {
1935                            sql::decode_json::<catalog::CatalogDataset>(&b, "catalog dataset")
1936                        })
1937                        .transpose()?;
1938                    let (ds, new_version) = catalog::apply_observation(
1939                        existing.as_ref(),
1940                        obs,
1941                        &update.run_id,
1942                        &update.pipeline,
1943                        &update.row,
1944                        update.recorded_at,
1945                    );
1946                    sqlx::query(&self.stmts.catalog_upsert_dataset)
1947                        .bind(&ds.id)
1948                        .bind(&ds.uri)
1949                        .bind(&ds.kind)
1950                        .bind(&now_s)
1951                        .bind(sql::encode_json(&ds, "catalog dataset")?)
1952                        .execute(&self.pool)
1953                        .await
1954                        .map_err(backend)?;
1955                    if let Some(v) = new_version {
1956                        sqlx::query(&self.stmts.catalog_insert_schema_version)
1957                            .bind(&v.dataset_id)
1958                            .bind(v.version.to_string())
1959                            .bind(sql::fmt_ts(v.recorded_at))
1960                            .bind(sql::encode_json(&v, "catalog schema version")?)
1961                            .execute(&self.pool)
1962                            .await
1963                            .map_err(backend)?;
1964                    }
1965                    sqlx::query(&self.stmts.catalog_insert_stat)
1966                        .bind(&id)
1967                        .bind(&now_s)
1968                        .bind(&update.run_id)
1969                        .bind(obs.records.to_string())
1970                        .execute(&self.pool)
1971                        .await
1972                        .map_err(backend)?;
1973                    sqlx::query(&self.stmts.catalog_prune_stats)
1974                        .bind(&id)
1975                        .bind(&id)
1976                        .bind(catalog::STATS_RETAIN as i64)
1977                        .execute(&self.pool)
1978                        .await
1979                        .map_err(backend)?;
1980                }
1981
1982                let src_id = catalog::dataset_id(&update.source.uri);
1983                let dst_id = catalog::dataset_id(&update.sink.uri);
1984                let existing_edges = self.catalog_all_edges().await?;
1985                let existing = existing_edges
1986                    .iter()
1987                    .find(|e| e.src_id == src_id && e.dst_id == dst_id);
1988                let edge = catalog::apply_edge(existing, update);
1989                sqlx::query(&self.stmts.catalog_upsert_edge)
1990                    .bind(&edge.src_id)
1991                    .bind(&edge.dst_id)
1992                    .bind(&now_s)
1993                    .bind(sql::encode_json(&edge, "catalog edge")?)
1994                    .execute(&self.pool)
1995                    .await
1996                    .map_err(backend)?;
1997                Ok(())
1998            }
1999
2000            async fn catalog_list_datasets(
2001                &self,
2002                filter: &$crate::serve::history::catalog::CatalogListFilter,
2003            ) -> Result<
2004                $crate::serve::history::catalog::CatalogDatasetPage,
2005                $crate::serve::history::HistoryError,
2006            > {
2007                use sqlx::Row as _;
2008                use $crate::serve::history::HistoryError;
2009                use $crate::serve::history::catalog;
2010                use $crate::serve::history::sql;
2011                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
2012                let rows = sqlx::query(&self.stmts.catalog_select_datasets)
2013                    .fetch_all(&self.pool)
2014                    .await
2015                    .map_err(backend)?;
2016                let mut all = Vec::with_capacity(rows.len());
2017                for r in &rows {
2018                    let body: String = r.try_get("body").map_err(backend)?;
2019                    all.push(sql::decode_json(&body, "catalog dataset")?);
2020                }
2021                Ok(catalog::filter_datasets(all, filter))
2022            }
2023
2024            async fn catalog_get_dataset(
2025                &self,
2026                id: &str,
2027            ) -> Result<
2028                Option<$crate::serve::history::catalog::CatalogDatasetDetail>,
2029                $crate::serve::history::HistoryError,
2030            > {
2031                use sqlx::Row as _;
2032                use $crate::serve::history::HistoryError;
2033                use $crate::serve::history::catalog;
2034                use $crate::serve::history::sql;
2035                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
2036                let Some(row) = sqlx::query(&self.stmts.catalog_select_dataset)
2037                    .bind(id)
2038                    .fetch_optional(&self.pool)
2039                    .await
2040                    .map_err(backend)?
2041                else {
2042                    return Ok(None);
2043                };
2044                let body: String = row.try_get("body").map_err(backend)?;
2045                let dataset: catalog::CatalogDataset =
2046                    sql::decode_json(&body, "catalog dataset")?;
2047
2048                let rows = sqlx::query(&self.stmts.catalog_select_schema_versions)
2049                    .bind(id)
2050                    .fetch_all(&self.pool)
2051                    .await
2052                    .map_err(backend)?;
2053                let mut schema_timeline = Vec::with_capacity(rows.len());
2054                for r in &rows {
2055                    let body: String = r.try_get("body").map_err(backend)?;
2056                    schema_timeline.push(sql::decode_json(&body, "catalog schema version")?);
2057                }
2058
2059                let rows = sqlx::query(&self.stmts.catalog_select_stats)
2060                    .bind(id)
2061                    .bind(catalog::STATS_DETAIL_LIMIT as i64)
2062                    .fetch_all(&self.pool)
2063                    .await
2064                    .map_err(backend)?;
2065                let mut stats = Vec::with_capacity(rows.len());
2066                for r in &rows {
2067                    let recorded: String = r.try_get("recorded_at").map_err(backend)?;
2068                    let run_id: String = r.try_get("run_id").map_err(backend)?;
2069                    let records: String = r.try_get("records").map_err(backend)?;
2070                    stats.push(catalog::CatalogStatsPoint {
2071                        recorded_at: chrono::DateTime::parse_from_rfc3339(&recorded)
2072                            .map(|d| d.to_utc())
2073                            .unwrap_or_else(|_| chrono::Utc::now()),
2074                        run_id,
2075                        records: records.parse().unwrap_or(0),
2076                    });
2077                }
2078
2079                let edges = self.catalog_all_edges().await?;
2080                let (downstream, rest): (Vec<_>, Vec<_>) =
2081                    edges.into_iter().partition(|e| e.src_id == id);
2082                let upstream = rest.into_iter().filter(|e| e.dst_id == id).collect();
2083                Ok(Some(catalog::CatalogDatasetDetail {
2084                    dataset,
2085                    schema_timeline,
2086                    stats,
2087                    upstream,
2088                    downstream,
2089                }))
2090            }
2091
2092            async fn catalog_lineage(
2093                &self,
2094                root: Option<&str>,
2095                depth: u32,
2096            ) -> Result<
2097                Vec<$crate::serve::history::catalog::CatalogLineageEdge>,
2098                $crate::serve::history::HistoryError,
2099            > {
2100                use $crate::serve::history::catalog;
2101                let edges = self.catalog_all_edges().await?;
2102                Ok(catalog::lineage_slice(edges, root, depth))
2103            }
2104
2105            async fn catalog_record_config_snapshot(
2106                &self,
2107                snapshot: &$crate::serve::history::catalog::ConfigSnapshot,
2108            ) -> Result<(), $crate::serve::history::HistoryError> {
2109                use $crate::serve::history::HistoryError;
2110                use $crate::serve::history::sql;
2111                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
2112                sqlx::query(&self.stmts.catalog_upsert_config_snapshot)
2113                    .bind(&snapshot.pipeline)
2114                    .bind(sql::fmt_ts(snapshot.recorded_at))
2115                    .bind(&snapshot.faucet_version)
2116                    .bind(sql::encode_json(snapshot, "config snapshot")?)
2117                    .execute(&self.pool)
2118                    .await
2119                    .map_err(backend)?;
2120                Ok(())
2121            }
2122
2123            async fn catalog_last_config_snapshot(
2124                &self,
2125                pipeline: &str,
2126            ) -> Result<
2127                Option<$crate::serve::history::catalog::ConfigSnapshot>,
2128                $crate::serve::history::HistoryError,
2129            > {
2130                use sqlx::Row as _;
2131                use $crate::serve::history::HistoryError;
2132                use $crate::serve::history::sql;
2133                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
2134                let Some(row) = sqlx::query(&self.stmts.catalog_select_config_snapshot)
2135                    .bind(pipeline)
2136                    .fetch_optional(&self.pool)
2137                    .await
2138                    .map_err(backend)?
2139                else {
2140                    return Ok(None);
2141                };
2142                let body: String = row.try_get("body").map_err(backend)?;
2143                Ok(Some(sql::decode_json(&body, "config snapshot")?))
2144            }
2145
2146            fn degraded(&self) -> bool {
2147                // A live SQL backend is never self-degraded; the FallbackHistory
2148                // wrapper owns degradation when the backend becomes unreachable.
2149                false
2150            }
2151        }
2152
2153        impl $name {
2154            /// Every catalog lineage edge, newest activity first (#279).
2155            async fn catalog_all_edges(
2156                &self,
2157            ) -> Result<
2158                Vec<$crate::serve::history::catalog::CatalogLineageEdge>,
2159                $crate::serve::history::HistoryError,
2160            > {
2161                use sqlx::Row as _;
2162                use $crate::serve::history::HistoryError;
2163                use $crate::serve::history::sql;
2164                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
2165                let rows = sqlx::query(&self.stmts.catalog_select_edges)
2166                    .fetch_all(&self.pool)
2167                    .await
2168                    .map_err(backend)?;
2169                let mut edges = Vec::with_capacity(rows.len());
2170                for r in &rows {
2171                    let body: String = r.try_get("body").map_err(backend)?;
2172                    edges.push(sql::decode_json(&body, "catalog edge")?);
2173                }
2174                Ok(edges)
2175            }
2176        }
2177    };
2178}
2179
2180pub(crate) use impl_sql_history;
2181
2182#[cfg(test)]
2183mod tests {
2184    use super::*;
2185
2186    #[test]
2187    fn postgres_shard_statements_are_built() {
2188        // SQLite tests only build the Sqlite statement set; exercise the
2189        // Postgres shard-statement construction too (Mode B, #230).
2190        let s = Stmts::new(Dialect::Postgres);
2191        assert!(s.insert_shard.contains("faucet_serve_shards"));
2192        assert!(s.insert_shard.contains("ON CONFLICT"));
2193        assert!(s.claim_shards_select.contains("JOIN faucet_serve_runs"));
2194        assert!(s.claim_shard_one.contains("'running'"));
2195        assert!(s.renew_shard_leases.contains("lease_expires_at"));
2196        assert!(s.reclaim_shards_select.contains("'running'"));
2197        assert!(s.reclaim_shard_requeue.contains("'pending'"));
2198        assert!(s.reclaim_shard_fail.contains("'failed'"));
2199        assert!(s.finalize_shard.contains("owner"));
2200        assert!(s.shard_progress.contains("GROUP BY"));
2201    }
2202
2203    #[test]
2204    fn fmt_ts_is_fixed_width_and_sortable() {
2205        let a = fmt_ts(
2206            DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
2207                .unwrap()
2208                .to_utc(),
2209        );
2210        let b = fmt_ts(
2211            DateTime::parse_from_rfc3339("2026-01-01T00:00:01Z")
2212                .unwrap()
2213                .to_utc(),
2214        );
2215        assert!(a.ends_with('Z'));
2216        assert_eq!(a.len(), b.len(), "fixed width");
2217        assert!(a < b, "lexicographic order matches chronological order");
2218    }
2219
2220    #[test]
2221    fn is_expired_respects_window() {
2222        let now = Utc::now();
2223        let old = fmt_ts(now - chrono::Duration::seconds(120));
2224        assert!(is_expired(&old, now, Duration::from_secs(60)));
2225        assert!(!is_expired(&old, now, Duration::from_secs(600)));
2226        // Unparseable → not expired (conservative).
2227        assert!(!is_expired("not-a-timestamp", now, Duration::ZERO));
2228    }
2229
2230    #[test]
2231    fn parse_status_round_trips_known_and_defaults_failed() {
2232        for s in [
2233            RunStatus::Queued,
2234            RunStatus::Pending,
2235            RunStatus::Running,
2236            RunStatus::Completed,
2237            RunStatus::Failed,
2238            RunStatus::Cancelled,
2239        ] {
2240            assert_eq!(parse_status(s.as_str()), s);
2241        }
2242        assert_eq!(parse_status("garbage"), RunStatus::Failed);
2243    }
2244
2245    #[test]
2246    fn body_round_trips() {
2247        let rec = RunRecord::queued(
2248            "r1".into(),
2249            Some("n".into()),
2250            Default::default(),
2251            Some("idem".into()),
2252            Utc::now(),
2253        );
2254        let encoded = encode_body(&rec).unwrap();
2255        let decoded = decode_body(&encoded).unwrap();
2256        assert_eq!(decoded.run_id, "r1");
2257        assert_eq!(decoded.idempotency_key.as_deref(), Some("idem"));
2258    }
2259
2260    #[test]
2261    fn postgres_and_sqlite_statements_differ_only_in_placeholders() {
2262        let pg = Stmts::new(Dialect::Postgres);
2263        let lite = Stmts::new(Dialect::Sqlite);
2264        assert!(pg.upsert.contains("$1") && lite.upsert.contains('?'));
2265        assert!(pg.list.contains("$13") && lite.list.contains('?'));
2266        // Both target the same tables / conflict targets.
2267        assert!(pg.insert_idem.contains("ON CONFLICT (key) DO NOTHING"));
2268        assert!(lite.insert_idem.contains("ON CONFLICT (key) DO NOTHING"));
2269        assert!(pg.claim_one.contains("$3") && lite.claim_one.contains('?'));
2270        assert!(pg.heartbeat_instance.contains("faucet_serve_instances"));
2271        assert!(lite.heartbeat_instance.contains("faucet_serve_instances"));
2272    }
2273}