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/// Validate, idempotency-claim, queue, and spawn a submission.
74pub async fn submit(state: ServerState, req: SubmitRequest) -> Result<SubmitResponse, ServeError> {
75    let format: ConfigFormat = req.config_format.into();
76    let loaded = load_submission(&req.config, format, state.default_base()).await?;
77
78    // Reserve a queue slot first, so a Fresh idempotency claim is always followed
79    // by a spawned run (no orphaned claims — spec §20.2).
80    if !state.registry().try_reserve() {
81        return Err(ServeError::QueueFull {
82            retry_after_secs: QUEUE_FULL_RETRY_AFTER_SECS,
83        });
84    }
85    // Releases the reservation on ANY early return below (doctor_first 422 /
86    // replay / conflict / claim or upsert error). Defused just before spawn.
87    let reservation = ReservationGuard::new(state.clone());
88
89    // doctor_first preflight — run BEHIND the reservation so concurrent preflight
90    // probing is bounded by `max_queued_runs` rather than running unthrottled
91    // before any limit applies (#146 R). On failure the guard releases the slot
92    // via the early `?`. The (redacted) report is stored on the run record below
93    // so `GET /v1/runs/{id}` exposes it (#146 R: doctor_report was never set).
94    let doctor_report = if req.doctor_first {
95        Some(run_doctor_first(&state, &loaded).await?)
96    } else {
97        None
98    };
99
100    let run_id = uuid::Uuid::now_v7().to_string();
101
102    // Idempotency claim (if a key was supplied).
103    if let Some(key) = &req.idempotency_key {
104        let merged = serde_json::to_value(&loaded.cfg).unwrap_or(serde_json::Value::Null);
105        // Fold the run-affecting request fields (clock / timeout_secs / labels)
106        // into the fingerprint, not just the config — so a key replayed with a
107        // different backfill `clock` is a 409, not a replay of the original
108        // run's window (#146 M7).
109        let fp_config = idempotency::fingerprint(&merged, loaded.cfg.name.as_deref());
110        let fp = idempotency::request_fingerprint(
111            &fp_config,
112            req.clock.as_deref(),
113            req.timeout_secs,
114            &req.labels,
115        );
116        match state
117            .history()
118            .claim_idempotency(key, &fp, &run_id, state.idempotency_retention())
119            .await
120            .map_err(|e| match e {
121                // Degraded backend can't safely honor idempotency → 503, retry.
122                crate::serve::history::HistoryError::Degraded(m) => ServeError::Unavailable(m),
123                other => ServeError::Internal(other.to_string()),
124            })? {
125            Claim::Fresh => {}
126            Claim::Replay(existing) => {
127                metrics::record_idempotency_hit();
128                return replay_response(&state, &existing).await;
129            }
130            Claim::Conflict => {
131                return Err(ServeError::Conflict(
132                    "idempotency key reused with a different payload".into(),
133                ));
134            }
135        }
136        // NOTE (Phase 5 / SQL backends): a `Fresh` claim is recorded BEFORE the
137        // record upsert below. The memory backend's `upsert` is infallible, so the
138        // claim and record are always consistent today. A future fallible backend
139        // whose `upsert` errors here would leave an orphaned claim (a replay of the
140        // key returns 404 until the claim self-expires within the retention
141        // window). When SQL backends land, claim after a successful upsert, or add
142        // a claim-release to RunHistory.
143    }
144
145    let submitted_at = Utc::now();
146    let mut rec = RunRecord::queued(
147        run_id.clone(),
148        req.name.clone(),
149        req.labels.clone(),
150        req.idempotency_key.clone(),
151        submitted_at,
152    );
153    rec.doctor_report = doctor_report;
154    state
155        .history()
156        .upsert(&rec)
157        .await
158        .map_err(|e| ServeError::Internal(e.to_string()))?;
159
160    let run_token = CancellationToken::new();
161    state.registry().register(run_id.clone(), run_token.clone());
162    metrics::set_run_gauges(&state);
163
164    // The spawned task now owns the queued→running→finished lifecycle.
165    reservation.defuse();
166    spawn_run(
167        state.clone(),
168        loaded,
169        req,
170        run_id.clone(),
171        run_token,
172        submitted_at,
173    );
174
175    Ok(SubmitResponse {
176        run_id,
177        status: RunStatus::Queued,
178        submitted_at,
179    })
180}
181
182/// Run the `doctor_first` probes; on any failure return 422 with the report.
183/// Run the `doctor_first` probes. On success returns the (redacted) report so
184/// the caller can store it on the run record (`doctor_report`); on any probe
185/// failure returns 422 with the same redacted report as `details`.
186async fn run_doctor_first(
187    state: &ServerState,
188    loaded: &LoadedSubmission,
189) -> Result<serde_json::Value, ServeError> {
190    use faucet_core::check::CheckContext;
191    let auth =
192        build_auth_catalog(loaded.cfg.auth.as_ref()).map_err(|e| ServeError::Unprocessable {
193            message: e.to_string(),
194            details: None,
195        })?;
196    let ctx = CheckContext {
197        timeout: state.probe_timeout(),
198    };
199    let mut invs = crate::commands::doctor::probe_roots(&loaded.nodes, &auth, &ctx).await;
200    let failed = crate::commands::doctor::count_failures(&invs);
201    // Redact regardless of outcome — the report is surfaced either way (as the
202    // 422 `details` on failure, or stored on the run record on success).
203    crate::commands::doctor::redact_invocations(&mut invs);
204    let report = serde_json::json!({ "invocations": invs });
205    if failed > 0 {
206        return Err(ServeError::Unprocessable {
207            message: format!("doctor_first preflight failed: {failed} probe(s) failed"),
208            details: Some(report),
209        });
210    }
211    Ok(report)
212}
213
214/// Build the replay response for an idempotency hit (the existing run's status).
215async fn replay_response(state: &ServerState, run_id: &str) -> Result<SubmitResponse, ServeError> {
216    let rec = state
217        .history()
218        .get(run_id)
219        .await
220        .map_err(|e| ServeError::Internal(e.to_string()))?
221        .ok_or(ServeError::NotFound)?;
222    Ok(SubmitResponse {
223        run_id: rec.run_id,
224        status: rec.status,
225        submitted_at: rec.submitted_at,
226    })
227}
228
229/// Releases a queue reservation on drop unless [`Self::defuse`]d. Guarantees the
230/// `queued` counter is balanced on every early-return path (replay / conflict /
231/// claim-or-upsert error) without a manual `release_reservation` at each site.
232/// Defused once the run is handed to the spawned task, which then owns the
233/// queued→running transition via `mark_running`.
234struct ReservationGuard {
235    state: Option<ServerState>,
236}
237
238impl ReservationGuard {
239    fn new(state: ServerState) -> Self {
240        Self { state: Some(state) }
241    }
242
243    /// Hand the reservation off to the spawned task (no release on drop).
244    fn defuse(mut self) {
245        self.state = None;
246    }
247}
248
249impl Drop for ReservationGuard {
250    fn drop(&mut self) {
251        if let Some(state) = self.state.take() {
252            state.registry().release_reservation();
253            metrics::set_run_gauges(&state);
254        }
255    }
256}
257
258/// Releases the in-flight slot (decrement `in_flight`, drop the cancel token, wake
259/// the shutdown drain) on drop — on EVERY path including panic. Without this, a
260/// panic between `mark_running` and a manual `mark_finished` would leak the
261/// counter and hang graceful shutdown forever.
262struct InFlightGuard {
263    state: ServerState,
264    run_id: String,
265}
266
267impl Drop for InFlightGuard {
268    fn drop(&mut self) {
269        self.state.registry().mark_finished(&self.run_id);
270        metrics::set_run_gauges(&self.state);
271    }
272}
273
274/// Terminal classification of a run task.
275enum Terminal {
276    Completed {
277        records: u64,
278        invs: Vec<InvocationRecord>,
279    },
280    Failed {
281        reason: String,
282        records: u64,
283        invs: Vec<InvocationRecord>,
284    },
285    Timeout {
286        secs: u64,
287    },
288    Cancelled,
289    ShutdownFailed,
290}
291
292impl Terminal {
293    /// (status, metric reason label, records, invocations, error message)
294    fn into_parts(
295        self,
296    ) -> (
297        RunStatus,
298        &'static str,
299        u64,
300        Vec<InvocationRecord>,
301        Option<String>,
302    ) {
303        match self {
304            Terminal::Completed { records, invs } => {
305                (RunStatus::Completed, "ok", records, invs, None)
306            }
307            Terminal::Failed {
308                reason,
309                records,
310                invs,
311            } => (RunStatus::Failed, "error", records, invs, Some(reason)),
312            Terminal::Timeout { secs } => (
313                RunStatus::Failed,
314                "timeout",
315                0,
316                Vec::new(),
317                Some(format!("run exceeded timeout_secs ({secs}s)")),
318            ),
319            Terminal::Cancelled => (RunStatus::Cancelled, "cancelled", 0, Vec::new(), None),
320            Terminal::ShutdownFailed => (
321                RunStatus::Failed,
322                "server_shutdown",
323                0,
324                Vec::new(),
325                Some("server shutdown before the run finished".into()),
326            ),
327        }
328    }
329}
330
331/// Classify a finished `run_expanded` result into a `Terminal`.
332fn classify_run(result: crate::error::CliResult<RunSummary>) -> Terminal {
333    match result {
334        Ok(summary) => {
335            let records: u64 = summary
336                .invocations
337                .iter()
338                .map(|i| i.records_written as u64)
339                .sum();
340            let invs: Vec<InvocationRecord> = summary
341                .invocations
342                .iter()
343                .map(InvocationRecord::from)
344                .collect();
345            if summary.had_failures() {
346                Terminal::Failed {
347                    reason: format!("{} invocation(s) failed", summary.failure_count()),
348                    records,
349                    invs,
350                }
351            } else {
352                Terminal::Completed { records, invs }
353            }
354        }
355        Err(e) => Terminal::Failed {
356            reason: e.to_string(),
357            records: 0,
358            invs: Vec::new(),
359        },
360    }
361}
362
363/// Parse the optional request `clock` (RFC3339), defaulting to `submitted_at`.
364fn resolve_clock(
365    flag: Option<&str>,
366    default: DateTime<Utc>,
367) -> Result<DateTime<FixedOffset>, ServeError> {
368    match flag {
369        None => Ok(default.fixed_offset()),
370        Some(s) => DateTime::parse_from_rfc3339(s)
371            .map_err(|_| ServeError::BadConfig(format!("clock '{s}' is not RFC3339"))),
372    }
373}
374
375/// Spawn the detached run task: acquire a permit, run under the 3-arm select,
376/// finalize the terminal status.
377fn spawn_run(
378    state: ServerState,
379    loaded: LoadedSubmission,
380    req: SubmitRequest,
381    run_id: String,
382    run_token: CancellationToken,
383    submitted_at: DateTime<Utc>,
384) {
385    let server_shutdown = state.shutdown_token();
386    let LoadedSubmission { cfg, nodes } = loaded;
387
388    tokio::spawn(async move {
389        // Race the permit acquisition against cancel / shutdown so a run
390        // cancelled while STILL QUEUED (before any permit frees) is finalized
391        // immediately, instead of only after it eventually acquires a permit
392        // (#146 R). `biased` prefers the cancel/shutdown signals over a
393        // simultaneously-available permit.
394        let _permit = tokio::select! {
395            biased;
396            _ = run_token.cancelled() => {
397                finalize_queued_cancel(&state, &run_id, submitted_at, Terminal::Cancelled).await;
398                return;
399            }
400            _ = server_shutdown.cancelled() => {
401                finalize_queued_cancel(&state, &run_id, submitted_at, Terminal::ShutdownFailed).await;
402                return;
403            }
404            permit = state.semaphore().acquire_owned() => permit.expect("semaphore not closed"),
405        };
406
407        // Queued → running. From here the guard guarantees `mark_finished` (and a
408        // gauge refresh) on EVERY exit, including early returns and panics.
409        state.registry().mark_running();
410        let _guard = InFlightGuard {
411            state: state.clone(),
412            run_id: run_id.clone(),
413        };
414        let started = Utc::now();
415        if let Ok(Some(mut rec)) = state.history().get(&run_id).await {
416            rec.status = RunStatus::Running;
417            rec.started_at = Some(started);
418            let _ = state.history().upsert(&rec).await;
419        }
420        metrics::set_run_gauges(&state);
421
422        // Build execution options (auth/clock failures finalize as Failed).
423        let pipeline_name = cfg.name.clone().unwrap_or_else(|| "serve".to_string());
424        let auth = match build_auth_catalog(cfg.auth.as_ref()) {
425            Ok(a) => a,
426            Err(e) => {
427                finalize(
428                    &state,
429                    &run_id,
430                    started,
431                    Terminal::Failed {
432                        reason: format!("auth catalog: {e}"),
433                        records: 0,
434                        invs: Vec::new(),
435                    },
436                )
437                .await;
438                return;
439            }
440        };
441        let clock = match resolve_clock(req.clock.as_deref(), submitted_at) {
442            Ok(c) => c,
443            Err(e) => {
444                finalize(
445                    &state,
446                    &run_id,
447                    started,
448                    Terminal::Failed {
449                        reason: e.api_error().error.message,
450                        records: 0,
451                        invs: Vec::new(),
452                    },
453                )
454                .await;
455                return;
456            }
457        };
458
459        // Cooperative-cancel token the pipeline observes so it flushes buffered
460        // output (e.g. a Parquet footer, an S3 multipart upload) at its next
461        // page boundary on cancel / timeout / shutdown — instead of having its
462        // future hard-dropped, which flushes nothing (#146 H16).
463        let coop = CancellationToken::new();
464        let opts = ExecuteOptions {
465            pipeline_name,
466            execution: cfg.execution.clone(),
467            dry_run: false,
468            limit: None,
469            state_path_override: None,
470            auth,
471            clock,
472            cancel: Some(coop.clone()),
473        };
474        let timeout_secs = req.timeout_secs;
475
476        let span = tracing::info_span!("faucet.serve.run", serve_run_id = %run_id);
477        let work = async move {
478            // Emitted inside the run span so it is captured by the SSE log layer
479            // (and gives every `/logs` reader at least one line to anchor on).
480            tracing::info!("pipeline run starting");
481            classify_run(run_expanded(nodes, opts).await)
482        }
483        .instrument(span);
484        tokio::pin!(work);
485
486        // The run timeout is modelled as a cancel trigger (not a hard
487        // `tokio::time::timeout` drop) so a timed-out run still flushes.
488        let timeout_fut = async {
489            match timeout_secs {
490                Some(s) => tokio::time::sleep(Duration::from_secs(s)).await,
491                None => std::future::pending::<()>().await,
492            }
493        };
494        tokio::pin!(timeout_fut);
495
496        enum Trigger {
497            Done(Terminal),
498            Cancel,
499            Shutdown,
500            Timeout(u64),
501        }
502
503        // Phase 1: run to natural completion, or until a cancel trigger fires.
504        // `biased` prefers a just-completed run over a simultaneous trigger.
505        let trigger = tokio::select! {
506            biased;
507            t = &mut work => Trigger::Done(t),
508            _ = run_token.cancelled() => Trigger::Cancel,
509            _ = server_shutdown.cancelled() => Trigger::Shutdown,
510            _ = &mut timeout_fut => Trigger::Timeout(timeout_secs.unwrap_or(0)),
511        };
512
513        let terminal = match trigger {
514            Trigger::Done(t) => t,
515            triggered => {
516                // Phase 2: a trigger fired. Cancel cooperatively and give the
517                // pipeline a bounded grace to flush at its next page boundary,
518                // then hard-drop it (drops the JoinSet, aborting any pipeline
519                // genuinely stuck mid-write) so a hung run can't wedge shutdown.
520                coop.cancel();
521                let _ = tokio::time::timeout(RUN_FLUSH_GRACE, &mut work).await;
522                match triggered {
523                    Trigger::Cancel => Terminal::Cancelled,
524                    Trigger::Shutdown => Terminal::ShutdownFailed,
525                    Trigger::Timeout(secs) => Terminal::Timeout { secs },
526                    Trigger::Done(_) => unreachable!("matched in the outer arm"),
527                }
528            }
529        };
530
531        finalize(&state, &run_id, started, terminal).await;
532        // Signal `/logs` readers the run is done, then drop the buffer after a
533        // drain window so a late fetcher can still replay it (spec §12).
534        state.log_hub().finish(&run_id);
535        schedule_log_drop(state.clone(), run_id.clone());
536        // `_guard` drops here → mark_finished + gauge refresh.
537    });
538}
539
540/// Write the authoritative terminal record + the run-finished metric.
541async fn finalize(state: &ServerState, run_id: &str, started: DateTime<Utc>, term: Terminal) {
542    let finished = Utc::now();
543    let elapsed = (finished - started).to_std().ok().map(|d| d.as_secs_f64());
544    let (status, reason, records, invs, error) = term.into_parts();
545    // Read-modify-write the existing record to preserve its metadata
546    // (name / labels / idempotency_key / submitted_at). If it can't be read —
547    // the backend errored, or the record was purged / landed in another store
548    // under degraded fallback — DON'T silently drop the terminal status (#146
549    // M6): reconstruct a minimal terminal record and upsert it, so the run
550    // never lingers non-terminal while `record_run_finished` has already fired.
551    let mut rec = match state.history().get(run_id).await {
552        Ok(Some(rec)) => rec,
553        Ok(None) => {
554            tracing::warn!(
555                run_id,
556                "finalize: run record not found; writing a fresh terminal record"
557            );
558            RunRecord::queued(run_id.to_string(), None, BTreeMap::new(), None, started)
559        }
560        Err(e) => {
561            tracing::warn!(
562                run_id,
563                error = %e,
564                "finalize: failed to read run record; writing a fresh terminal record"
565            );
566            RunRecord::queued(run_id.to_string(), None, BTreeMap::new(), None, started)
567        }
568    };
569    rec.status = status;
570    rec.started_at.get_or_insert(started);
571    rec.finished_at = Some(finished);
572    rec.elapsed_secs = elapsed;
573    rec.records_written = records;
574    rec.invocations = invs;
575    rec.error = error;
576    if let Err(e) = state.history().upsert(&rec).await {
577        tracing::error!(
578            run_id,
579            error = %e,
580            "finalize: failed to persist terminal run record"
581        );
582    }
583    metrics::record_run_finished(status, reason);
584}
585
586/// Finalize a run that was cancelled / hit shutdown while still QUEUED (before
587/// it acquired an execution permit, so it never became in-flight and has no
588/// `InFlightGuard`). Releases the queue slot, writes the terminal record, closes
589/// the log buffer, and refreshes the gauges — the queued-path analogue of the
590/// normal `finalize` + `InFlightGuard`-drop cleanup.
591async fn finalize_queued_cancel(
592    state: &ServerState,
593    run_id: &str,
594    submitted_at: DateTime<Utc>,
595    term: Terminal,
596) {
597    state.registry().mark_queued_cancelled(run_id);
598    finalize(state, run_id, submitted_at, term).await;
599    state.log_hub().finish(run_id);
600    schedule_log_drop(state.clone(), run_id.to_string());
601    metrics::set_run_gauges(state);
602}
603
604/// Spawn a detached timer that drops a finished run's log buffer after the drain
605/// window, freeing its ring once late `/logs` fetchers have had a chance to read.
606fn schedule_log_drop(state: ServerState, run_id: String) {
607    tokio::spawn(async move {
608        tokio::time::sleep(crate::serve::logs::LOG_DRAIN).await;
609        state.log_hub().drop_run(&run_id);
610    });
611}
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616
617    #[test]
618    fn classify_ok_no_failures_is_completed() {
619        let summary = RunSummary {
620            invocations: vec![crate::executor::InvocationOutcome {
621                row_id: "r".into(),
622                parent_record_key: None,
623                records_written: 3,
624                error: None,
625            }],
626        };
627        let (status, reason, records, _, error) = classify_run(Ok(summary)).into_parts();
628        assert_eq!(status, RunStatus::Completed);
629        assert_eq!(reason, "ok");
630        assert_eq!(records, 3);
631        assert!(error.is_none());
632    }
633
634    #[test]
635    fn classify_ok_with_failures_is_failed() {
636        let summary = RunSummary {
637            invocations: vec![crate::executor::InvocationOutcome {
638                row_id: "r".into(),
639                parent_record_key: None,
640                records_written: 0,
641                error: Some("boom".into()),
642            }],
643        };
644        let (status, reason, _, _, error) = classify_run(Ok(summary)).into_parts();
645        assert_eq!(status, RunStatus::Failed);
646        assert_eq!(reason, "error");
647        assert!(error.unwrap().contains("invocation(s) failed"));
648    }
649
650    #[test]
651    fn timeout_maps_to_failed_with_timeout_reason() {
652        let (status, reason, _, _, error) = Terminal::Timeout { secs: 30 }.into_parts();
653        assert_eq!(status, RunStatus::Failed);
654        assert_eq!(reason, "timeout");
655        assert!(error.unwrap().contains("30s"));
656    }
657
658    #[test]
659    fn resolve_clock_defaults_and_parses() {
660        let default = Utc::now();
661        assert_eq!(
662            resolve_clock(None, default).unwrap(),
663            default.fixed_offset()
664        );
665        assert!(resolve_clock(Some("2026-01-31T00:00:00Z"), default).is_ok());
666        assert!(resolve_clock(Some("not-a-time"), default).is_err());
667    }
668
669    #[tokio::test]
670    async fn conflict_releases_reservation() {
671        use crate::serve::config::{AuthMode, HistoryBackendSpec, ServeConfig};
672        use crate::serve::history::RunHistory;
673        use crate::serve::history::memory::MemoryHistory;
674        use crate::serve::state::ServerState;
675        use std::sync::Arc;
676        use tokio_util::sync::CancellationToken;
677
678        let cfg = ServeConfig {
679            listen: "127.0.0.1:0".parse().unwrap(),
680            auth: AuthMode::None,
681            max_concurrent_runs: 4,
682            max_queued_runs: 4,
683            default_config_path: None,
684            history: HistoryBackendSpec::Memory,
685            cors_origins: vec![],
686            body_limit_bytes: 1_048_576,
687            shutdown_grace: Duration::from_secs(60),
688            retain_terminal_runs: Duration::from_secs(60),
689            idempotency_retention: Duration::from_secs(60),
690            lease_ttl: Duration::from_secs(30),
691            probe_timeout: Duration::from_secs(10),
692            env_file: None,
693            no_env_file: false,
694            log_level: "info".into(),
695        };
696        let history = Arc::new(MemoryHistory::new(Duration::from_secs(60))) as Arc<dyn RunHistory>;
697        let state = ServerState::new(
698            &cfg,
699            None,
700            CancellationToken::new(),
701            history,
702            crate::serve::logs::LogHub::new(),
703            None,
704        );
705
706        // Pre-claim the key with a DIFFERENT fingerprint so submit() hits Conflict.
707        state
708            .history()
709            .claim_idempotency("k", "different-fp", "prior", Duration::from_secs(60))
710            .await
711            .unwrap();
712
713        let req = SubmitRequest {
714            config: "version: 1\npipeline:\n  source: { type: csv, config: { path: x.csv } }\n  sink: { type: jsonl, config: { path: out.jsonl } }\n".into(),
715            config_format: ConfigFormatWire::Yaml,
716            name: None,
717            labels: BTreeMap::new(),
718            timeout_secs: None,
719            doctor_first: false,
720            idempotency_key: Some("k".into()),
721            clock: None,
722        };
723
724        let err = submit(state.clone(), req).await.unwrap_err();
725        assert!(
726            matches!(err, ServeError::Conflict(_)),
727            "expected Conflict, got {err:?}"
728        );
729        // The reservation taken before the claim must have been released by the guard.
730        assert_eq!(state.registry().queued(), 0);
731    }
732
733    /// A `ServerState` backed by an in-memory history, for finalize tests.
734    fn memory_state() -> crate::serve::state::ServerState {
735        use crate::serve::config::{AuthMode, HistoryBackendSpec, ServeConfig};
736        use crate::serve::history::RunHistory;
737        use crate::serve::history::memory::MemoryHistory;
738        use crate::serve::state::ServerState;
739        use std::sync::Arc;
740        use tokio_util::sync::CancellationToken;
741
742        let cfg = ServeConfig {
743            listen: "127.0.0.1:0".parse().unwrap(),
744            auth: AuthMode::None,
745            max_concurrent_runs: 4,
746            max_queued_runs: 4,
747            default_config_path: None,
748            history: HistoryBackendSpec::Memory,
749            cors_origins: vec![],
750            body_limit_bytes: 1_048_576,
751            shutdown_grace: Duration::from_secs(60),
752            retain_terminal_runs: Duration::from_secs(60),
753            idempotency_retention: Duration::from_secs(60),
754            lease_ttl: Duration::from_secs(30),
755            probe_timeout: Duration::from_secs(10),
756            env_file: None,
757            no_env_file: false,
758            log_level: "info".into(),
759        };
760        let history = Arc::new(MemoryHistory::new(Duration::from_secs(60))) as Arc<dyn RunHistory>;
761        ServerState::new(
762            &cfg,
763            None,
764            CancellationToken::new(),
765            history,
766            crate::serve::logs::LogHub::new(),
767            None,
768        )
769    }
770
771    #[tokio::test]
772    async fn finalize_writes_terminal_record_when_record_is_missing() {
773        // M6 (#146): if the run record can't be read at finalize time (purged,
774        // or split to another store under degraded fallback), the terminal
775        // status must NOT be silently dropped — a fresh terminal record is
776        // written so the run never lingers non-terminal while the run-finished
777        // metric has already fired.
778        let state = memory_state();
779        let started = Utc::now();
780        finalize(
781            &state,
782            "ghost",
783            started,
784            Terminal::Failed {
785                reason: "boom".into(),
786                records: 0,
787                invs: Vec::new(),
788            },
789        )
790        .await;
791        let rec = state
792            .history()
793            .get("ghost")
794            .await
795            .unwrap()
796            .expect("finalize must create a terminal record even when none existed");
797        assert_eq!(rec.status, RunStatus::Failed);
798        assert!(rec.finished_at.is_some());
799        assert!(rec.started_at.is_some());
800        assert_eq!(rec.error.as_deref(), Some("boom"));
801    }
802
803    #[tokio::test]
804    async fn finalize_preserves_metadata_of_existing_record() {
805        // The happy path still read-modify-writes, preserving name/labels/key.
806        let state = memory_state();
807        let started = Utc::now();
808        let mut rec = RunRecord::queued(
809            "r1".into(),
810            Some("nightly".into()),
811            BTreeMap::new(),
812            Some("idem-k".into()),
813            started,
814        );
815        rec.status = RunStatus::Running;
816        rec.started_at = Some(started);
817        state.history().upsert(&rec).await.unwrap();
818
819        finalize(
820            &state,
821            "r1",
822            started,
823            Terminal::Completed {
824                records: 5,
825                invs: Vec::new(),
826            },
827        )
828        .await;
829        let got = state.history().get("r1").await.unwrap().unwrap();
830        assert_eq!(got.status, RunStatus::Completed);
831        assert_eq!(got.records_written, 5);
832        assert_eq!(got.name.as_deref(), Some("nightly"));
833        assert_eq!(got.idempotency_key.as_deref(), Some("idem-k"));
834    }
835}