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::serve::error::ServeError;
10use crate::serve::history::{Claim, InvocationRecord, RunRecord, RunStatus};
11use crate::serve::load::{ConfigFormat, LoadedSubmission, load_submission};
12use crate::serve::state::ServerState;
13use crate::serve::{idempotency, metrics};
14use chrono::{DateTime, FixedOffset, Utc};
15use serde::{Deserialize, Serialize};
16use std::collections::BTreeMap;
17use std::time::Duration;
18use tokio_util::sync::CancellationToken;
19use tracing::Instrument;
20
21/// `Retry-After` advertised when the queue is full.
22const QUEUE_FULL_RETRY_AFTER_SECS: u64 = 5;
23
24/// Grace granted to a cancelled / timed-out / shutting-down run to flush
25/// buffered sink output cooperatively before its future is hard-dropped (which
26/// aborts the pipeline's task set, the backstop for a run stuck mid-write so a
27/// hung run can't wedge shutdown). Generous enough for an S3 multipart
28/// completion (#146 H16).
29const RUN_FLUSH_GRACE: Duration = Duration::from_secs(30);
30
31/// `POST /v1/runs` request body.
32#[derive(Debug, Deserialize)]
33pub struct SubmitRequest {
34    pub config: String,
35    #[serde(default)]
36    pub config_format: ConfigFormatWire,
37    pub name: Option<String>,
38    #[serde(default)]
39    pub labels: BTreeMap<String, String>,
40    pub timeout_secs: Option<u64>,
41    #[serde(default)]
42    pub doctor_first: bool,
43    pub idempotency_key: Option<String>,
44    pub clock: Option<String>,
45}
46
47/// Wire enum mirroring `load::ConfigFormat` with serde rename.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
49#[serde(rename_all = "lowercase")]
50pub enum ConfigFormatWire {
51    #[default]
52    Yaml,
53    Json,
54}
55
56impl From<ConfigFormatWire> for ConfigFormat {
57    fn from(w: ConfigFormatWire) -> Self {
58        match w {
59            ConfigFormatWire::Yaml => ConfigFormat::Yaml,
60            ConfigFormatWire::Json => ConfigFormat::Json,
61        }
62    }
63}
64
65/// `POST /v1/runs` success body (202).
66#[derive(Debug, Serialize)]
67pub struct SubmitResponse {
68    pub run_id: String,
69    pub status: RunStatus,
70    pub submitted_at: DateTime<Utc>,
71}
72
73/// Re-run a claimed Pending run on this instance (cluster mode). Reconstructs the
74/// execution inputs from the persisted record (re-resolving config with this
75/// instance's own env/credentials), acquires a permit, and runs the shared tail.
76pub fn resume_claimed_run(state: ServerState, rec: RunRecord) {
77    tokio::spawn(async move {
78        let run_id = rec.run_id.clone();
79        let Some(body) = rec.config_body.as_deref() else {
80            tracing::error!(run_id, "claimed run has no stored config; failing it");
81            finalize(
82                &state,
83                &run_id,
84                rec.submitted_at,
85                Terminal::Failed {
86                    reason: "claimed run record missing config_body".into(),
87                    records: 0,
88                    invs: Vec::new(),
89                },
90            )
91            .await;
92            return;
93        };
94        let format = rec.config_format.unwrap_or_default();
95        let loaded = match load_submission(body, format, state.default_base()).await {
96            Ok(l) => l,
97            Err(e) => {
98                finalize(
99                    &state,
100                    &run_id,
101                    rec.submitted_at,
102                    Terminal::Failed {
103                        reason: format!(
104                            "re-loading claimed config: {}",
105                            e.api_error().error.message
106                        ),
107                        records: 0,
108                        invs: Vec::new(),
109                    },
110                )
111                .await;
112                return;
113            }
114        };
115
116        // The claim loop only claims up to available_permits and is the sole
117        // permit consumer, so this acquire returns immediately.
118        let _permit = state
119            .semaphore()
120            .acquire_owned()
121            .await
122            .expect("semaphore not closed");
123        // Register a local cancel token so a cross-instance cancel (the claim loop
124        // calling registry.cancel) reaches this run.
125        let run_token = CancellationToken::new();
126        state.registry().register(run_id.clone(), run_token.clone());
127        execute_run(
128            state.clone(),
129            loaded,
130            run_id,
131            run_token,
132            rec.submitted_at,
133            rec.timeout_secs,
134            rec.clock.clone(),
135            false,
136        )
137        .await;
138    });
139}
140
141/// Validate, idempotency-claim, queue, and spawn a submission.
142pub async fn submit(state: ServerState, req: SubmitRequest) -> Result<SubmitResponse, ServeError> {
143    let format: ConfigFormat = req.config_format.into();
144    let loaded = load_submission(&req.config, format, state.default_base()).await?;
145
146    // Reserve a queue slot first, so a Fresh idempotency claim is always followed
147    // by a spawned run (no orphaned claims — spec §20.2).
148    if !state.registry().try_reserve() {
149        return Err(ServeError::QueueFull {
150            retry_after_secs: QUEUE_FULL_RETRY_AFTER_SECS,
151        });
152    }
153    // Releases the reservation on ANY early return below (doctor_first 422 /
154    // replay / conflict / claim or upsert error). Defused just before spawn.
155    let reservation = ReservationGuard::new(state.clone());
156
157    // doctor_first preflight — run BEHIND the reservation so concurrent preflight
158    // probing is bounded by `max_queued_runs` rather than running unthrottled
159    // before any limit applies (#146 R). On failure the guard releases the slot
160    // via the early `?`. The (redacted) report is stored on the run record below
161    // so `GET /v1/runs/{id}` exposes it (#146 R: doctor_report was never set).
162    let doctor_report = if req.doctor_first {
163        Some(run_doctor_first(&state, &loaded).await?)
164    } else {
165        None
166    };
167
168    let run_id = uuid::Uuid::now_v7().to_string();
169
170    // Idempotency claim (if a key was supplied).
171    if let Some(key) = &req.idempotency_key {
172        let merged = serde_json::to_value(&loaded.cfg).unwrap_or(serde_json::Value::Null);
173        // Fold the run-affecting request fields (clock / timeout_secs / labels)
174        // into the fingerprint, not just the config — so a key replayed with a
175        // different backfill `clock` is a 409, not a replay of the original
176        // run's window (#146 M7).
177        let fp_config = idempotency::fingerprint(&merged, loaded.cfg.name.as_deref());
178        let fp = idempotency::request_fingerprint(
179            &fp_config,
180            req.clock.as_deref(),
181            req.timeout_secs,
182            &req.labels,
183        );
184        match state
185            .history()
186            .claim_idempotency(key, &fp, &run_id, state.idempotency_retention())
187            .await
188            .map_err(|e| match e {
189                // Degraded backend can't safely honor idempotency → 503, retry.
190                crate::serve::history::HistoryError::Degraded(m) => ServeError::Unavailable(m),
191                other => ServeError::Internal(other.to_string()),
192            })? {
193            Claim::Fresh => {}
194            Claim::Replay(existing) => {
195                metrics::record_idempotency_hit();
196                return replay_response(&state, &existing).await;
197            }
198            Claim::Conflict => {
199                return Err(ServeError::Conflict(
200                    "idempotency key reused with a different payload".into(),
201                ));
202            }
203        }
204        // NOTE (Phase 5 / SQL backends): a `Fresh` claim is recorded BEFORE the
205        // record upsert below. The memory backend's `upsert` is infallible, so the
206        // claim and record are always consistent today. A future fallible backend
207        // whose `upsert` errors here would leave an orphaned claim (a replay of the
208        // key returns 404 until the claim self-expires within the retention
209        // window). When SQL backends land, claim after a successful upsert, or add
210        // a claim-release to RunHistory.
211    }
212
213    let submitted_at = Utc::now();
214    let mut rec = RunRecord::queued(
215        run_id.clone(),
216        req.name.clone(),
217        req.labels.clone(),
218        req.idempotency_key.clone(),
219        submitted_at,
220    );
221    rec.doctor_report = doctor_report;
222
223    if state.cluster().enabled() {
224        // A degraded (DB-unreachable) backend can't coordinate a cluster: the
225        // claim loop's claim_pending is a no-op on the in-memory fallback, so a
226        // Pending run would never be claimed. Fail closed with a retryable 503
227        // rather than silently orphaning the run (#197 spec §9).
228        if state.history().degraded() {
229            return Err(ServeError::Unavailable(
230                "clustered run-history backend is degraded; runs cannot be claimed \
231                 by any instance — retry once it recovers"
232                    .into(),
233            ));
234        }
235        // Cluster mode: persist the RAW config so any instance can re-resolve +
236        // run it, mark the run Pending, and wake the local claim loop. No local
237        // queue slot / spawn — the claim loop owns execution.
238        rec.status = RunStatus::Pending;
239        rec.config_body = Some(req.config.clone());
240        rec.config_format = Some(req.config_format.into());
241        rec.timeout_secs = req.timeout_secs;
242        rec.clock = req.clock.clone();
243        state
244            .history()
245            .upsert(&rec)
246            .await
247            .map_err(|e| ServeError::Internal(e.to_string()))?;
248        // Release the local queue reservation (cluster runs are bounded by the
249        // claim loop + semaphore, not the submit-side queue).
250        drop(reservation);
251        state.cluster().kick();
252        return Ok(SubmitResponse {
253            run_id,
254            status: RunStatus::Pending,
255            submitted_at,
256        });
257    }
258
259    state
260        .history()
261        .upsert(&rec)
262        .await
263        .map_err(|e| ServeError::Internal(e.to_string()))?;
264
265    let run_token = CancellationToken::new();
266    state.registry().register(run_id.clone(), run_token.clone());
267    metrics::set_run_gauges(&state);
268
269    // The spawned task now owns the queued→running→finished lifecycle.
270    reservation.defuse();
271    spawn_run(
272        state.clone(),
273        loaded,
274        req,
275        run_id.clone(),
276        run_token,
277        submitted_at,
278    );
279
280    Ok(SubmitResponse {
281        run_id,
282        status: RunStatus::Queued,
283        submitted_at,
284    })
285}
286
287/// Run the `doctor_first` probes; on any failure return 422 with the report.
288/// Run the `doctor_first` probes. On success returns the (redacted) report so
289/// the caller can store it on the run record (`doctor_report`); on any probe
290/// failure returns 422 with the same redacted report as `details`.
291pub(crate) async fn run_doctor_first(
292    state: &ServerState,
293    loaded: &LoadedSubmission,
294) -> Result<serde_json::Value, ServeError> {
295    use faucet_core::check::CheckContext;
296    let auth =
297        build_auth_catalog(loaded.cfg.auth.as_ref()).map_err(|e| ServeError::Unprocessable {
298            message: e.to_string(),
299            details: None,
300        })?;
301    let ctx = CheckContext {
302        timeout: state.probe_timeout(),
303    };
304    let mut invs = crate::commands::doctor::probe_roots(&loaded.nodes, &auth, &ctx).await;
305    let failed = crate::commands::doctor::count_failures(&invs);
306    // Redact regardless of outcome — the report is surfaced either way (as the
307    // 422 `details` on failure, or stored on the run record on success).
308    crate::commands::doctor::redact_invocations(&mut invs);
309    let report = serde_json::json!({ "invocations": invs });
310    if failed > 0 {
311        return Err(ServeError::Unprocessable {
312            message: format!("doctor_first preflight failed: {failed} probe(s) failed"),
313            details: Some(report),
314        });
315    }
316    Ok(report)
317}
318
319/// Build the replay response for an idempotency hit (the existing run's status).
320async fn replay_response(state: &ServerState, run_id: &str) -> Result<SubmitResponse, ServeError> {
321    let rec = state
322        .history()
323        .get(run_id)
324        .await
325        .map_err(|e| ServeError::Internal(e.to_string()))?
326        .ok_or(ServeError::NotFound)?;
327    Ok(SubmitResponse {
328        run_id: rec.run_id,
329        status: rec.status,
330        submitted_at: rec.submitted_at,
331    })
332}
333
334/// Releases a queue reservation on drop unless [`Self::defuse`]d. Guarantees the
335/// `queued` counter is balanced on every early-return path (replay / conflict /
336/// claim-or-upsert error) without a manual `release_reservation` at each site.
337/// Defused once the run is handed to the spawned task, which then owns the
338/// queued→running transition via `mark_running`.
339struct ReservationGuard {
340    state: Option<ServerState>,
341}
342
343impl ReservationGuard {
344    fn new(state: ServerState) -> Self {
345        Self { state: Some(state) }
346    }
347
348    /// Hand the reservation off to the spawned task (no release on drop).
349    fn defuse(mut self) {
350        self.state = None;
351    }
352}
353
354impl Drop for ReservationGuard {
355    fn drop(&mut self) {
356        if let Some(state) = self.state.take() {
357            state.registry().release_reservation();
358            metrics::set_run_gauges(&state);
359        }
360    }
361}
362
363/// Releases the in-flight slot (decrement `in_flight`, drop the cancel token, wake
364/// the shutdown drain) on drop — on EVERY path including panic. Without this, a
365/// panic between `mark_running` and a manual `mark_finished` would leak the
366/// counter and hang graceful shutdown forever.
367struct InFlightGuard {
368    state: ServerState,
369    run_id: String,
370}
371
372impl Drop for InFlightGuard {
373    fn drop(&mut self) {
374        self.state.registry().mark_finished(&self.run_id);
375        metrics::set_run_gauges(&self.state);
376    }
377}
378
379/// Terminal classification of a run task.
380enum Terminal {
381    Completed {
382        records: u64,
383        invs: Vec<InvocationRecord>,
384    },
385    Failed {
386        reason: String,
387        records: u64,
388        invs: Vec<InvocationRecord>,
389    },
390    Timeout {
391        secs: u64,
392    },
393    Cancelled,
394    ShutdownFailed,
395}
396
397impl Terminal {
398    /// (status, metric reason label, records, invocations, error message)
399    fn into_parts(
400        self,
401    ) -> (
402        RunStatus,
403        &'static str,
404        u64,
405        Vec<InvocationRecord>,
406        Option<String>,
407    ) {
408        match self {
409            Terminal::Completed { records, invs } => {
410                (RunStatus::Completed, "ok", records, invs, None)
411            }
412            Terminal::Failed {
413                reason,
414                records,
415                invs,
416            } => (RunStatus::Failed, "error", records, invs, Some(reason)),
417            Terminal::Timeout { secs } => (
418                RunStatus::Failed,
419                "timeout",
420                0,
421                Vec::new(),
422                Some(format!("run exceeded timeout_secs ({secs}s)")),
423            ),
424            Terminal::Cancelled => (RunStatus::Cancelled, "cancelled", 0, Vec::new(), None),
425            Terminal::ShutdownFailed => (
426                RunStatus::Failed,
427                "server_shutdown",
428                0,
429                Vec::new(),
430                Some("server shutdown before the run finished".into()),
431            ),
432        }
433    }
434}
435
436/// Classify a finished `run_expanded` result into a `Terminal`.
437fn classify_run(result: crate::error::CliResult<RunSummary>) -> Terminal {
438    match result {
439        Ok(summary) => {
440            let records: u64 = summary
441                .invocations
442                .iter()
443                .map(|i| i.records_written as u64)
444                .sum();
445            let invs: Vec<InvocationRecord> = summary
446                .invocations
447                .iter()
448                .map(InvocationRecord::from)
449                .collect();
450            if summary.had_failures() {
451                Terminal::Failed {
452                    reason: format!("{} invocation(s) failed", summary.failure_count()),
453                    records,
454                    invs,
455                }
456            } else {
457                Terminal::Completed { records, invs }
458            }
459        }
460        Err(e) => Terminal::Failed {
461            reason: e.to_string(),
462            records: 0,
463            invs: Vec::new(),
464        },
465    }
466}
467
468/// Parse the optional request `clock` (RFC3339), defaulting to `submitted_at`.
469fn resolve_clock(
470    flag: Option<&str>,
471    default: DateTime<Utc>,
472) -> Result<DateTime<FixedOffset>, ServeError> {
473    match flag {
474        None => Ok(default.fixed_offset()),
475        Some(s) => DateTime::parse_from_rfc3339(s)
476            .map_err(|_| ServeError::BadConfig(format!("clock '{s}' is not RFC3339"))),
477    }
478}
479
480/// Spawn the detached run task: acquire a permit, run under the 3-arm select,
481/// finalize the terminal status.
482fn spawn_run(
483    state: ServerState,
484    loaded: LoadedSubmission,
485    req: SubmitRequest,
486    run_id: String,
487    run_token: CancellationToken,
488    submitted_at: DateTime<Utc>,
489) {
490    let server_shutdown = state.shutdown_token();
491    tokio::spawn(async move {
492        // Race the permit acquisition against cancel / shutdown so a run
493        // cancelled while STILL QUEUED (before any permit frees) is finalized
494        // immediately, instead of only after it eventually acquires a permit
495        // (#146 R). `biased` prefers the cancel/shutdown signals over a
496        // simultaneously-available permit.
497        let _permit = tokio::select! {
498            biased;
499            _ = run_token.cancelled() => {
500                finalize_queued_cancel(&state, &run_id, submitted_at, Terminal::Cancelled).await;
501                return;
502            }
503            _ = server_shutdown.cancelled() => {
504                finalize_queued_cancel(&state, &run_id, submitted_at, Terminal::ShutdownFailed).await;
505                return;
506            }
507            permit = state.semaphore().acquire_owned() => permit.expect("semaphore not closed"),
508        };
509        execute_run(
510            state,
511            loaded,
512            run_id,
513            run_token,
514            submitted_at,
515            req.timeout_secs,
516            req.clock,
517            true,
518        )
519        .await;
520        // `_permit` drops here.
521    });
522}
523
524/// The queued→running→finalize execution tail, shared by the submit path
525/// (`spawn_run`) and the cluster claim path (`resume_claimed_run`). Assumes the
526/// caller already holds an execution permit and has registered `run_token`.
527/// `from_queue` is `true` when the run consumed a local queue slot (submit path)
528/// and `false` for a cluster claim-path run that never reserved one (#228).
529#[allow(clippy::too_many_arguments)]
530async fn execute_run(
531    state: ServerState,
532    loaded: LoadedSubmission,
533    run_id: String,
534    run_token: CancellationToken,
535    submitted_at: DateTime<Utc>,
536    timeout_secs: Option<u64>,
537    clock_flag: Option<String>,
538    from_queue: bool,
539) {
540    let server_shutdown = state.shutdown_token();
541    let LoadedSubmission { cfg, nodes } = loaded;
542
543    // Queued → running. From here the guard guarantees `mark_finished` (and a
544    // gauge refresh) on EVERY exit, including early returns and panics.
545    // A submit-path run consumed a local queue slot (Queued→Running); a cluster
546    // claim-path run never did (#228) — only bump in_flight for it.
547    if from_queue {
548        state.registry().mark_running();
549    } else {
550        state.registry().mark_running_unqueued();
551    }
552    let _guard = InFlightGuard {
553        state: state.clone(),
554        run_id: run_id.clone(),
555    };
556    let started = Utc::now();
557    if let Ok(Some(mut rec)) = state.history().get(&run_id).await {
558        rec.status = RunStatus::Running;
559        rec.started_at = Some(started);
560        let _ = state.history().upsert(&rec).await;
561    }
562    metrics::set_run_gauges(&state);
563
564    // Build execution options (auth/clock failures finalize as Failed).
565    let pipeline_name = cfg.name.clone().unwrap_or_else(|| "serve".to_string());
566    let auth = match build_auth_catalog(cfg.auth.as_ref()) {
567        Ok(a) => a,
568        Err(e) => {
569            finalize(
570                &state,
571                &run_id,
572                started,
573                Terminal::Failed {
574                    reason: format!("auth catalog: {e}"),
575                    records: 0,
576                    invs: Vec::new(),
577                },
578            )
579            .await;
580            return;
581        }
582    };
583    let clock = match resolve_clock(clock_flag.as_deref(), submitted_at) {
584        Ok(c) => c,
585        Err(e) => {
586            finalize(
587                &state,
588                &run_id,
589                started,
590                Terminal::Failed {
591                    reason: e.api_error().error.message,
592                    records: 0,
593                    invs: Vec::new(),
594                },
595            )
596            .await;
597            return;
598        }
599    };
600
601    // Cooperative-cancel token the pipeline observes so it flushes buffered
602    // output (e.g. a Parquet footer, an S3 multipart upload) at its next
603    // page boundary on cancel / timeout / shutdown — instead of having its
604    // future hard-dropped, which flushes nothing (#146 H16).
605    let coop = CancellationToken::new();
606    // Build the per-run OpenLineage emitter from the (merged) submitted
607    // config. A malformed `lineage:` block finalizes the run as Failed,
608    // mirroring the auth/clock failure handling above.
609    #[cfg(feature = "lineage")]
610    let lineage = match crate::lineage_glue::build_emitter(cfg.lineage.as_ref()) {
611        Ok(l) => l,
612        Err(e) => {
613            finalize(
614                &state,
615                &run_id,
616                started,
617                Terminal::Failed {
618                    reason: format!("lineage: {e}"),
619                    records: 0,
620                    invs: Vec::new(),
621                },
622            )
623            .await;
624            return;
625        }
626    };
627    let opts = ExecuteOptions {
628        pipeline_name,
629        execution: cfg.execution.clone(),
630        dry_run: false,
631        limit: None,
632        state_path_override: None,
633        auth,
634        clock,
635        cancel: Some(coop.clone()),
636        #[cfg(feature = "lineage")]
637        lineage,
638        #[cfg(feature = "lineage")]
639        lineage_cfg: cfg.lineage.clone(),
640    };
641
642    let span = tracing::info_span!("faucet.serve.run", serve_run_id = %run_id);
643    let work = async move {
644        // Emitted inside the run span so it is captured by the SSE log layer
645        // (and gives every `/logs` reader at least one line to anchor on).
646        tracing::info!("pipeline run starting");
647        classify_run(run_expanded(nodes, opts).await)
648    }
649    .instrument(span);
650    tokio::pin!(work);
651
652    // The run timeout is modelled as a cancel trigger (not a hard
653    // `tokio::time::timeout` drop) so a timed-out run still flushes.
654    let timeout_fut = async {
655        match timeout_secs {
656            Some(s) => tokio::time::sleep(Duration::from_secs(s)).await,
657            None => std::future::pending::<()>().await,
658        }
659    };
660    tokio::pin!(timeout_fut);
661
662    enum Trigger {
663        Done(Terminal),
664        Cancel,
665        Shutdown,
666        Timeout(u64),
667    }
668
669    // Phase 1: run to natural completion, or until a cancel trigger fires.
670    // `biased` prefers a just-completed run over a simultaneous trigger.
671    let trigger = tokio::select! {
672        biased;
673        t = &mut work => Trigger::Done(t),
674        _ = run_token.cancelled() => Trigger::Cancel,
675        _ = server_shutdown.cancelled() => Trigger::Shutdown,
676        _ = &mut timeout_fut => Trigger::Timeout(timeout_secs.unwrap_or(0)),
677    };
678
679    let terminal = match trigger {
680        Trigger::Done(t) => t,
681        triggered => {
682            // Phase 2: a trigger fired. Cancel cooperatively and give the
683            // pipeline a bounded grace to flush at its next page boundary,
684            // then hard-drop it (drops the JoinSet, aborting any pipeline
685            // genuinely stuck mid-write) so a hung run can't wedge shutdown.
686            coop.cancel();
687            let _ = tokio::time::timeout(RUN_FLUSH_GRACE, &mut work).await;
688            match triggered {
689                Trigger::Cancel => Terminal::Cancelled,
690                Trigger::Shutdown => Terminal::ShutdownFailed,
691                Trigger::Timeout(secs) => Terminal::Timeout { secs },
692                Trigger::Done(_) => unreachable!("matched in the outer arm"),
693            }
694        }
695    };
696
697    finalize(&state, &run_id, started, terminal).await;
698    // Signal `/logs` readers the run is done, then drop the buffer after a
699    // drain window so a late fetcher can still replay it (spec §12).
700    state.log_hub().finish(&run_id);
701    schedule_log_drop(state.clone(), run_id.clone());
702    // `_guard` drops here → mark_finished + gauge refresh.
703}
704
705/// Write the authoritative terminal record + the run-finished metric.
706async fn finalize(state: &ServerState, run_id: &str, started: DateTime<Utc>, term: Terminal) {
707    let finished = Utc::now();
708    let elapsed = (finished - started).to_std().ok().map(|d| d.as_secs_f64());
709    let (status, reason, records, invs, error) = term.into_parts();
710    // Read-modify-write the existing record to preserve its metadata
711    // (name / labels / idempotency_key / submitted_at). If it can't be read —
712    // the backend errored, or the record was purged / landed in another store
713    // under degraded fallback — DON'T silently drop the terminal status (#146
714    // M6): reconstruct a minimal terminal record and upsert it, so the run
715    // never lingers non-terminal while `record_run_finished` has already fired.
716    let mut rec = match state.history().get(run_id).await {
717        Ok(Some(rec)) => rec,
718        Ok(None) => {
719            tracing::warn!(
720                run_id,
721                "finalize: run record not found; writing a fresh terminal record"
722            );
723            RunRecord::queued(run_id.to_string(), None, BTreeMap::new(), None, started)
724        }
725        Err(e) => {
726            tracing::warn!(
727                run_id,
728                error = %e,
729                "finalize: failed to read run record; writing a fresh terminal record"
730            );
731            RunRecord::queued(run_id.to_string(), None, BTreeMap::new(), None, started)
732        }
733    };
734    rec.status = status;
735    rec.started_at.get_or_insert(started);
736    rec.finished_at = Some(finished);
737    rec.elapsed_secs = elapsed;
738    rec.records_written = records;
739    rec.invocations = invs;
740    rec.error = error;
741    if state.cluster().enabled() {
742        // Owner-fenced: if another instance reclaimed this run (lease expired),
743        // our write is a no-op and we discard our result — the reclaimer is now
744        // authoritative (#197).
745        match state.history().finalize_owned(&rec).await {
746            Ok(true) => metrics::record_run_finished(status, reason),
747            Ok(false) => tracing::warn!(
748                run_id,
749                "finalize: run was reclaimed by another instance; discarding result"
750            ),
751            Err(e) => {
752                tracing::error!(run_id, error = %e, "finalize: owner-fenced write failed")
753            }
754        }
755    } else {
756        if let Err(e) = state.history().upsert(&rec).await {
757            tracing::error!(
758                run_id,
759                error = %e,
760                "finalize: failed to persist terminal run record"
761            );
762        }
763        metrics::record_run_finished(status, reason);
764    }
765}
766
767/// Finalize a run that was cancelled / hit shutdown while still QUEUED (before
768/// it acquired an execution permit, so it never became in-flight and has no
769/// `InFlightGuard`). Releases the queue slot, writes the terminal record, closes
770/// the log buffer, and refreshes the gauges — the queued-path analogue of the
771/// normal `finalize` + `InFlightGuard`-drop cleanup.
772async fn finalize_queued_cancel(
773    state: &ServerState,
774    run_id: &str,
775    submitted_at: DateTime<Utc>,
776    term: Terminal,
777) {
778    state.registry().mark_queued_cancelled(run_id);
779    finalize(state, run_id, submitted_at, term).await;
780    state.log_hub().finish(run_id);
781    schedule_log_drop(state.clone(), run_id.to_string());
782    metrics::set_run_gauges(state);
783}
784
785/// Spawn a detached timer that drops a finished run's log buffer after the drain
786/// window, freeing its ring once late `/logs` fetchers have had a chance to read.
787fn schedule_log_drop(state: ServerState, run_id: String) {
788    tokio::spawn(async move {
789        tokio::time::sleep(crate::serve::logs::LOG_DRAIN).await;
790        state.log_hub().drop_run(&run_id);
791    });
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797
798    #[test]
799    fn classify_ok_no_failures_is_completed() {
800        let summary = RunSummary {
801            invocations: vec![crate::executor::InvocationOutcome {
802                row_id: "r".into(),
803                parent_record_key: None,
804                records_written: 3,
805                error: None,
806            }],
807        };
808        let (status, reason, records, _, error) = classify_run(Ok(summary)).into_parts();
809        assert_eq!(status, RunStatus::Completed);
810        assert_eq!(reason, "ok");
811        assert_eq!(records, 3);
812        assert!(error.is_none());
813    }
814
815    #[test]
816    fn classify_ok_with_failures_is_failed() {
817        let summary = RunSummary {
818            invocations: vec![crate::executor::InvocationOutcome {
819                row_id: "r".into(),
820                parent_record_key: None,
821                records_written: 0,
822                error: Some("boom".into()),
823            }],
824        };
825        let (status, reason, _, _, error) = classify_run(Ok(summary)).into_parts();
826        assert_eq!(status, RunStatus::Failed);
827        assert_eq!(reason, "error");
828        assert!(error.unwrap().contains("invocation(s) failed"));
829    }
830
831    #[test]
832    fn timeout_maps_to_failed_with_timeout_reason() {
833        let (status, reason, _, _, error) = Terminal::Timeout { secs: 30 }.into_parts();
834        assert_eq!(status, RunStatus::Failed);
835        assert_eq!(reason, "timeout");
836        assert!(error.unwrap().contains("30s"));
837    }
838
839    #[test]
840    fn resolve_clock_defaults_and_parses() {
841        let default = Utc::now();
842        assert_eq!(
843            resolve_clock(None, default).unwrap(),
844            default.fixed_offset()
845        );
846        assert!(resolve_clock(Some("2026-01-31T00:00:00Z"), default).is_ok());
847        assert!(resolve_clock(Some("not-a-time"), default).is_err());
848    }
849
850    #[tokio::test]
851    async fn conflict_releases_reservation() {
852        use crate::serve::config::{AuthMode, HistoryBackendSpec, ServeConfig};
853        use crate::serve::history::RunHistory;
854        use crate::serve::history::memory::MemoryHistory;
855        use crate::serve::state::ServerState;
856        use std::sync::Arc;
857        use tokio_util::sync::CancellationToken;
858
859        let cfg = ServeConfig {
860            listen: "127.0.0.1:0".parse().unwrap(),
861            auth: AuthMode::None,
862            max_concurrent_runs: 4,
863            max_queued_runs: 4,
864            default_config_path: None,
865            history: HistoryBackendSpec::Memory,
866            cors_origins: vec![],
867            body_limit_bytes: 1_048_576,
868            shutdown_grace: Duration::from_secs(60),
869            retain_terminal_runs: Duration::from_secs(60),
870            idempotency_retention: Duration::from_secs(60),
871            lease_ttl: Duration::from_secs(30),
872            probe_timeout: Duration::from_secs(10),
873            env_file: None,
874            no_env_file: false,
875            log_level: "info".into(),
876            ui_enabled: true,
877            cluster: crate::serve::cluster::ClusterConfig::disabled(),
878            triggers_path: None,
879        };
880        let history = Arc::new(MemoryHistory::new(Duration::from_secs(60))) as Arc<dyn RunHistory>;
881        let state = ServerState::new(
882            &cfg,
883            None,
884            CancellationToken::new(),
885            history,
886            crate::serve::logs::LogHub::new(),
887            None,
888            #[cfg(feature = "triggers")]
889            crate::serve::triggers::health::TriggersHandle::empty(),
890        );
891
892        // Pre-claim the key with a DIFFERENT fingerprint so submit() hits Conflict.
893        state
894            .history()
895            .claim_idempotency("k", "different-fp", "prior", Duration::from_secs(60))
896            .await
897            .unwrap();
898
899        let req = SubmitRequest {
900            config: "version: 1\npipeline:\n  source: { type: csv, config: { path: x.csv } }\n  sink: { type: jsonl, config: { path: out.jsonl } }\n".into(),
901            config_format: ConfigFormatWire::Yaml,
902            name: None,
903            labels: BTreeMap::new(),
904            timeout_secs: None,
905            doctor_first: false,
906            idempotency_key: Some("k".into()),
907            clock: None,
908        };
909
910        let err = submit(state.clone(), req).await.unwrap_err();
911        assert!(
912            matches!(err, ServeError::Conflict(_)),
913            "expected Conflict, got {err:?}"
914        );
915        // The reservation taken before the claim must have been released by the guard.
916        assert_eq!(state.registry().queued(), 0);
917    }
918
919    #[tokio::test]
920    async fn cluster_submit_writes_pending_with_config_and_does_not_spawn() {
921        use crate::serve::cluster::ClusterConfig;
922        use crate::serve::config::{AuthMode, HistoryBackendSpec, ServeConfig};
923        use crate::serve::history::RunHistory;
924        use crate::serve::history::memory::MemoryHistory;
925        use crate::serve::state::ServerState;
926        use std::sync::Arc;
927        use tokio_util::sync::CancellationToken;
928
929        let mut cluster = ClusterConfig::disabled();
930        cluster.enabled = true;
931        let cfg = ServeConfig {
932            listen: "127.0.0.1:0".parse().unwrap(),
933            auth: AuthMode::None,
934            max_concurrent_runs: 4,
935            max_queued_runs: 4,
936            default_config_path: None,
937            history: HistoryBackendSpec::Memory,
938            cors_origins: vec![],
939            body_limit_bytes: 1_048_576,
940            shutdown_grace: Duration::from_secs(60),
941            retain_terminal_runs: Duration::from_secs(60),
942            idempotency_retention: Duration::from_secs(60),
943            lease_ttl: Duration::from_secs(30),
944            probe_timeout: Duration::from_secs(10),
945            env_file: None,
946            no_env_file: false,
947            log_level: "info".into(),
948            ui_enabled: true,
949            cluster,
950            triggers_path: None,
951        };
952        let history = Arc::new(MemoryHistory::new(Duration::from_secs(60))) as Arc<dyn RunHistory>;
953        let state = ServerState::new(
954            &cfg,
955            None,
956            CancellationToken::new(),
957            history,
958            crate::serve::logs::LogHub::new(),
959            None,
960            #[cfg(feature = "triggers")]
961            crate::serve::triggers::health::TriggersHandle::empty(),
962        );
963
964        let req = SubmitRequest {
965            config: "version: 1\npipeline:\n  source: { type: csv, config: { path: x.csv } }\n  sink: { type: jsonl, config: { path: out.jsonl } }\n".into(),
966            config_format: ConfigFormatWire::Yaml,
967            name: Some("n".into()),
968            labels: BTreeMap::new(),
969            timeout_secs: Some(99),
970            doctor_first: false,
971            idempotency_key: None,
972            clock: None,
973        };
974        let resp = submit(state.clone(), req).await.unwrap();
975        assert_eq!(resp.status, RunStatus::Pending);
976        // No local queue slot was consumed (cluster runs don't queue locally).
977        assert_eq!(state.registry().queued(), 0);
978        let rec = state.history().get(&resp.run_id).await.unwrap().unwrap();
979        assert_eq!(rec.status, RunStatus::Pending);
980        assert!(rec.config_body.as_deref().unwrap().contains("version: 1"));
981        assert_eq!(rec.timeout_secs, Some(99));
982    }
983
984    /// A `ServerState` backed by an in-memory history, for finalize tests.
985    fn memory_state() -> crate::serve::state::ServerState {
986        use crate::serve::config::{AuthMode, HistoryBackendSpec, ServeConfig};
987        use crate::serve::history::RunHistory;
988        use crate::serve::history::memory::MemoryHistory;
989        use crate::serve::state::ServerState;
990        use std::sync::Arc;
991        use tokio_util::sync::CancellationToken;
992
993        let cfg = ServeConfig {
994            listen: "127.0.0.1:0".parse().unwrap(),
995            auth: AuthMode::None,
996            max_concurrent_runs: 4,
997            max_queued_runs: 4,
998            default_config_path: None,
999            history: HistoryBackendSpec::Memory,
1000            cors_origins: vec![],
1001            body_limit_bytes: 1_048_576,
1002            shutdown_grace: Duration::from_secs(60),
1003            retain_terminal_runs: Duration::from_secs(60),
1004            idempotency_retention: Duration::from_secs(60),
1005            lease_ttl: Duration::from_secs(30),
1006            probe_timeout: Duration::from_secs(10),
1007            env_file: None,
1008            no_env_file: false,
1009            log_level: "info".into(),
1010            ui_enabled: true,
1011            cluster: crate::serve::cluster::ClusterConfig::disabled(),
1012            triggers_path: None,
1013        };
1014        let history = Arc::new(MemoryHistory::new(Duration::from_secs(60))) as Arc<dyn RunHistory>;
1015        ServerState::new(
1016            &cfg,
1017            None,
1018            CancellationToken::new(),
1019            history,
1020            crate::serve::logs::LogHub::new(),
1021            None,
1022            #[cfg(feature = "triggers")]
1023            crate::serve::triggers::health::TriggersHandle::empty(),
1024        )
1025    }
1026
1027    #[tokio::test]
1028    async fn finalize_writes_terminal_record_when_record_is_missing() {
1029        // M6 (#146): if the run record can't be read at finalize time (purged,
1030        // or split to another store under degraded fallback), the terminal
1031        // status must NOT be silently dropped — a fresh terminal record is
1032        // written so the run never lingers non-terminal while the run-finished
1033        // metric has already fired.
1034        let state = memory_state();
1035        let started = Utc::now();
1036        finalize(
1037            &state,
1038            "ghost",
1039            started,
1040            Terminal::Failed {
1041                reason: "boom".into(),
1042                records: 0,
1043                invs: Vec::new(),
1044            },
1045        )
1046        .await;
1047        let rec = state
1048            .history()
1049            .get("ghost")
1050            .await
1051            .unwrap()
1052            .expect("finalize must create a terminal record even when none existed");
1053        assert_eq!(rec.status, RunStatus::Failed);
1054        assert!(rec.finished_at.is_some());
1055        assert!(rec.started_at.is_some());
1056        assert_eq!(rec.error.as_deref(), Some("boom"));
1057    }
1058
1059    #[tokio::test]
1060    async fn finalize_preserves_metadata_of_existing_record() {
1061        // The happy path still read-modify-writes, preserving name/labels/key.
1062        let state = memory_state();
1063        let started = Utc::now();
1064        let mut rec = RunRecord::queued(
1065            "r1".into(),
1066            Some("nightly".into()),
1067            BTreeMap::new(),
1068            Some("idem-k".into()),
1069            started,
1070        );
1071        rec.status = RunStatus::Running;
1072        rec.started_at = Some(started);
1073        state.history().upsert(&rec).await.unwrap();
1074
1075        finalize(
1076            &state,
1077            "r1",
1078            started,
1079            Terminal::Completed {
1080                records: 5,
1081                invs: Vec::new(),
1082            },
1083        )
1084        .await;
1085        let got = state.history().get("r1").await.unwrap().unwrap();
1086        assert_eq!(got.status, RunStatus::Completed);
1087        assert_eq!(got.records_written, 5);
1088        assert_eq!(got.name.as_deref(), Some("nightly"));
1089        assert_eq!(got.idempotency_key.as_deref(), Some("idem-k"));
1090    }
1091
1092    #[cfg(any(feature = "serve-history-sqlite", feature = "serve-history-postgres"))]
1093    #[tokio::test]
1094    async fn cluster_submit_503s_when_history_degraded() {
1095        // #197 spec §9: a degraded backend can't coordinate a cluster, so submit
1096        // must fail closed with 503 rather than orphan a never-claimable Pending run.
1097        use crate::serve::cluster::ClusterConfig;
1098        use crate::serve::config::{AuthMode, HistoryBackendSpec, ServeConfig};
1099        use crate::serve::history::RunHistory;
1100        use crate::serve::history::fallback::FallbackHistory;
1101        use crate::serve::state::ServerState;
1102        use std::sync::Arc;
1103        use tokio_util::sync::CancellationToken;
1104
1105        let mut cluster = ClusterConfig::disabled();
1106        cluster.enabled = true;
1107        let cfg = ServeConfig {
1108            listen: "127.0.0.1:0".parse().unwrap(),
1109            auth: AuthMode::None,
1110            max_concurrent_runs: 4,
1111            max_queued_runs: 4,
1112            default_config_path: None,
1113            history: HistoryBackendSpec::Memory,
1114            cors_origins: vec![],
1115            body_limit_bytes: 1_048_576,
1116            shutdown_grace: Duration::from_secs(60),
1117            retain_terminal_runs: Duration::from_secs(60),
1118            idempotency_retention: Duration::from_secs(60),
1119            lease_ttl: Duration::from_secs(30),
1120            probe_timeout: Duration::from_secs(10),
1121            env_file: None,
1122            no_env_file: false,
1123            log_level: "info".into(),
1124            ui_enabled: true,
1125            cluster,
1126            triggers_path: None,
1127        };
1128        // A backend that is degraded from startup (primary unreachable).
1129        let history = Arc::new(FallbackHistory::degraded_at_startup(
1130            Duration::from_secs(60),
1131            "test",
1132        )) as Arc<dyn RunHistory>;
1133        assert!(history.degraded());
1134        let state = ServerState::new(
1135            &cfg,
1136            None,
1137            CancellationToken::new(),
1138            history,
1139            crate::serve::logs::LogHub::new(),
1140            None,
1141            #[cfg(feature = "triggers")]
1142            crate::serve::triggers::health::TriggersHandle::empty(),
1143        );
1144        let req = SubmitRequest {
1145            config: "version: 1\npipeline:\n  source: { type: csv, config: { path: x.csv } }\n  sink: { type: jsonl, config: { path: out.jsonl } }\n".into(),
1146            config_format: ConfigFormatWire::Yaml,
1147            name: None,
1148            labels: BTreeMap::new(),
1149            timeout_secs: None,
1150            doctor_first: false,
1151            idempotency_key: None,
1152            clock: None,
1153        };
1154        let err = submit(state.clone(), req).await.unwrap_err();
1155        assert!(
1156            matches!(err, ServeError::Unavailable(_)),
1157            "expected 503 Unavailable, got {err:?}"
1158        );
1159        // The queue reservation must have been released (no leak).
1160        assert_eq!(state.registry().queued(), 0);
1161    }
1162}