Skip to main content

faucet_cli/serve/
runner.rs

1//! The run lifecycle: validate + queue a submission (`submit`), then run it under
2//! a permit. A cancel / timeout / shutdown trigger cooperatively cancels the
3//! pipeline (so a buffered sink flushes at its next page boundary, #146 H16) and
4//! grants a bounded flush grace before hard-dropping it; the task then finalizes
5//! an authoritative terminal status. See spec §7 + §20.
6
7use crate::auth_catalog::build_auth_catalog;
8use crate::executor::{ExecuteOptions, RunSummary, run_expanded};
9use crate::registry::build_source;
10use crate::serve::error::ServeError;
11use crate::serve::history::{Claim, InvocationRecord, RunRecord, RunStatus};
12use crate::serve::history::{ClaimedShard, ShardInsert};
13use crate::serve::load::{ConfigFormat, LoadedSubmission, load_submission};
14use crate::serve::state::ServerState;
15use crate::serve::{idempotency, metrics};
16use chrono::{DateTime, FixedOffset, Utc};
17use serde::{Deserialize, Serialize};
18use std::collections::BTreeMap;
19use std::time::Duration;
20use tokio_util::sync::CancellationToken;
21use tracing::Instrument;
22
23/// `Retry-After` advertised when the queue is full.
24const QUEUE_FULL_RETRY_AFTER_SECS: u64 = 5;
25
26/// Grace granted to a cancelled / timed-out / shutting-down run to flush
27/// buffered sink output cooperatively before its future is hard-dropped (which
28/// aborts the pipeline's task set, the backstop for a run stuck mid-write so a
29/// hung run can't wedge shutdown). Generous enough for an S3 multipart
30/// completion (#146 H16).
31const RUN_FLUSH_GRACE: Duration = Duration::from_secs(30);
32
33/// `POST /v1/runs` request body.
34#[derive(Debug, Deserialize)]
35pub struct SubmitRequest {
36    pub config: String,
37    #[serde(default)]
38    pub config_format: ConfigFormatWire,
39    pub name: Option<String>,
40    #[serde(default)]
41    pub labels: BTreeMap<String, String>,
42    pub timeout_secs: Option<u64>,
43    #[serde(default)]
44    pub doctor_first: bool,
45    pub idempotency_key: Option<String>,
46    pub clock: Option<String>,
47}
48
49/// Wire enum mirroring `load::ConfigFormat` with serde rename.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
51#[serde(rename_all = "lowercase")]
52pub enum ConfigFormatWire {
53    #[default]
54    Yaml,
55    Json,
56}
57
58impl From<ConfigFormatWire> for ConfigFormat {
59    fn from(w: ConfigFormatWire) -> Self {
60        match w {
61            ConfigFormatWire::Yaml => ConfigFormat::Yaml,
62            ConfigFormatWire::Json => ConfigFormat::Json,
63        }
64    }
65}
66
67/// `POST /v1/runs` success body (202).
68#[derive(Debug, Serialize)]
69pub struct SubmitResponse {
70    pub run_id: String,
71    pub status: RunStatus,
72    pub submitted_at: DateTime<Utc>,
73}
74
75/// Re-run a claimed Pending run on this instance (cluster mode). Reconstructs the
76/// execution inputs from the persisted record (re-resolving config with this
77/// instance's own env/credentials), acquires a permit, and runs the shared tail.
78pub fn resume_claimed_run(state: ServerState, rec: RunRecord) {
79    tokio::spawn(async move {
80        let run_id = rec.run_id.clone();
81        let Some(body) = rec.config_body.as_deref() else {
82            tracing::error!(run_id, "claimed run has no stored config; failing it");
83            finalize(
84                &state,
85                &run_id,
86                rec.submitted_at,
87                Terminal::Failed {
88                    reason: "claimed run record missing config_body".into(),
89                    records: 0,
90                    invs: Vec::new(),
91                },
92            )
93            .await;
94            return;
95        };
96        let format = rec.config_format.unwrap_or_default();
97        let loaded = match load_submission(body, format, state.default_base()).await {
98            Ok(l) => l,
99            Err(e) => {
100                finalize(
101                    &state,
102                    &run_id,
103                    rec.submitted_at,
104                    Terminal::Failed {
105                        reason: format!(
106                            "re-loading claimed config: {}",
107                            e.api_error().error.message
108                        ),
109                        records: 0,
110                        invs: Vec::new(),
111                    },
112                )
113                .await;
114                return;
115            }
116        };
117
118        // Mode B (#230): a sharded run is expanded into shard rows here — the
119        // claiming instance acts as the (ephemeral) coordinator — and is NOT
120        // executed as a whole. Enumeration + insert is idempotent, so two
121        // instances both claiming + coordinating converge on the same shard set.
122        if let Some(sh) = loaded.cfg.shard.clone()
123            && sh.count >= 2
124        {
125            match coordinate_sharded_run(&state, &run_id, &loaded, sh.count).await {
126                Ok(true) => return, // expanded into shards — shard loop runs them
127                Ok(false) => {}     // not shardable → fall through, run the whole run
128                Err(e) => {
129                    finalize(
130                        &state,
131                        &run_id,
132                        rec.submitted_at,
133                        Terminal::Failed {
134                            reason: format!("sharding: {e}"),
135                            records: 0,
136                            invs: Vec::new(),
137                        },
138                    )
139                    .await;
140                    return;
141                }
142            }
143        }
144
145        // The claim loop only claims up to available_permits and is the sole
146        // permit consumer, so this acquire returns immediately.
147        let _permit = state
148            .semaphore()
149            .acquire_owned()
150            .await
151            .expect("semaphore not closed");
152        // Register a local cancel token so a cross-instance cancel (the claim loop
153        // calling registry.cancel) reaches this run.
154        let run_token = CancellationToken::new();
155        state.registry().register(run_id.clone(), run_token.clone());
156        execute_run(
157            state.clone(),
158            loaded,
159            run_id,
160            run_token,
161            rec.submitted_at,
162            rec.timeout_secs,
163            rec.clock.clone(),
164            false,
165        )
166        .await;
167    });
168}
169
170/// Coordinator step (Mode B): expand a sharded run into `faucet_serve_shards`
171/// rows. Returns `Ok(true)` when the run was sharded (caller must not execute it
172/// as a whole), `Ok(false)` when it isn't shardable (caller runs it whole).
173///
174/// Idempotent: enumeration is deterministic and the insert is
175/// `ON CONFLICT DO NOTHING`, so a re-coordinated run (e.g. after the coordinator
176/// crashed and the Pending run was requeued) converges on the same shard set.
177async fn coordinate_sharded_run(
178    state: &ServerState,
179    run_id: &str,
180    loaded: &LoadedSubmission,
181    count: usize,
182) -> crate::error::CliResult<bool> {
183    use crate::error::CliError;
184
185    // Sharding applies to a single-source pipeline; a matrix fan-out is not
186    // shardable (each row is already an independent unit — use Mode A).
187    if loaded.nodes.len() != 1 {
188        tracing::warn!(
189            run_id,
190            nodes = loaded.nodes.len(),
191            "shard requested but the run is not a single-node pipeline; running it whole"
192        );
193        return Ok(false);
194    }
195    let node = &loaded.nodes[0];
196    let auth = build_auth_catalog(loaded.cfg.auth.as_ref())
197        .map_err(|e| CliError::Internal(format!("auth catalog: {e}")))?;
198    let source = build_source(&node.source.kind, node.source.config.clone(), &auth, None).await?;
199    if !source.is_shardable() {
200        tracing::warn!(
201            run_id,
202            kind = %node.source.kind,
203            "source is not shardable; running the run whole"
204        );
205        return Ok(false);
206    }
207
208    // Mark the parent run Sharded BEFORE inserting any shard rows (F27).
209    // Orphan recovery (`recover_orphans` / `reclaim_orphans`) only reclaims
210    // `running` runs; once the parent is `Sharded` it is immune to a false
211    // fail. Doing this first guarantees the invariant that **no shard row ever
212    // exists while the parent is still `running`** — otherwise a coordinator
213    // crash between `insert_shards` and the status flip would let orphan
214    // recovery mark the run Failed while its already-inserted shards are
215    // claimed, execute, and write data (a run the user believes failed and may
216    // resubmit → duplicate writes). If the flip itself fails, the run stays
217    // `running` + owned by us with no shards inserted, so a crash here is
218    // safely *requeued* by reclaim, not falsely failed. (A crash after the flip
219    // but before `insert_shards` leaves a 0-shard `Sharded` run, which
220    // `all_terminal()` never finalizes — an availability leak, not a
221    // correctness/duplication bug; the desired trade.)
222    {
223        let mut r = state
224            .history()
225            .get(run_id)
226            .await
227            .map_err(|e| CliError::Internal(e.to_string()))?
228            .ok_or_else(|| CliError::Internal(format!("run {run_id} vanished before sharding")))?;
229        r.status = RunStatus::Sharded;
230        state
231            .history()
232            .upsert(&r)
233            .await
234            .map_err(|e| CliError::Internal(e.to_string()))?;
235    }
236
237    let shards = source
238        .enumerate_shards(count)
239        .await
240        .map_err(|e| CliError::Internal(format!("enumerate_shards: {e}")))?;
241    let inserts: Vec<ShardInsert> = shards
242        .iter()
243        .map(|s| ShardInsert {
244            shard_id: s.id.clone(),
245            descriptor: s.descriptor.clone(),
246            size_estimate: s.size_estimate,
247        })
248        .collect();
249    let inserted = state
250        .history()
251        .insert_shards(run_id, &inserts)
252        .await
253        .map_err(|e| CliError::Internal(e.to_string()))?;
254    tracing::info!(
255        run_id,
256        shards = inserts.len(),
257        inserted,
258        "expanded run into shards (Mode B)"
259    );
260
261    // Wake the local claim loop so it picks up the freshly-inserted shards.
262    state.cluster().kick();
263    Ok(true)
264}
265
266/// Execute one claimed shard (Mode B): rebuild + narrow the source to the shard,
267/// run it under a permit, owner-fenced-finalize the shard, then finalize the
268/// parent run once every shard is terminal.
269pub fn resume_claimed_shard(state: ServerState, claimed: ClaimedShard) {
270    tokio::spawn(async move {
271        let ClaimedShard {
272            run_id,
273            shard_id,
274            descriptor,
275            run,
276        } = claimed;
277
278        let Some(body) = run.config_body.clone() else {
279            tracing::error!(run_id, shard_id, "claimed shard's run has no stored config");
280            let _ = state
281                .history()
282                .finalize_shard(&run_id, &shard_id, false)
283                .await;
284            maybe_finalize_parent(&state, &run_id).await;
285            return;
286        };
287        let format = run.config_format.unwrap_or_default();
288        let loaded = match load_submission(&body, format, state.default_base()).await {
289            Ok(l) => l,
290            Err(e) => {
291                tracing::error!(
292                    run_id,
293                    shard_id,
294                    error = %e.api_error().error.message,
295                    "re-loading shard config failed"
296                );
297                let _ = state
298                    .history()
299                    .finalize_shard(&run_id, &shard_id, false)
300                    .await;
301                maybe_finalize_parent(&state, &run_id).await;
302                return;
303            }
304        };
305
306        let _permit = state
307            .semaphore()
308            .acquire_owned()
309            .await
310            .expect("semaphore not closed");
311
312        let shard = faucet_core::ShardSpec {
313            id: shard_id.clone(),
314            descriptor,
315            size_estimate: None,
316        };
317        // Register a per-shard cooperative-cancel token BEFORE running so a
318        // cross-instance cancel reaches this shard: `cancel_run` flags the parent
319        // → `pending_shard_cancellations` → the claim loop calls
320        // `registry().cancel_run_shards(run_id)`, which fires this token. Removed
321        // on return so a terminated shard never leaks a token (shard accounting is
322        // separate from the run's `in_flight`, so a plain `deregister` is used).
323        let coop = CancellationToken::new();
324        state
325            .registry()
326            .register_shard(&run_id, &shard_id, coop.clone());
327        let success = execute_shard(
328            &state,
329            loaded,
330            &run_id,
331            &shard_id,
332            shard,
333            coop,
334            run.timeout_secs,
335            run.clock.clone(),
336            run.submitted_at,
337        )
338        .await;
339        state.registry().deregister_shard(&run_id, &shard_id);
340
341        match state
342            .history()
343            .finalize_shard(&run_id, &shard_id, success)
344            .await
345        {
346            Ok(true) => {}
347            Ok(false) => tracing::warn!(
348                run_id,
349                shard_id,
350                "shard was reclaimed by another instance; discarding result"
351            ),
352            Err(e) => tracing::error!(run_id, shard_id, error = %e, "finalize_shard failed"),
353        }
354        maybe_finalize_parent(&state, &run_id).await;
355    });
356}
357
358/// Run one shard's pipeline (single node, source narrowed via `opts.shard`).
359/// Returns `true` on clean completion. Does not touch the parent run record —
360/// the caller finalizes the shard and the parent.
361#[allow(clippy::too_many_arguments)]
362async fn execute_shard(
363    state: &ServerState,
364    loaded: LoadedSubmission,
365    run_id: &str,
366    shard_id: &str,
367    shard: faucet_core::ShardSpec,
368    coop: CancellationToken,
369    timeout_secs: Option<u64>,
370    clock_flag: Option<String>,
371    submitted_at: DateTime<Utc>,
372) -> bool {
373    let LoadedSubmission { cfg, nodes } = loaded;
374    let pipeline_name = cfg.name.clone().unwrap_or_else(|| "serve".to_string());
375
376    let auth = match build_auth_catalog(cfg.auth.as_ref()) {
377        Ok(a) => a,
378        Err(e) => {
379            tracing::error!(run_id, shard_id, "shard auth catalog: {e}");
380            return false;
381        }
382    };
383    let clock = match resolve_clock(clock_flag.as_deref(), submitted_at) {
384        Ok(c) => c,
385        Err(e) => {
386            tracing::error!(
387                run_id,
388                shard_id,
389                "shard clock: {}",
390                e.api_error().error.message
391            );
392            return false;
393        }
394    };
395    let resilience = match &cfg.resilience {
396        Some(spec) => match spec.to_policy() {
397            Ok(p) => Some(p),
398            Err(e) => {
399                tracing::error!(run_id, shard_id, "shard resilience: {e}");
400                return false;
401            }
402        },
403        None => None,
404    };
405    #[cfg(feature = "lineage")]
406    let lineage = match crate::lineage_glue::build_emitter(cfg.lineage.as_ref()) {
407        Ok(l) => l,
408        Err(e) => {
409            tracing::error!(run_id, shard_id, "shard lineage: {e}");
410            return false;
411        }
412    };
413
414    // `coop` is the per-shard cancel token registered by `resume_claimed_shard`;
415    // a cross-instance cancel (F10), a server shutdown, or a timeout all fire it
416    // so the shard's pipeline flushes at its next page boundary.
417    let opts = ExecuteOptions {
418        pipeline_name,
419        execution: cfg.execution.clone(),
420        dry_run: false,
421        limit: None,
422        state_path_override: None,
423        shard: Some(shard),
424        auth,
425        clock,
426        cancel: Some(coop.clone()),
427        resilience,
428        #[cfg(feature = "lineage")]
429        lineage,
430        #[cfg(feature = "lineage")]
431        lineage_cfg: cfg.lineage.clone(),
432    };
433
434    let server_shutdown = state.shutdown_token();
435    let span = tracing::info_span!("faucet.serve.shard", serve_run_id = %run_id, shard = %shard_id);
436    let work = async move { classify_run(run_expanded(nodes, opts).await) }.instrument(span);
437    tokio::pin!(work);
438    let timeout_fut = async {
439        match timeout_secs {
440            Some(s) => tokio::time::sleep(Duration::from_secs(s)).await,
441            None => std::future::pending::<()>().await,
442        }
443    };
444    tokio::pin!(timeout_fut);
445
446    // Cancel triggers (shutdown / timeout) cooperatively cancel + flush, like
447    // execute_run. A failed shard simply returns false → its lease eventually
448    // reassigns it (or it poisons after max_attempts).
449    // A cross-instance cancel (F10) flows in via the shard's registered coop
450    // token: `cancel_run` flags the parent → `pending_cancellations` →
451    // `claim_loop` fires `registry().cancel(run_id)`. The token is registered
452    // under the run id by `resume_claimed_shard` before this runs.
453    let terminal = tokio::select! {
454        biased;
455        t = &mut work => t,
456        _ = coop.cancelled() => {
457            // Fired by the claim loop for a remote cancel. Flush within the grace
458            // window; a cooperative cancel returns Ok(partial), so the shard is
459            // Cancelled — but a flush that FAILS must surface, not be masked.
460            match tokio::time::timeout(RUN_FLUSH_GRACE, &mut work).await {
461                Ok(failed @ Terminal::Failed { .. }) => failed,
462                Ok(_) | Err(_) => Terminal::Cancelled,
463            }
464        }
465        _ = server_shutdown.cancelled() => {
466            coop.cancel();
467            match tokio::time::timeout(RUN_FLUSH_GRACE, &mut work).await {
468                Ok(failed @ Terminal::Failed { .. }) => failed,
469                Ok(_) | Err(_) => Terminal::ShutdownFailed,
470            }
471        }
472        _ = &mut timeout_fut => {
473            coop.cancel();
474            match tokio::time::timeout(RUN_FLUSH_GRACE, &mut work).await {
475                Ok(failed @ Terminal::Failed { .. }) => failed,
476                Ok(_) | Err(_) => Terminal::Timeout { secs: timeout_secs.unwrap_or(0) },
477            }
478        }
479    };
480    matches!(terminal, Terminal::Completed { .. })
481}
482
483/// Finalize a `Sharded` parent run once all its shards are terminal. The last
484/// shard to finish always observes `all_terminal` (its own `finalize_shard`
485/// committed first), so the run never lingers `Sharded`. A benign double-finalize
486/// (two shards finishing simultaneously) writes the same terminal status twice.
487async fn maybe_finalize_parent(state: &ServerState, run_id: &str) {
488    let progress = match state.history().shard_progress(run_id).await {
489        Ok(p) => p,
490        Err(e) => {
491            tracing::warn!(run_id, error = %e, "shard_progress failed");
492            return;
493        }
494    };
495    if !progress.all_terminal() {
496        return;
497    }
498    let success = progress.failed == 0;
499    let status = if success {
500        RunStatus::Completed
501    } else {
502        RunStatus::Failed
503    };
504    let error =
505        (!success).then(|| format!("{}/{} shard(s) failed", progress.failed, progress.total));
506    // Status-fenced finalize: transitions the parent only while it is still
507    // `Sharded`, and does NOT re-stamp owner/lease on the terminal record, so a
508    // near-simultaneous double-finalize from two instances has a single winner
509    // (F45). The loser (and any non-`Sharded` parent) is a no-op.
510    match state
511        .history()
512        .finalize_sharded_parent(run_id, status, Utc::now(), error)
513        .await
514    {
515        Ok(true) => {
516            metrics::record_run_finished(status, if success { "ok" } else { "error" });
517            tracing::info!(
518                run_id,
519                shards = progress.total,
520                failed = progress.failed,
521                "sharded run finalized"
522            );
523        }
524        Ok(false) => {} // already finalized by another shard/instance — nothing to do
525        Err(e) => {
526            tracing::error!(run_id, error = %e, "finalizing sharded parent run failed");
527        }
528    }
529}
530
531/// Warn at submit when a clustered or source-sharded run is at-least-once with
532/// a non-idempotent destination (F26/F39). Cluster failover and shard reclaim
533/// are at-least-once by construction: an owner whose lease lapses while it is
534/// still alive (slow, paused), or a reassigned shard whose source re-reads from
535/// the start (postgres PK-range sharding yields no resumable bookmark), can
536/// re-execute work and write **duplicate** rows to an append-mode sink. The
537/// safe configuration is `write_mode: upsert`/`delete` (keyed, so re-writes are
538/// idempotent) or `delivery: exactly_once`. We surface the risk loudly rather
539/// than silently duplicating — the repo's #1 worst bug class.
540/// Pure decision for [`warn_if_cluster_at_least_once`]: the sink kinds that
541/// would write non-idempotently under cluster failover / shard reclaim. Empty
542/// when the run is neither clustered nor sharded, is `exactly_once`, or every
543/// sink is keyed (`upsert`/`delete`).
544fn at_least_once_risky_sinks(
545    loaded: &LoadedSubmission,
546    clustered: bool,
547    sharded: bool,
548) -> Vec<&str> {
549    if !(clustered || sharded) || loaded.cfg.delivery == faucet_core::DeliveryMode::ExactlyOnce {
550        return Vec::new();
551    }
552    loaded
553        .nodes
554        .iter()
555        .filter(|n| {
556            !matches!(
557                n.sink
558                    .config
559                    .get("write_mode")
560                    .and_then(|v| v.as_str())
561                    .unwrap_or("append"),
562                "upsert" | "delete"
563            )
564        })
565        .map(|n| n.sink.kind.as_str())
566        .collect()
567}
568
569fn warn_if_cluster_at_least_once(loaded: &LoadedSubmission, clustered: bool, sharded: bool) {
570    let risky = at_least_once_risky_sinks(loaded, clustered, sharded);
571    if !risky.is_empty() {
572        let scope = if sharded {
573            "source-sharded (Mode B)"
574        } else {
575            "clustered"
576        };
577        tracing::warn!(
578            sinks = ?risky,
579            "{scope} execution is at-least-once: a failover or shard reclaim can re-run work \
580             and write duplicate rows to an append-mode sink. Set `write_mode: upsert` (or \
581             `delivery: exactly_once`) on the destination to make re-execution idempotent (F26/F39)."
582        );
583    }
584}
585
586/// Best-effort release of an idempotency claim whose paired run-record upsert
587/// failed (F21). No-op when the request carried no key; the release itself is
588/// scoped by `run_id` (a newer run that re-claimed the key keeps its claim).
589async fn release_orphaned_claim(state: &ServerState, req: &SubmitRequest, run_id: &str) {
590    if req.idempotency_key.is_some()
591        && let Err(e) = state.history().release_idempotency(run_id).await
592    {
593        tracing::warn!(
594            run_id,
595            error = %e,
596            "failed to release idempotency claim after a run-record write error; \
597             a replay of the key may 404 until the claim self-expires"
598        );
599    }
600}
601
602/// Validate, idempotency-claim, queue, and spawn a submission.
603pub async fn submit(state: ServerState, req: SubmitRequest) -> Result<SubmitResponse, ServeError> {
604    let format: ConfigFormat = req.config_format.into();
605    let loaded = load_submission(&req.config, format, state.default_base()).await?;
606
607    // At-least-once duplicate-write warning for clustered / source-sharded runs
608    // with an append-mode destination (F26/F39).
609    let sharded = loaded.cfg.shard.as_ref().is_some_and(|s| s.count >= 2);
610    warn_if_cluster_at_least_once(&loaded, state.cluster().enabled(), sharded);
611
612    // Reserve a queue slot first, so a Fresh idempotency claim is always followed
613    // by a spawned run (no orphaned claims — spec §20.2).
614    if !state.registry().try_reserve() {
615        return Err(ServeError::QueueFull {
616            retry_after_secs: QUEUE_FULL_RETRY_AFTER_SECS,
617        });
618    }
619    // Releases the reservation on ANY early return below (doctor_first 422 /
620    // replay / conflict / claim or upsert error). Defused just before spawn.
621    let reservation = ReservationGuard::new(state.clone());
622
623    // doctor_first preflight — run BEHIND the reservation so concurrent preflight
624    // probing is bounded by `max_queued_runs` rather than running unthrottled
625    // before any limit applies (#146 R). On failure the guard releases the slot
626    // via the early `?`. The (redacted) report is stored on the run record below
627    // so `GET /v1/runs/{id}` exposes it (#146 R: doctor_report was never set).
628    let doctor_report = if req.doctor_first {
629        Some(run_doctor_first(&state, &loaded).await?)
630    } else {
631        None
632    };
633
634    let run_id = uuid::Uuid::now_v7().to_string();
635
636    // Idempotency claim (if a key was supplied).
637    if let Some(key) = &req.idempotency_key {
638        let merged = serde_json::to_value(&loaded.cfg).unwrap_or(serde_json::Value::Null);
639        // Fold the run-affecting request fields (clock / timeout_secs / labels)
640        // into the fingerprint, not just the config — so a key replayed with a
641        // different backfill `clock` is a 409, not a replay of the original
642        // run's window (#146 M7).
643        let fp_config = idempotency::fingerprint(&merged, loaded.cfg.name.as_deref());
644        let fp = idempotency::request_fingerprint(
645            &fp_config,
646            req.clock.as_deref(),
647            req.timeout_secs,
648            &req.labels,
649        );
650        match state
651            .history()
652            .claim_idempotency(key, &fp, &run_id, state.idempotency_retention())
653            .await
654            .map_err(|e| match e {
655                // Degraded backend can't safely honor idempotency → 503, retry.
656                crate::serve::history::HistoryError::Degraded(m) => ServeError::Unavailable(m),
657                other => ServeError::Internal(other.to_string()),
658            })? {
659            Claim::Fresh => {}
660            Claim::Replay(existing) => {
661                metrics::record_idempotency_hit();
662                return replay_response(&state, &existing).await;
663            }
664            Claim::Conflict => {
665                return Err(ServeError::Conflict(
666                    "idempotency key reused with a different payload".into(),
667                ));
668            }
669        }
670        // A `Fresh` claim is recorded BEFORE the record upsert below. The memory
671        // backend's `upsert` is infallible, but a SQL backend's can fail — so if
672        // the upsert fails, `release_orphaned_claim` drops the claim (F21) rather
673        // than leaving a replay 404-ing until the claim self-expires.
674    }
675
676    let submitted_at = Utc::now();
677    let mut rec = RunRecord::queued(
678        run_id.clone(),
679        req.name.clone(),
680        req.labels.clone(),
681        req.idempotency_key.clone(),
682        submitted_at,
683    );
684    rec.doctor_report = doctor_report;
685
686    if state.cluster().enabled() {
687        // A degraded (DB-unreachable) backend can't coordinate a cluster: the
688        // claim loop's claim_pending is a no-op on the in-memory fallback, so a
689        // Pending run would never be claimed. Fail closed with a retryable 503
690        // rather than silently orphaning the run (#197 spec §9).
691        if state.history().degraded() {
692            return Err(ServeError::Unavailable(
693                "clustered run-history backend is degraded; runs cannot be claimed \
694                 by any instance — retry once it recovers"
695                    .into(),
696            ));
697        }
698        // Cluster mode: persist the RAW config so any instance can re-resolve +
699        // run it, mark the run Pending, and wake the local claim loop. No local
700        // queue slot / spawn — the claim loop owns execution.
701        rec.status = RunStatus::Pending;
702        rec.config_body = Some(req.config.clone());
703        rec.config_format = Some(req.config_format.into());
704        rec.timeout_secs = req.timeout_secs;
705        rec.clock = req.clock.clone();
706        if let Err(e) = state.history().upsert(&rec).await {
707            // The record write that should follow a `Fresh` claim failed — release
708            // the orphaned claim so a replay starts fresh instead of 404-ing for
709            // the whole retention window (F21). Best-effort.
710            release_orphaned_claim(&state, &req, &run_id).await;
711            return Err(ServeError::Internal(e.to_string()));
712        }
713        // Release the local queue reservation (cluster runs are bounded by the
714        // claim loop + semaphore, not the submit-side queue).
715        drop(reservation);
716        state.cluster().kick();
717        return Ok(SubmitResponse {
718            run_id,
719            status: RunStatus::Pending,
720            submitted_at,
721        });
722    }
723
724    if let Err(e) = state.history().upsert(&rec).await {
725        // See the cluster path above: release the orphaned claim (F21).
726        release_orphaned_claim(&state, &req, &run_id).await;
727        return Err(ServeError::Internal(e.to_string()));
728    }
729
730    let run_token = CancellationToken::new();
731    state.registry().register(run_id.clone(), run_token.clone());
732    metrics::set_run_gauges(&state);
733
734    // The spawned task now owns the queued→running→finished lifecycle.
735    reservation.defuse();
736    spawn_run(
737        state.clone(),
738        loaded,
739        req,
740        run_id.clone(),
741        run_token,
742        submitted_at,
743    );
744
745    Ok(SubmitResponse {
746        run_id,
747        status: RunStatus::Queued,
748        submitted_at,
749    })
750}
751
752/// Run the `doctor_first` probes; on any failure return 422 with the report.
753/// Run the `doctor_first` probes. On success returns the (redacted) report so
754/// the caller can store it on the run record (`doctor_report`); on any probe
755/// failure returns 422 with the same redacted report as `details`.
756pub(crate) async fn run_doctor_first(
757    state: &ServerState,
758    loaded: &LoadedSubmission,
759) -> Result<serde_json::Value, ServeError> {
760    use faucet_core::check::CheckContext;
761    let auth =
762        build_auth_catalog(loaded.cfg.auth.as_ref()).map_err(|e| ServeError::Unprocessable {
763            message: e.to_string(),
764            details: None,
765        })?;
766    let ctx = CheckContext {
767        timeout: state.probe_timeout(),
768    };
769    let mut invs = crate::commands::doctor::probe_roots(&loaded.nodes, &auth, &ctx).await;
770    let failed = crate::commands::doctor::count_failures(&invs);
771    // Redact regardless of outcome — the report is surfaced either way (as the
772    // 422 `details` on failure, or stored on the run record on success).
773    crate::commands::doctor::redact_invocations(&mut invs);
774    let report = serde_json::json!({ "invocations": invs });
775    if failed > 0 {
776        return Err(ServeError::Unprocessable {
777            message: format!("doctor_first preflight failed: {failed} probe(s) failed"),
778            details: Some(report),
779        });
780    }
781    Ok(report)
782}
783
784/// Build the replay response for an idempotency hit (the existing run's status).
785async fn replay_response(state: &ServerState, run_id: &str) -> Result<SubmitResponse, ServeError> {
786    let rec = state
787        .history()
788        .get(run_id)
789        .await
790        .map_err(|e| ServeError::Internal(e.to_string()))?
791        .ok_or(ServeError::NotFound)?;
792    Ok(SubmitResponse {
793        run_id: rec.run_id,
794        status: rec.status,
795        submitted_at: rec.submitted_at,
796    })
797}
798
799/// Releases a queue reservation on drop unless [`Self::defuse`]d. Guarantees the
800/// `queued` counter is balanced on every early-return path (replay / conflict /
801/// claim-or-upsert error) without a manual `release_reservation` at each site.
802/// Defused once the run is handed to the spawned task, which then owns the
803/// queued→running transition via `mark_running`.
804struct ReservationGuard {
805    state: Option<ServerState>,
806}
807
808impl ReservationGuard {
809    fn new(state: ServerState) -> Self {
810        Self { state: Some(state) }
811    }
812
813    /// Hand the reservation off to the spawned task (no release on drop).
814    fn defuse(mut self) {
815        self.state = None;
816    }
817}
818
819impl Drop for ReservationGuard {
820    fn drop(&mut self) {
821        if let Some(state) = self.state.take() {
822            state.registry().release_reservation();
823            metrics::set_run_gauges(&state);
824        }
825    }
826}
827
828/// Releases the in-flight slot (decrement `in_flight`, drop the cancel token, wake
829/// the shutdown drain) on drop — on EVERY path including panic. Without this, a
830/// panic between `mark_running` and a manual `mark_finished` would leak the
831/// counter and hang graceful shutdown forever.
832struct InFlightGuard {
833    state: ServerState,
834    run_id: String,
835}
836
837impl Drop for InFlightGuard {
838    fn drop(&mut self) {
839        self.state.registry().mark_finished(&self.run_id);
840        metrics::set_run_gauges(&self.state);
841    }
842}
843
844/// Terminal classification of a run task.
845enum Terminal {
846    Completed {
847        records: u64,
848        invs: Vec<InvocationRecord>,
849    },
850    Failed {
851        reason: String,
852        records: u64,
853        invs: Vec<InvocationRecord>,
854    },
855    Timeout {
856        secs: u64,
857    },
858    Cancelled,
859    ShutdownFailed,
860}
861
862impl Terminal {
863    /// (status, metric reason label, records, invocations, error message)
864    fn into_parts(
865        self,
866    ) -> (
867        RunStatus,
868        &'static str,
869        u64,
870        Vec<InvocationRecord>,
871        Option<String>,
872    ) {
873        match self {
874            Terminal::Completed { records, invs } => {
875                (RunStatus::Completed, "ok", records, invs, None)
876            }
877            Terminal::Failed {
878                reason,
879                records,
880                invs,
881            } => (RunStatus::Failed, "error", records, invs, Some(reason)),
882            Terminal::Timeout { secs } => (
883                RunStatus::Failed,
884                "timeout",
885                0,
886                Vec::new(),
887                Some(format!("run exceeded timeout_secs ({secs}s)")),
888            ),
889            Terminal::Cancelled => (RunStatus::Cancelled, "cancelled", 0, Vec::new(), None),
890            Terminal::ShutdownFailed => (
891                RunStatus::Failed,
892                "server_shutdown",
893                0,
894                Vec::new(),
895                Some("server shutdown before the run finished".into()),
896            ),
897        }
898    }
899}
900
901/// Classify a finished `run_expanded` result into a `Terminal`.
902fn classify_run(result: crate::error::CliResult<RunSummary>) -> Terminal {
903    match result {
904        Ok(summary) => {
905            let records: u64 = summary
906                .invocations
907                .iter()
908                .map(|i| i.records_written as u64)
909                .sum();
910            let invs: Vec<InvocationRecord> = summary
911                .invocations
912                .iter()
913                .map(InvocationRecord::from)
914                .collect();
915            if summary.had_failures() {
916                Terminal::Failed {
917                    reason: format!("{} invocation(s) failed", summary.failure_count()),
918                    records,
919                    invs,
920                }
921            } else {
922                Terminal::Completed { records, invs }
923            }
924        }
925        Err(e) => Terminal::Failed {
926            reason: e.to_string(),
927            records: 0,
928            invs: Vec::new(),
929        },
930    }
931}
932
933/// Parse the optional request `clock` (RFC3339), defaulting to `submitted_at`.
934fn resolve_clock(
935    flag: Option<&str>,
936    default: DateTime<Utc>,
937) -> Result<DateTime<FixedOffset>, ServeError> {
938    match flag {
939        None => Ok(default.fixed_offset()),
940        Some(s) => DateTime::parse_from_rfc3339(s)
941            .map_err(|_| ServeError::BadConfig(format!("clock '{s}' is not RFC3339"))),
942    }
943}
944
945/// Spawn the detached run task: acquire a permit, run under the 3-arm select,
946/// finalize the terminal status.
947fn spawn_run(
948    state: ServerState,
949    loaded: LoadedSubmission,
950    req: SubmitRequest,
951    run_id: String,
952    run_token: CancellationToken,
953    submitted_at: DateTime<Utc>,
954) {
955    let server_shutdown = state.shutdown_token();
956    tokio::spawn(async move {
957        // Race the permit acquisition against cancel / shutdown so a run
958        // cancelled while STILL QUEUED (before any permit frees) is finalized
959        // immediately, instead of only after it eventually acquires a permit
960        // (#146 R). `biased` prefers the cancel/shutdown signals over a
961        // simultaneously-available permit.
962        let _permit = tokio::select! {
963            biased;
964            _ = run_token.cancelled() => {
965                finalize_queued_cancel(&state, &run_id, submitted_at, Terminal::Cancelled).await;
966                return;
967            }
968            _ = server_shutdown.cancelled() => {
969                finalize_queued_cancel(&state, &run_id, submitted_at, Terminal::ShutdownFailed).await;
970                return;
971            }
972            permit = state.semaphore().acquire_owned() => permit.expect("semaphore not closed"),
973        };
974        execute_run(
975            state,
976            loaded,
977            run_id,
978            run_token,
979            submitted_at,
980            req.timeout_secs,
981            req.clock,
982            true,
983        )
984        .await;
985        // `_permit` drops here.
986    });
987}
988
989/// The queued→running→finalize execution tail, shared by the submit path
990/// (`spawn_run`) and the cluster claim path (`resume_claimed_run`). Assumes the
991/// caller already holds an execution permit and has registered `run_token`.
992/// `from_queue` is `true` when the run consumed a local queue slot (submit path)
993/// and `false` for a cluster claim-path run that never reserved one (#228).
994#[allow(clippy::too_many_arguments)]
995async fn execute_run(
996    state: ServerState,
997    loaded: LoadedSubmission,
998    run_id: String,
999    run_token: CancellationToken,
1000    submitted_at: DateTime<Utc>,
1001    timeout_secs: Option<u64>,
1002    clock_flag: Option<String>,
1003    from_queue: bool,
1004) {
1005    let server_shutdown = state.shutdown_token();
1006    let LoadedSubmission { cfg, nodes } = loaded;
1007
1008    // Queued → running. From here the guard guarantees `mark_finished` (and a
1009    // gauge refresh) on EVERY exit, including early returns and panics.
1010    // A submit-path run consumed a local queue slot (Queued→Running); a cluster
1011    // claim-path run never did (#228) — only bump in_flight for it.
1012    if from_queue {
1013        state.registry().mark_running();
1014    } else {
1015        state.registry().mark_running_unqueued();
1016    }
1017    let _guard = InFlightGuard {
1018        state: state.clone(),
1019        run_id: run_id.clone(),
1020    };
1021    let started = Utc::now();
1022    if let Ok(Some(mut rec)) = state.history().get(&run_id).await {
1023        rec.status = RunStatus::Running;
1024        rec.started_at = Some(started);
1025        let _ = state.history().upsert(&rec).await;
1026    }
1027    metrics::set_run_gauges(&state);
1028
1029    // Build execution options (auth/clock failures finalize as Failed).
1030    let pipeline_name = cfg.name.clone().unwrap_or_else(|| "serve".to_string());
1031    let auth = match build_auth_catalog(cfg.auth.as_ref()) {
1032        Ok(a) => a,
1033        Err(e) => {
1034            finalize(
1035                &state,
1036                &run_id,
1037                started,
1038                Terminal::Failed {
1039                    reason: format!("auth catalog: {e}"),
1040                    records: 0,
1041                    invs: Vec::new(),
1042                },
1043            )
1044            .await;
1045            return;
1046        }
1047    };
1048    let clock = match resolve_clock(clock_flag.as_deref(), submitted_at) {
1049        Ok(c) => c,
1050        Err(e) => {
1051            finalize(
1052                &state,
1053                &run_id,
1054                started,
1055                Terminal::Failed {
1056                    reason: e.api_error().error.message,
1057                    records: 0,
1058                    invs: Vec::new(),
1059                },
1060            )
1061            .await;
1062            return;
1063        }
1064    };
1065
1066    // Cooperative-cancel token the pipeline observes so it flushes buffered
1067    // output (e.g. a Parquet footer, an S3 multipart upload) at its next
1068    // page boundary on cancel / timeout / shutdown — instead of having its
1069    // future hard-dropped, which flushes nothing (#146 H16).
1070    let coop = CancellationToken::new();
1071    // Resilience policy from the (merged) submitted config. A malformed
1072    // `resilience:` block finalizes the run as Failed, mirroring the
1073    // auth/clock failure handling above.
1074    let resilience = match &cfg.resilience {
1075        Some(spec) => match spec.to_policy() {
1076            Ok(p) => Some(p),
1077            Err(e) => {
1078                finalize(
1079                    &state,
1080                    &run_id,
1081                    started,
1082                    Terminal::Failed {
1083                        reason: format!("resilience: {e}"),
1084                        records: 0,
1085                        invs: Vec::new(),
1086                    },
1087                )
1088                .await;
1089                return;
1090            }
1091        },
1092        None => None,
1093    };
1094    // Build the per-run OpenLineage emitter from the (merged) submitted
1095    // config. A malformed `lineage:` block finalizes the run as Failed,
1096    // mirroring the auth/clock failure handling above.
1097    #[cfg(feature = "lineage")]
1098    let lineage = match crate::lineage_glue::build_emitter(cfg.lineage.as_ref()) {
1099        Ok(l) => l,
1100        Err(e) => {
1101            finalize(
1102                &state,
1103                &run_id,
1104                started,
1105                Terminal::Failed {
1106                    reason: format!("lineage: {e}"),
1107                    records: 0,
1108                    invs: Vec::new(),
1109                },
1110            )
1111            .await;
1112            return;
1113        }
1114    };
1115    let opts = ExecuteOptions {
1116        pipeline_name,
1117        execution: cfg.execution.clone(),
1118        dry_run: false,
1119        limit: None,
1120        state_path_override: None,
1121        shard: None,
1122        auth,
1123        clock,
1124        cancel: Some(coop.clone()),
1125        resilience,
1126        #[cfg(feature = "lineage")]
1127        lineage,
1128        #[cfg(feature = "lineage")]
1129        lineage_cfg: cfg.lineage.clone(),
1130    };
1131
1132    let span = tracing::info_span!("faucet.serve.run", serve_run_id = %run_id);
1133    let work = async move {
1134        // Emitted inside the run span so it is captured by the SSE log layer
1135        // (and gives every `/logs` reader at least one line to anchor on).
1136        tracing::info!("pipeline run starting");
1137        classify_run(run_expanded(nodes, opts).await)
1138    }
1139    .instrument(span);
1140    tokio::pin!(work);
1141
1142    // The run timeout is modelled as a cancel trigger (not a hard
1143    // `tokio::time::timeout` drop) so a timed-out run still flushes.
1144    let timeout_fut = async {
1145        match timeout_secs {
1146            Some(s) => tokio::time::sleep(Duration::from_secs(s)).await,
1147            None => std::future::pending::<()>().await,
1148        }
1149    };
1150    tokio::pin!(timeout_fut);
1151
1152    enum Trigger {
1153        Done(Terminal),
1154        Cancel,
1155        Shutdown,
1156        Timeout(u64),
1157    }
1158
1159    // Phase 1: run to natural completion, or until a cancel trigger fires.
1160    // `biased` prefers a just-completed run over a simultaneous trigger.
1161    let trigger = tokio::select! {
1162        biased;
1163        t = &mut work => Trigger::Done(t),
1164        _ = run_token.cancelled() => Trigger::Cancel,
1165        _ = server_shutdown.cancelled() => Trigger::Shutdown,
1166        _ = &mut timeout_fut => Trigger::Timeout(timeout_secs.unwrap_or(0)),
1167    };
1168
1169    let terminal = match trigger {
1170        Trigger::Done(t) => t,
1171        triggered => {
1172            // Phase 2: a trigger fired. Cancel cooperatively and give the
1173            // pipeline a bounded grace to flush at its next page boundary,
1174            // then hard-drop it (drops the JoinSet, aborting any pipeline
1175            // genuinely stuck mid-write) so a hung run can't wedge shutdown.
1176            coop.cancel();
1177            let trigger_terminal = match triggered {
1178                Trigger::Cancel => Terminal::Cancelled,
1179                Trigger::Shutdown => Terminal::ShutdownFailed,
1180                Trigger::Timeout(secs) => Terminal::Timeout { secs },
1181                Trigger::Done(_) => unreachable!("matched in the outer arm"),
1182            };
1183            // A cooperative cancel makes `run_stream` flush and return Ok(partial),
1184            // so the trigger label (Cancelled/Timeout/ShutdownFailed) is the
1185            // correct status for that path. But if the flush itself FAILS within
1186            // the grace window, surface that real failure — never mask it behind
1187            // the trigger label, which would hide a data error / partial write.
1188            match tokio::time::timeout(RUN_FLUSH_GRACE, &mut work).await {
1189                Ok(failed @ Terminal::Failed { .. }) => failed,
1190                Ok(_) | Err(_) => trigger_terminal,
1191            }
1192        }
1193    };
1194
1195    finalize(&state, &run_id, started, terminal).await;
1196    // Signal `/logs` readers the run is done, then drop the buffer after a
1197    // drain window so a late fetcher can still replay it (spec §12).
1198    state.log_hub().finish(&run_id);
1199    schedule_log_drop(state.clone(), run_id.clone());
1200    // `_guard` drops here → mark_finished + gauge refresh.
1201}
1202
1203/// Write the authoritative terminal record + the run-finished metric.
1204async fn finalize(state: &ServerState, run_id: &str, started: DateTime<Utc>, term: Terminal) {
1205    let finished = Utc::now();
1206    let elapsed = (finished - started).to_std().ok().map(|d| d.as_secs_f64());
1207    let (status, reason, records, invs, error) = term.into_parts();
1208    // Read-modify-write the existing record to preserve its metadata
1209    // (name / labels / idempotency_key / submitted_at). If it can't be read —
1210    // the backend errored, or the record was purged / landed in another store
1211    // under degraded fallback — DON'T silently drop the terminal status (#146
1212    // M6): reconstruct a minimal terminal record and upsert it, so the run
1213    // never lingers non-terminal while `record_run_finished` has already fired.
1214    let mut rec = match state.history().get(run_id).await {
1215        Ok(Some(rec)) => rec,
1216        Ok(None) => {
1217            tracing::warn!(
1218                run_id,
1219                "finalize: run record not found; writing a fresh terminal record"
1220            );
1221            RunRecord::queued(run_id.to_string(), None, BTreeMap::new(), None, started)
1222        }
1223        Err(e) => {
1224            tracing::warn!(
1225                run_id,
1226                error = %e,
1227                "finalize: failed to read run record; writing a fresh terminal record"
1228            );
1229            RunRecord::queued(run_id.to_string(), None, BTreeMap::new(), None, started)
1230        }
1231    };
1232    rec.status = status;
1233    rec.started_at.get_or_insert(started);
1234    rec.finished_at = Some(finished);
1235    rec.elapsed_secs = elapsed;
1236    rec.records_written = records;
1237    rec.invocations = invs;
1238    rec.error = error;
1239    if state.cluster().enabled() {
1240        // Owner-fenced: if another instance reclaimed this run (lease expired),
1241        // our write is a no-op and we discard our result — the reclaimer is now
1242        // authoritative (#197).
1243        match state.history().finalize_owned(&rec).await {
1244            Ok(true) => metrics::record_run_finished(status, reason),
1245            Ok(false) => tracing::warn!(
1246                run_id,
1247                "finalize: run was reclaimed by another instance; discarding result"
1248            ),
1249            Err(e) => {
1250                tracing::error!(run_id, error = %e, "finalize: owner-fenced write failed")
1251            }
1252        }
1253    } else {
1254        if let Err(e) = state.history().upsert(&rec).await {
1255            tracing::error!(
1256                run_id,
1257                error = %e,
1258                "finalize: failed to persist terminal run record"
1259            );
1260        }
1261        metrics::record_run_finished(status, reason);
1262    }
1263}
1264
1265/// Finalize a run that was cancelled / hit shutdown while still QUEUED (before
1266/// it acquired an execution permit, so it never became in-flight and has no
1267/// `InFlightGuard`). Releases the queue slot, writes the terminal record, closes
1268/// the log buffer, and refreshes the gauges — the queued-path analogue of the
1269/// normal `finalize` + `InFlightGuard`-drop cleanup.
1270async fn finalize_queued_cancel(
1271    state: &ServerState,
1272    run_id: &str,
1273    submitted_at: DateTime<Utc>,
1274    term: Terminal,
1275) {
1276    state.registry().mark_queued_cancelled(run_id);
1277    finalize(state, run_id, submitted_at, term).await;
1278    state.log_hub().finish(run_id);
1279    schedule_log_drop(state.clone(), run_id.to_string());
1280    metrics::set_run_gauges(state);
1281}
1282
1283/// Spawn a detached timer that drops a finished run's log buffer after the drain
1284/// window, freeing its ring once late `/logs` fetchers have had a chance to read.
1285fn schedule_log_drop(state: ServerState, run_id: String) {
1286    tokio::spawn(async move {
1287        tokio::time::sleep(crate::serve::logs::LOG_DRAIN).await;
1288        state.log_hub().drop_run(&run_id);
1289    });
1290}
1291
1292#[cfg(test)]
1293mod tests {
1294    use super::*;
1295
1296    #[test]
1297    fn classify_ok_no_failures_is_completed() {
1298        let summary = RunSummary {
1299            invocations: vec![crate::executor::InvocationOutcome {
1300                row_id: "r".into(),
1301                parent_record_key: None,
1302                records_written: 3,
1303                error: None,
1304            }],
1305        };
1306        let (status, reason, records, _, error) = classify_run(Ok(summary)).into_parts();
1307        assert_eq!(status, RunStatus::Completed);
1308        assert_eq!(reason, "ok");
1309        assert_eq!(records, 3);
1310        assert!(error.is_none());
1311    }
1312
1313    #[test]
1314    fn classify_ok_with_failures_is_failed() {
1315        let summary = RunSummary {
1316            invocations: vec![crate::executor::InvocationOutcome {
1317                row_id: "r".into(),
1318                parent_record_key: None,
1319                records_written: 0,
1320                error: Some("boom".into()),
1321            }],
1322        };
1323        let (status, reason, _, _, error) = classify_run(Ok(summary)).into_parts();
1324        assert_eq!(status, RunStatus::Failed);
1325        assert_eq!(reason, "error");
1326        assert!(error.unwrap().contains("invocation(s) failed"));
1327    }
1328
1329    #[test]
1330    fn timeout_maps_to_failed_with_timeout_reason() {
1331        let (status, reason, _, _, error) = Terminal::Timeout { secs: 30 }.into_parts();
1332        assert_eq!(status, RunStatus::Failed);
1333        assert_eq!(reason, "timeout");
1334        assert!(error.unwrap().contains("30s"));
1335    }
1336
1337    #[test]
1338    fn resolve_clock_defaults_and_parses() {
1339        let default = Utc::now();
1340        assert_eq!(
1341            resolve_clock(None, default).unwrap(),
1342            default.fixed_offset()
1343        );
1344        assert!(resolve_clock(Some("2026-01-31T00:00:00Z"), default).is_ok());
1345        assert!(resolve_clock(Some("not-a-time"), default).is_err());
1346    }
1347
1348    #[tokio::test]
1349    async fn conflict_releases_reservation() {
1350        use crate::serve::config::{AuthMode, HistoryBackendSpec, ServeConfig};
1351        use crate::serve::history::RunHistory;
1352        use crate::serve::history::memory::MemoryHistory;
1353        use crate::serve::state::ServerState;
1354        use std::sync::Arc;
1355        use tokio_util::sync::CancellationToken;
1356
1357        let cfg = ServeConfig {
1358            listen: "127.0.0.1:0".parse().unwrap(),
1359            auth: AuthMode::None,
1360            max_concurrent_runs: 4,
1361            max_queued_runs: 4,
1362            default_config_path: None,
1363            history: HistoryBackendSpec::Memory,
1364            cors_origins: vec![],
1365            body_limit_bytes: 1_048_576,
1366            shutdown_grace: Duration::from_secs(60),
1367            retain_terminal_runs: Duration::from_secs(60),
1368            idempotency_retention: Duration::from_secs(60),
1369            lease_ttl: Duration::from_secs(30),
1370            probe_timeout: Duration::from_secs(10),
1371            env_file: None,
1372            no_env_file: false,
1373            log_level: "info".into(),
1374            ui_enabled: true,
1375            cluster: crate::serve::cluster::ClusterConfig::disabled(),
1376            triggers_path: None,
1377        };
1378        let history = Arc::new(MemoryHistory::new(Duration::from_secs(60))) as Arc<dyn RunHistory>;
1379        let state = ServerState::new(
1380            &cfg,
1381            None,
1382            CancellationToken::new(),
1383            history,
1384            crate::serve::logs::LogHub::new(),
1385            None,
1386            #[cfg(feature = "triggers")]
1387            crate::serve::triggers::health::TriggersHandle::empty(),
1388        );
1389
1390        // Pre-claim the key with a DIFFERENT fingerprint so submit() hits Conflict.
1391        state
1392            .history()
1393            .claim_idempotency("k", "different-fp", "prior", Duration::from_secs(60))
1394            .await
1395            .unwrap();
1396
1397        let req = SubmitRequest {
1398            config: "version: 1\npipeline:\n  source: { type: csv, config: { path: x.csv } }\n  sink: { type: jsonl, config: { path: out.jsonl } }\n".into(),
1399            config_format: ConfigFormatWire::Yaml,
1400            name: None,
1401            labels: BTreeMap::new(),
1402            timeout_secs: None,
1403            doctor_first: false,
1404            idempotency_key: Some("k".into()),
1405            clock: None,
1406        };
1407
1408        let err = submit(state.clone(), req).await.unwrap_err();
1409        assert!(
1410            matches!(err, ServeError::Conflict(_)),
1411            "expected Conflict, got {err:?}"
1412        );
1413        // The reservation taken before the claim must have been released by the guard.
1414        assert_eq!(state.registry().queued(), 0);
1415    }
1416
1417    #[tokio::test]
1418    async fn cluster_submit_writes_pending_with_config_and_does_not_spawn() {
1419        use crate::serve::cluster::ClusterConfig;
1420        use crate::serve::config::{AuthMode, HistoryBackendSpec, ServeConfig};
1421        use crate::serve::history::RunHistory;
1422        use crate::serve::history::memory::MemoryHistory;
1423        use crate::serve::state::ServerState;
1424        use std::sync::Arc;
1425        use tokio_util::sync::CancellationToken;
1426
1427        let mut cluster = ClusterConfig::disabled();
1428        cluster.enabled = true;
1429        let cfg = ServeConfig {
1430            listen: "127.0.0.1:0".parse().unwrap(),
1431            auth: AuthMode::None,
1432            max_concurrent_runs: 4,
1433            max_queued_runs: 4,
1434            default_config_path: None,
1435            history: HistoryBackendSpec::Memory,
1436            cors_origins: vec![],
1437            body_limit_bytes: 1_048_576,
1438            shutdown_grace: Duration::from_secs(60),
1439            retain_terminal_runs: Duration::from_secs(60),
1440            idempotency_retention: Duration::from_secs(60),
1441            lease_ttl: Duration::from_secs(30),
1442            probe_timeout: Duration::from_secs(10),
1443            env_file: None,
1444            no_env_file: false,
1445            log_level: "info".into(),
1446            ui_enabled: true,
1447            cluster,
1448            triggers_path: None,
1449        };
1450        let history = Arc::new(MemoryHistory::new(Duration::from_secs(60))) as Arc<dyn RunHistory>;
1451        let state = ServerState::new(
1452            &cfg,
1453            None,
1454            CancellationToken::new(),
1455            history,
1456            crate::serve::logs::LogHub::new(),
1457            None,
1458            #[cfg(feature = "triggers")]
1459            crate::serve::triggers::health::TriggersHandle::empty(),
1460        );
1461
1462        let req = SubmitRequest {
1463            config: "version: 1\npipeline:\n  source: { type: csv, config: { path: x.csv } }\n  sink: { type: jsonl, config: { path: out.jsonl } }\n".into(),
1464            config_format: ConfigFormatWire::Yaml,
1465            name: Some("n".into()),
1466            labels: BTreeMap::new(),
1467            timeout_secs: Some(99),
1468            doctor_first: false,
1469            idempotency_key: None,
1470            clock: None,
1471        };
1472        let resp = submit(state.clone(), req).await.unwrap();
1473        assert_eq!(resp.status, RunStatus::Pending);
1474        // No local queue slot was consumed (cluster runs don't queue locally).
1475        assert_eq!(state.registry().queued(), 0);
1476        let rec = state.history().get(&resp.run_id).await.unwrap().unwrap();
1477        assert_eq!(rec.status, RunStatus::Pending);
1478        assert!(rec.config_body.as_deref().unwrap().contains("version: 1"));
1479        assert_eq!(rec.timeout_secs, Some(99));
1480    }
1481
1482    /// A `ServerState` backed by an in-memory history, for finalize tests.
1483    fn memory_state() -> crate::serve::state::ServerState {
1484        use crate::serve::config::{AuthMode, HistoryBackendSpec, ServeConfig};
1485        use crate::serve::history::RunHistory;
1486        use crate::serve::history::memory::MemoryHistory;
1487        use crate::serve::state::ServerState;
1488        use std::sync::Arc;
1489        use tokio_util::sync::CancellationToken;
1490
1491        let cfg = ServeConfig {
1492            listen: "127.0.0.1:0".parse().unwrap(),
1493            auth: AuthMode::None,
1494            max_concurrent_runs: 4,
1495            max_queued_runs: 4,
1496            default_config_path: None,
1497            history: HistoryBackendSpec::Memory,
1498            cors_origins: vec![],
1499            body_limit_bytes: 1_048_576,
1500            shutdown_grace: Duration::from_secs(60),
1501            retain_terminal_runs: Duration::from_secs(60),
1502            idempotency_retention: Duration::from_secs(60),
1503            lease_ttl: Duration::from_secs(30),
1504            probe_timeout: Duration::from_secs(10),
1505            env_file: None,
1506            no_env_file: false,
1507            log_level: "info".into(),
1508            ui_enabled: true,
1509            cluster: crate::serve::cluster::ClusterConfig::disabled(),
1510            triggers_path: None,
1511        };
1512        let history = Arc::new(MemoryHistory::new(Duration::from_secs(60))) as Arc<dyn RunHistory>;
1513        ServerState::new(
1514            &cfg,
1515            None,
1516            CancellationToken::new(),
1517            history,
1518            crate::serve::logs::LogHub::new(),
1519            None,
1520            #[cfg(feature = "triggers")]
1521            crate::serve::triggers::health::TriggersHandle::empty(),
1522        )
1523    }
1524
1525    #[tokio::test]
1526    async fn finalize_writes_terminal_record_when_record_is_missing() {
1527        // M6 (#146): if the run record can't be read at finalize time (purged,
1528        // or split to another store under degraded fallback), the terminal
1529        // status must NOT be silently dropped — a fresh terminal record is
1530        // written so the run never lingers non-terminal while the run-finished
1531        // metric has already fired.
1532        let state = memory_state();
1533        let started = Utc::now();
1534        finalize(
1535            &state,
1536            "ghost",
1537            started,
1538            Terminal::Failed {
1539                reason: "boom".into(),
1540                records: 0,
1541                invs: Vec::new(),
1542            },
1543        )
1544        .await;
1545        let rec = state
1546            .history()
1547            .get("ghost")
1548            .await
1549            .unwrap()
1550            .expect("finalize must create a terminal record even when none existed");
1551        assert_eq!(rec.status, RunStatus::Failed);
1552        assert!(rec.finished_at.is_some());
1553        assert!(rec.started_at.is_some());
1554        assert_eq!(rec.error.as_deref(), Some("boom"));
1555    }
1556
1557    #[tokio::test]
1558    async fn finalize_preserves_metadata_of_existing_record() {
1559        // The happy path still read-modify-writes, preserving name/labels/key.
1560        let state = memory_state();
1561        let started = Utc::now();
1562        let mut rec = RunRecord::queued(
1563            "r1".into(),
1564            Some("nightly".into()),
1565            BTreeMap::new(),
1566            Some("idem-k".into()),
1567            started,
1568        );
1569        rec.status = RunStatus::Running;
1570        rec.started_at = Some(started);
1571        state.history().upsert(&rec).await.unwrap();
1572
1573        finalize(
1574            &state,
1575            "r1",
1576            started,
1577            Terminal::Completed {
1578                records: 5,
1579                invs: Vec::new(),
1580            },
1581        )
1582        .await;
1583        let got = state.history().get("r1").await.unwrap().unwrap();
1584        assert_eq!(got.status, RunStatus::Completed);
1585        assert_eq!(got.records_written, 5);
1586        assert_eq!(got.name.as_deref(), Some("nightly"));
1587        assert_eq!(got.idempotency_key.as_deref(), Some("idem-k"));
1588    }
1589
1590    #[cfg(any(feature = "serve-history-sqlite", feature = "serve-history-postgres"))]
1591    #[tokio::test]
1592    async fn cluster_submit_503s_when_history_degraded() {
1593        // #197 spec §9: a degraded backend can't coordinate a cluster, so submit
1594        // must fail closed with 503 rather than orphan a never-claimable Pending run.
1595        use crate::serve::cluster::ClusterConfig;
1596        use crate::serve::config::{AuthMode, HistoryBackendSpec, ServeConfig};
1597        use crate::serve::history::RunHistory;
1598        use crate::serve::history::fallback::FallbackHistory;
1599        use crate::serve::state::ServerState;
1600        use std::sync::Arc;
1601        use tokio_util::sync::CancellationToken;
1602
1603        let mut cluster = ClusterConfig::disabled();
1604        cluster.enabled = true;
1605        let cfg = ServeConfig {
1606            listen: "127.0.0.1:0".parse().unwrap(),
1607            auth: AuthMode::None,
1608            max_concurrent_runs: 4,
1609            max_queued_runs: 4,
1610            default_config_path: None,
1611            history: HistoryBackendSpec::Memory,
1612            cors_origins: vec![],
1613            body_limit_bytes: 1_048_576,
1614            shutdown_grace: Duration::from_secs(60),
1615            retain_terminal_runs: Duration::from_secs(60),
1616            idempotency_retention: Duration::from_secs(60),
1617            lease_ttl: Duration::from_secs(30),
1618            probe_timeout: Duration::from_secs(10),
1619            env_file: None,
1620            no_env_file: false,
1621            log_level: "info".into(),
1622            ui_enabled: true,
1623            cluster,
1624            triggers_path: None,
1625        };
1626        // A backend that is degraded from startup (primary unreachable).
1627        let history = Arc::new(FallbackHistory::degraded_at_startup(
1628            Duration::from_secs(60),
1629            "test",
1630        )) as Arc<dyn RunHistory>;
1631        assert!(history.degraded());
1632        let state = ServerState::new(
1633            &cfg,
1634            None,
1635            CancellationToken::new(),
1636            history,
1637            crate::serve::logs::LogHub::new(),
1638            None,
1639            #[cfg(feature = "triggers")]
1640            crate::serve::triggers::health::TriggersHandle::empty(),
1641        );
1642        let req = SubmitRequest {
1643            config: "version: 1\npipeline:\n  source: { type: csv, config: { path: x.csv } }\n  sink: { type: jsonl, config: { path: out.jsonl } }\n".into(),
1644            config_format: ConfigFormatWire::Yaml,
1645            name: None,
1646            labels: BTreeMap::new(),
1647            timeout_secs: None,
1648            doctor_first: false,
1649            idempotency_key: None,
1650            clock: None,
1651        };
1652        let err = submit(state.clone(), req).await.unwrap_err();
1653        assert!(
1654            matches!(err, ServeError::Unavailable(_)),
1655            "expected 503 Unavailable, got {err:?}"
1656        );
1657        // The queue reservation must have been released (no leak).
1658        assert_eq!(state.registry().queued(), 0);
1659    }
1660
1661    // ── Mode B coverage: coordinator / parent finalize / shard execution ─────
1662    // SQLite-backed so the shard RunHistory methods are live (the memory backend
1663    // is inert for shards). No Docker: the S3 source builds offline and its
1664    // enumerate_shards is pure (hash-modulo); csv→jsonl runs entirely on temp
1665    // files.
1666    #[cfg(feature = "serve-history-sqlite")]
1667    mod shards {
1668        use super::*;
1669        use crate::serve::config::{AuthMode, HistoryBackendSpec, ServeConfig};
1670        use crate::serve::history::RunHistory;
1671        use crate::serve::history::sqlite::SqliteHistory;
1672        use crate::serve::load::{ConfigFormat, load_submission};
1673        use crate::serve::state::ServerState;
1674        use faucet_core::ShardSpec;
1675        use std::collections::BTreeMap;
1676        use std::sync::Arc;
1677        use tokio_util::sync::CancellationToken;
1678
1679        async fn sqlite_state(dir: &std::path::Path) -> ServerState {
1680            let url = format!("sqlite://{}/h.db", dir.display());
1681            let history = Arc::new(
1682                SqliteHistory::connect(
1683                    &url,
1684                    Duration::from_secs(300),
1685                    Duration::from_secs(300),
1686                    "inst-test".into(),
1687                )
1688                .await
1689                .expect("sqlite history"),
1690            ) as Arc<dyn RunHistory>;
1691            let cfg = ServeConfig {
1692                listen: "127.0.0.1:0".parse().unwrap(),
1693                auth: AuthMode::None,
1694                max_concurrent_runs: 4,
1695                max_queued_runs: 4,
1696                default_config_path: None,
1697                history: HistoryBackendSpec::Memory,
1698                cors_origins: vec![],
1699                body_limit_bytes: 1_048_576,
1700                shutdown_grace: Duration::from_secs(60),
1701                retain_terminal_runs: Duration::from_secs(60),
1702                idempotency_retention: Duration::from_secs(60),
1703                lease_ttl: Duration::from_secs(30),
1704                probe_timeout: Duration::from_secs(10),
1705                env_file: None,
1706                no_env_file: false,
1707                log_level: "info".into(),
1708                ui_enabled: true,
1709                cluster: crate::serve::cluster::ClusterConfig::disabled(),
1710                triggers_path: None,
1711            };
1712            ServerState::new(
1713                &cfg,
1714                None,
1715                CancellationToken::new(),
1716                history,
1717                crate::serve::logs::LogHub::new(),
1718                None,
1719                #[cfg(feature = "triggers")]
1720                crate::serve::triggers::health::TriggersHandle::empty(),
1721            )
1722        }
1723
1724        async fn loaded(yaml: &str) -> LoadedSubmission {
1725            load_submission(yaml, ConfigFormat::Yaml, None)
1726                .await
1727                .expect("load submission")
1728        }
1729
1730        async fn seed_run(state: &ServerState, run_id: &str, status: RunStatus) {
1731            let mut rec = RunRecord::queued(run_id.into(), None, BTreeMap::new(), None, Utc::now());
1732            rec.status = status;
1733            rec.config_body = Some("version: 1".into());
1734            state.history().upsert(&rec).await.expect("seed run");
1735        }
1736
1737        #[tokio::test]
1738        async fn coordinate_matrix_run_is_not_shardable() {
1739            // A matrix expands to >1 node → not shardable → Ok(false), no build.
1740            let dir = tempfile::tempdir().unwrap();
1741            let state = sqlite_state(dir.path()).await;
1742            let l = loaded(
1743                "version: 1\nname: m\nmatrix:\n  - id: a\n  - id: b\npipeline:\n  \
1744                 source: { type: rest, config: { url: \"http://localhost/x\" } }\n  \
1745                 sink: { type: stdout, config: {} }\n",
1746            )
1747            .await;
1748            assert!(!coordinate_sharded_run(&state, "r", &l, 4).await.unwrap());
1749        }
1750
1751        #[tokio::test]
1752        async fn coordinate_non_shardable_source_runs_whole() {
1753            // A csv source is not shardable → Ok(false) (built offline).
1754            let dir = tempfile::tempdir().unwrap();
1755            let state = sqlite_state(dir.path()).await;
1756            let input = dir.path().join("in.csv");
1757            std::fs::write(&input, "id\n1\n").unwrap();
1758            let l = loaded(&format!(
1759                "version: 1\npipeline:\n  \
1760                 source: {{ type: csv, config: {{ path: \"{}\" }} }}\n  \
1761                 sink: {{ type: stdout, config: {{}} }}\n",
1762                input.display()
1763            ))
1764            .await;
1765            assert!(!coordinate_sharded_run(&state, "r", &l, 4).await.unwrap());
1766        }
1767
1768        #[tokio::test]
1769        async fn coordinate_s3_source_inserts_shards_and_marks_sharded() {
1770            let dir = tempfile::tempdir().unwrap();
1771            let state = sqlite_state(dir.path()).await;
1772            seed_run(&state, "r", RunStatus::Running).await;
1773            let l = loaded(
1774                "version: 1\npipeline:\n  \
1775                 source: { type: s3, config: { bucket: my-bucket, prefix: null, \
1776                 region: null, endpoint_url: null, file_format: json_lines, \
1777                 max_objects: null, concurrency: 10 } }\n  \
1778                 sink: { type: stdout, config: {} }\n",
1779            )
1780            .await;
1781            assert!(coordinate_sharded_run(&state, "r", &l, 4).await.unwrap());
1782            // 4 shards inserted; parent flipped to Sharded.
1783            let prog = state.history().shard_progress("r").await.unwrap();
1784            assert_eq!(prog.total, 4);
1785            assert_eq!(prog.pending, 4);
1786            assert_eq!(
1787                state.history().get("r").await.unwrap().unwrap().status,
1788                RunStatus::Sharded
1789            );
1790        }
1791
1792        #[tokio::test]
1793        async fn at_least_once_risky_sinks_flags_append_cluster_and_shard() {
1794            // F26/F39: a clustered or sharded run with an append-mode sink is
1795            // at-least-once → flagged. Neither flag → not flagged.
1796            let append = loaded(
1797                "version: 1\npipeline:\n  \
1798                 source: { type: rest, config: { url: \"http://localhost/x\" } }\n  \
1799                 sink: { type: stdout, config: {} }\n",
1800            )
1801            .await;
1802            // Not clustered, not sharded → no warning.
1803            assert!(at_least_once_risky_sinks(&append, false, false).is_empty());
1804            // Clustered append → flagged.
1805            assert_eq!(
1806                at_least_once_risky_sinks(&append, true, false),
1807                vec!["stdout"]
1808            );
1809            // Sharded append → flagged.
1810            assert_eq!(
1811                at_least_once_risky_sinks(&append, false, true),
1812                vec!["stdout"]
1813            );
1814        }
1815
1816        #[tokio::test]
1817        async fn at_least_once_risky_sinks_safe_for_upsert_and_exactly_once() {
1818            // Upsert sink → keyed re-writes are idempotent → not flagged.
1819            let upsert = loaded(
1820                "version: 1\npipeline:\n  \
1821                 source: { type: postgres, config: { connection_url: \"postgres://x\", \
1822                 query: \"select 1\" } }\n  \
1823                 sink: { type: postgres, config: { connection_url: \"postgres://y\", \
1824                 table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }\n",
1825            )
1826            .await;
1827            assert!(at_least_once_risky_sinks(&upsert, true, true).is_empty());
1828
1829            // exactly_once delivery → not flagged even with an append sink.
1830            let eo = loaded(
1831                "version: 1\ndelivery: exactly_once\npipeline:\n  \
1832                 source: { type: postgres-cdc, config: {} }\n  \
1833                 sink: { type: sqlite, config: {} }\n  \
1834                 state: { type: file, config: { path: \"/tmp/x.json\" } }\n",
1835            )
1836            .await;
1837            assert!(at_least_once_risky_sinks(&eo, true, false).is_empty());
1838        }
1839
1840        async fn seed_sharded_with_shards(state: &ServerState, run_id: &str, n: usize) {
1841            use crate::serve::history::ShardInsert;
1842            seed_run(state, run_id, RunStatus::Sharded).await;
1843            let shards: Vec<ShardInsert> = (0..n)
1844                .map(|i| ShardInsert {
1845                    shard_id: i.to_string(),
1846                    descriptor: serde_json::json!({ "i": i }),
1847                    size_estimate: None,
1848                })
1849                .collect();
1850            state
1851                .history()
1852                .insert_shards(run_id, &shards)
1853                .await
1854                .unwrap();
1855            // Claim them so they are 'running' and finalizable by this instance.
1856            let claimed = state.history().claim_shards(n).await.unwrap();
1857            assert_eq!(claimed.len(), n);
1858        }
1859
1860        #[tokio::test]
1861        async fn maybe_finalize_parent_completes_when_all_shards_succeed() {
1862            let dir = tempfile::tempdir().unwrap();
1863            let state = sqlite_state(dir.path()).await;
1864            seed_sharded_with_shards(&state, "r", 3).await;
1865            for i in 0..3 {
1866                state
1867                    .history()
1868                    .finalize_shard("r", &i.to_string(), true)
1869                    .await
1870                    .unwrap();
1871            }
1872            maybe_finalize_parent(&state, "r").await;
1873            assert_eq!(
1874                state.history().get("r").await.unwrap().unwrap().status,
1875                RunStatus::Completed
1876            );
1877        }
1878
1879        #[tokio::test]
1880        async fn maybe_finalize_parent_fails_when_a_shard_fails() {
1881            let dir = tempfile::tempdir().unwrap();
1882            let state = sqlite_state(dir.path()).await;
1883            seed_sharded_with_shards(&state, "r", 2).await;
1884            state
1885                .history()
1886                .finalize_shard("r", "0", true)
1887                .await
1888                .unwrap();
1889            state
1890                .history()
1891                .finalize_shard("r", "1", false)
1892                .await
1893                .unwrap();
1894            maybe_finalize_parent(&state, "r").await;
1895            assert_eq!(
1896                state.history().get("r").await.unwrap().unwrap().status,
1897                RunStatus::Failed
1898            );
1899        }
1900
1901        #[tokio::test]
1902        async fn maybe_finalize_parent_keeps_sharded_until_all_terminal() {
1903            let dir = tempfile::tempdir().unwrap();
1904            let state = sqlite_state(dir.path()).await;
1905            seed_sharded_with_shards(&state, "r", 2).await;
1906            // Only one shard finalized → run stays Sharded.
1907            state
1908                .history()
1909                .finalize_shard("r", "0", true)
1910                .await
1911                .unwrap();
1912            maybe_finalize_parent(&state, "r").await;
1913            assert_eq!(
1914                state.history().get("r").await.unwrap().unwrap().status,
1915                RunStatus::Sharded
1916            );
1917        }
1918
1919        #[tokio::test]
1920        async fn finalize_sharded_parent_is_status_fenced_and_idempotent() {
1921            // F45: the status-fenced finalize transitions the parent exactly
1922            // once. A concurrent second finalize (e.g. two shards finishing on
1923            // two instances) is a no-op, and a non-Sharded run is never touched.
1924            let dir = tempfile::tempdir().unwrap();
1925            let state = sqlite_state(dir.path()).await;
1926            seed_sharded_with_shards(&state, "r", 2).await;
1927
1928            let first = state
1929                .history()
1930                .finalize_sharded_parent("r", RunStatus::Completed, Utc::now(), None)
1931                .await
1932                .unwrap();
1933            assert!(first, "first finalize wins");
1934            assert_eq!(
1935                state.history().get("r").await.unwrap().unwrap().status,
1936                RunStatus::Completed
1937            );
1938
1939            // Second finalize sees a non-Sharded parent → no-op, status unchanged.
1940            let second = state
1941                .history()
1942                .finalize_sharded_parent("r", RunStatus::Failed, Utc::now(), Some("late".into()))
1943                .await
1944                .unwrap();
1945            assert!(!second, "second finalize is a no-op");
1946            let r = state.history().get("r").await.unwrap().unwrap();
1947            assert_eq!(r.status, RunStatus::Completed, "status not overwritten");
1948            assert!(r.error.is_none(), "late error must not be stamped");
1949
1950            // An unknown / never-sharded run id is not finalized.
1951            let missing = state
1952                .history()
1953                .finalize_sharded_parent("does-not-exist", RunStatus::Completed, Utc::now(), None)
1954                .await
1955                .unwrap();
1956            assert!(!missing);
1957        }
1958
1959        #[tokio::test]
1960        async fn execute_shard_runs_a_csv_to_jsonl_shard() {
1961            let dir = tempfile::tempdir().unwrap();
1962            let state = sqlite_state(dir.path()).await;
1963            let input = dir.path().join("in.csv");
1964            std::fs::write(&input, "id,name\n1,alice\n2,bob\n").unwrap();
1965            let output = dir.path().join("out.jsonl");
1966            let yaml = format!(
1967                "version: 1\npipeline:\n  \
1968                 source: {{ type: csv, config: {{ path: \"{}\" }} }}\n  \
1969                 sink: {{ type: jsonl, config: {{ path: \"{}\" }} }}\n",
1970                input.display(),
1971                output.display()
1972            );
1973            let l = loaded(&yaml).await;
1974            // The whole-dataset shard is a no-op for the (non-shardable) csv source,
1975            // exercising the apply_shard call + per-shard state-key path end-to-end.
1976            let ok = execute_shard(
1977                &state,
1978                l,
1979                "r",
1980                "0",
1981                ShardSpec::whole(),
1982                CancellationToken::new(),
1983                None,
1984                None,
1985                Utc::now(),
1986            )
1987            .await;
1988            assert!(ok, "csv→jsonl shard should complete");
1989            let written = std::fs::read_to_string(&output).unwrap();
1990            assert_eq!(written.lines().count(), 2, "both rows written");
1991            assert!(written.contains("alice") && written.contains("bob"));
1992        }
1993
1994        #[tokio::test]
1995        async fn resume_claimed_shard_executes_and_finalizes_parent() {
1996            // End-to-end per-shard entry point: claim a shard whose run is a
1997            // csv→jsonl pipeline, dispatch it, and confirm the shard runs, is
1998            // finalized, and the parent run flips to Completed.
1999            let dir = tempfile::tempdir().unwrap();
2000            let state = sqlite_state(dir.path()).await;
2001            let input = dir.path().join("in.csv");
2002            std::fs::write(&input, "id,name\n1,alice\n").unwrap();
2003            let output = dir.path().join("out.jsonl");
2004            let yaml = format!(
2005                "version: 1\npipeline:\n  \
2006                 source: {{ type: csv, config: {{ path: \"{}\" }} }}\n  \
2007                 sink: {{ type: jsonl, config: {{ path: \"{}\" }} }}\n",
2008                input.display(),
2009                output.display()
2010            );
2011            // Seed the parent Sharded run carrying the pipeline config, + one shard.
2012            let mut rec = RunRecord::queued("r".into(), None, BTreeMap::new(), None, Utc::now());
2013            rec.status = RunStatus::Sharded;
2014            rec.config_body = Some(yaml);
2015            state.history().upsert(&rec).await.unwrap();
2016            use crate::serve::history::ShardInsert;
2017            state
2018                .history()
2019                .insert_shards(
2020                    "r",
2021                    &[ShardInsert {
2022                        shard_id: "0".into(),
2023                        descriptor: serde_json::Value::Null,
2024                        size_estimate: None,
2025                    }],
2026                )
2027                .await
2028                .unwrap();
2029            let claimed = state.history().claim_shards(1).await.unwrap();
2030            assert_eq!(claimed.len(), 1);
2031
2032            resume_claimed_shard(state.clone(), claimed.into_iter().next().unwrap());
2033
2034            // Poll until the parent run reaches a terminal state (the spawned
2035            // task runs the shard, finalizes it, then finalizes the parent).
2036            let mut status = RunStatus::Sharded;
2037            for _ in 0..100 {
2038                tokio::time::sleep(Duration::from_millis(50)).await;
2039                status = state.history().get("r").await.unwrap().unwrap().status;
2040                if status.is_terminal() {
2041                    break;
2042                }
2043            }
2044            assert_eq!(status, RunStatus::Completed, "shard ran → parent completed");
2045            assert!(output.exists(), "shard wrote its output");
2046        }
2047
2048        #[tokio::test]
2049        async fn resume_claimed_shard_with_no_config_fails_the_shard() {
2050            // A claimed shard whose parent run has no config_body can't run →
2051            // the shard is finalized failed and the parent run fails.
2052            let dir = tempfile::tempdir().unwrap();
2053            let state = sqlite_state(dir.path()).await;
2054            let mut rec = RunRecord::queued("r".into(), None, BTreeMap::new(), None, Utc::now());
2055            rec.status = RunStatus::Sharded; // config_body intentionally None
2056            state.history().upsert(&rec).await.unwrap();
2057            use crate::serve::history::ShardInsert;
2058            state
2059                .history()
2060                .insert_shards(
2061                    "r",
2062                    &[ShardInsert {
2063                        shard_id: "0".into(),
2064                        descriptor: serde_json::Value::Null,
2065                        size_estimate: None,
2066                    }],
2067                )
2068                .await
2069                .unwrap();
2070            let claimed = state.history().claim_shards(1).await.unwrap();
2071            resume_claimed_shard(state.clone(), claimed.into_iter().next().unwrap());
2072
2073            let mut status = RunStatus::Sharded;
2074            for _ in 0..100 {
2075                tokio::time::sleep(Duration::from_millis(50)).await;
2076                status = state.history().get("r").await.unwrap().unwrap().status;
2077                if status.is_terminal() {
2078                    break;
2079                }
2080            }
2081            assert_eq!(status, RunStatus::Failed, "no-config shard → parent failed");
2082        }
2083
2084        #[tokio::test]
2085        async fn coordinate_returns_err_when_source_build_fails() {
2086            // A shardable-looking source with an invalid config fails to build →
2087            // coordinate_sharded_run surfaces the error (the caller fails the run).
2088            let dir = tempfile::tempdir().unwrap();
2089            let state = sqlite_state(dir.path()).await;
2090            // s3 missing the required `file_format` field → build_source errors.
2091            let l = loaded(
2092                "version: 1\npipeline:\n  \
2093                 source: { type: s3, config: { bucket: b } }\n  \
2094                 sink: { type: stdout, config: {} }\n",
2095            )
2096            .await;
2097            assert!(coordinate_sharded_run(&state, "r", &l, 4).await.is_err());
2098        }
2099
2100        #[tokio::test]
2101        async fn execute_shard_returns_false_on_malformed_resilience() {
2102            // A resilience block that parses but fails to compile makes
2103            // execute_shard fail fast (false) before running the pipeline.
2104            let dir = tempfile::tempdir().unwrap();
2105            let state = sqlite_state(dir.path()).await;
2106            let input = dir.path().join("in.csv");
2107            std::fs::write(&input, "id\n1\n").unwrap();
2108            let yaml = format!(
2109                "version: 1\nresilience:\n  retry:\n    max_attempts: 0\npipeline:\n  \
2110                 source: {{ type: csv, config: {{ path: \"{}\" }} }}\n  \
2111                 sink: {{ type: stdout, config: {{}} }}\n",
2112                input.display()
2113            );
2114            let l = loaded(&yaml).await;
2115            let ok = execute_shard(
2116                &state,
2117                l,
2118                "r",
2119                "0",
2120                ShardSpec::whole(),
2121                CancellationToken::new(),
2122                None,
2123                None,
2124                Utc::now(),
2125            )
2126            .await;
2127            assert!(!ok, "malformed resilience → shard fails fast");
2128        }
2129
2130        #[tokio::test]
2131        async fn resume_claimed_shard_with_unloadable_config_fails_the_shard() {
2132            // A claimed shard whose run config fails to re-load (malformed body)
2133            // exercises resume_claimed_shard's load-error branch → shard failed.
2134            let dir = tempfile::tempdir().unwrap();
2135            let state = sqlite_state(dir.path()).await;
2136            let mut rec = RunRecord::queued("r".into(), None, BTreeMap::new(), None, Utc::now());
2137            rec.status = RunStatus::Sharded;
2138            rec.config_body = Some("this: is: not: valid: yaml: [".into());
2139            state.history().upsert(&rec).await.unwrap();
2140            use crate::serve::history::ShardInsert;
2141            state
2142                .history()
2143                .insert_shards(
2144                    "r",
2145                    &[ShardInsert {
2146                        shard_id: "0".into(),
2147                        descriptor: serde_json::Value::Null,
2148                        size_estimate: None,
2149                    }],
2150                )
2151                .await
2152                .unwrap();
2153            let claimed = state.history().claim_shards(1).await.unwrap();
2154            resume_claimed_shard(state.clone(), claimed.into_iter().next().unwrap());
2155
2156            let mut status = RunStatus::Sharded;
2157            for _ in 0..100 {
2158                tokio::time::sleep(Duration::from_millis(50)).await;
2159                status = state.history().get("r").await.unwrap().unwrap().status;
2160                if status.is_terminal() {
2161                    break;
2162                }
2163            }
2164            assert_eq!(
2165                status,
2166                RunStatus::Failed,
2167                "unloadable config → parent failed"
2168            );
2169        }
2170
2171        // ── F10: cross-instance cancel of a Sharded run ──────────────────────
2172
2173        #[tokio::test]
2174        async fn request_cancel_flags_a_sharded_parent() {
2175            // F10: a Sharded parent (status 'sharded') must accept request_cancel
2176            // (the guard was broadened from 'running' to ('running','sharded')).
2177            let dir = tempfile::tempdir().unwrap();
2178            let state = sqlite_state(dir.path()).await;
2179            seed_run(&state, "r", RunStatus::Sharded).await;
2180            // request_cancel is fire-and-forget; verify it took effect by reading
2181            // back via pending_shard_cancellations after a shard is claimed below.
2182            state.history().request_cancel("r").await.unwrap();
2183            // Insert + claim a shard so this instance owns a running shard under r.
2184            use crate::serve::history::ShardInsert;
2185            state
2186                .history()
2187                .insert_shards(
2188                    "r",
2189                    &[ShardInsert {
2190                        shard_id: "0".into(),
2191                        descriptor: serde_json::Value::Null,
2192                        size_estimate: None,
2193                    }],
2194                )
2195                .await
2196                .unwrap();
2197            let claimed = state.history().claim_shards(1).await.unwrap();
2198            assert_eq!(claimed.len(), 1, "shard claimed (running, owned)");
2199
2200            let flagged = state.history().pending_shard_cancellations().await.unwrap();
2201            assert_eq!(
2202                flagged,
2203                vec!["r".to_string()],
2204                "the flagged sharded parent's run id is returned for its running shard"
2205            );
2206        }
2207
2208        #[tokio::test]
2209        async fn pending_shard_cancellations_filters_unflagged_and_pending_shards() {
2210            let dir = tempfile::tempdir().unwrap();
2211            let state = sqlite_state(dir.path()).await;
2212            use crate::serve::history::ShardInsert;
2213            let one = |id: &str| {
2214                vec![ShardInsert {
2215                    shard_id: id.into(),
2216                    descriptor: serde_json::Value::Null,
2217                    size_estimate: None,
2218                }]
2219            };
2220
2221            // Runs A (flagged) and C (NOT flagged) each get one shard; claim both
2222            // so they are running+owned here.
2223            seed_run(&state, "A", RunStatus::Sharded).await;
2224            state.history().request_cancel("A").await.unwrap();
2225            state.history().insert_shards("A", &one("0")).await.unwrap();
2226            seed_run(&state, "C", RunStatus::Sharded).await;
2227            state.history().insert_shards("C", &one("0")).await.unwrap();
2228            let claimed = state.history().claim_shards(8).await.unwrap();
2229            assert_eq!(claimed.len(), 2, "A and C shards claimed (running)");
2230
2231            // Run B: flagged, but its shard stays PENDING (inserted after the
2232            // claim, never claimed) → must be excluded (the join requires a
2233            // 'running' shard owned by this instance).
2234            seed_run(&state, "B", RunStatus::Sharded).await;
2235            state.history().request_cancel("B").await.unwrap();
2236            state.history().insert_shards("B", &one("0")).await.unwrap();
2237
2238            let flagged = state.history().pending_shard_cancellations().await.unwrap();
2239            assert_eq!(
2240                flagged,
2241                vec!["A".to_string()],
2242                "only A (flagged + a running owned shard); B pending-shard, C unflagged"
2243            );
2244        }
2245
2246        // ── F11: orphaned-Sharded-parent sweep ───────────────────────────────
2247
2248        #[tokio::test]
2249        async fn finalize_sweep_completes_an_all_success_sharded_parent() {
2250            let dir = tempfile::tempdir().unwrap();
2251            let state = sqlite_state(dir.path()).await;
2252            seed_sharded_with_shards(&state, "r", 3).await;
2253            for i in 0..3 {
2254                state
2255                    .history()
2256                    .finalize_shard("r", &i.to_string(), true)
2257                    .await
2258                    .unwrap();
2259            }
2260            // No inline maybe_finalize_parent — the sweep must finalize it.
2261            let n = state
2262                .history()
2263                .finalize_completed_sharded_parents()
2264                .await
2265                .unwrap();
2266            assert_eq!(n, 1, "one sharded parent finalized");
2267            let rec = state.history().get("r").await.unwrap().unwrap();
2268            assert_eq!(rec.status, RunStatus::Completed);
2269            assert!(rec.finished_at.is_some());
2270            assert!(rec.error.is_none());
2271
2272            // Idempotent: a second sweep finalizes nothing.
2273            assert_eq!(
2274                state
2275                    .history()
2276                    .finalize_completed_sharded_parents()
2277                    .await
2278                    .unwrap(),
2279                0,
2280                "already-terminal parent is not re-finalized"
2281            );
2282        }
2283
2284        #[tokio::test]
2285        async fn finalize_sweep_fails_a_parent_with_a_failed_shard() {
2286            let dir = tempfile::tempdir().unwrap();
2287            let state = sqlite_state(dir.path()).await;
2288            seed_sharded_with_shards(&state, "r", 3).await;
2289            state
2290                .history()
2291                .finalize_shard("r", "0", true)
2292                .await
2293                .unwrap();
2294            state
2295                .history()
2296                .finalize_shard("r", "1", false)
2297                .await
2298                .unwrap();
2299            state
2300                .history()
2301                .finalize_shard("r", "2", true)
2302                .await
2303                .unwrap();
2304            let n = state
2305                .history()
2306                .finalize_completed_sharded_parents()
2307                .await
2308                .unwrap();
2309            assert_eq!(n, 1);
2310            let rec = state.history().get("r").await.unwrap().unwrap();
2311            assert_eq!(rec.status, RunStatus::Failed);
2312            assert!(rec.finished_at.is_some());
2313            assert_eq!(rec.error.as_deref(), Some("1/3 shard(s) failed"));
2314        }
2315
2316        #[tokio::test]
2317        async fn finalize_sweep_leaves_a_not_all_terminal_parent_sharded() {
2318            let dir = tempfile::tempdir().unwrap();
2319            let state = sqlite_state(dir.path()).await;
2320            seed_sharded_with_shards(&state, "r", 2).await;
2321            // Only one shard terminal → the parent stays sharded.
2322            state
2323                .history()
2324                .finalize_shard("r", "0", true)
2325                .await
2326                .unwrap();
2327            let n = state
2328                .history()
2329                .finalize_completed_sharded_parents()
2330                .await
2331                .unwrap();
2332            assert_eq!(n, 0, "parent with a still-running shard is not finalized");
2333            assert_eq!(
2334                state.history().get("r").await.unwrap().unwrap().status,
2335                RunStatus::Sharded
2336            );
2337        }
2338    }
2339}