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