1use 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
21const QUEUE_FULL_RETRY_AFTER_SECS: u64 = 5;
23
24const RUN_FLUSH_GRACE: Duration = Duration::from_secs(30);
30
31#[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#[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#[derive(Debug, Serialize)]
67pub struct SubmitResponse {
68 pub run_id: String,
69 pub status: RunStatus,
70 pub submitted_at: DateTime<Utc>,
71}
72
73pub 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 if !state.registry().try_reserve() {
81 return Err(ServeError::QueueFull {
82 retry_after_secs: QUEUE_FULL_RETRY_AFTER_SECS,
83 });
84 }
85 let reservation = ReservationGuard::new(state.clone());
88
89 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 if let Some(key) = &req.idempotency_key {
104 let merged = serde_json::to_value(&loaded.cfg).unwrap_or(serde_json::Value::Null);
105 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 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 }
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 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
182async 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 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
214async 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
229struct ReservationGuard {
235 state: Option<ServerState>,
236}
237
238impl ReservationGuard {
239 fn new(state: ServerState) -> Self {
240 Self { state: Some(state) }
241 }
242
243 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
258struct 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
274enum 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 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
331fn 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
363fn 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
375fn 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 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 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 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 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 tracing::info!("pipeline run starting");
481 classify_run(run_expanded(nodes, opts).await)
482 }
483 .instrument(span);
484 tokio::pin!(work);
485
486 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 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 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 state.log_hub().finish(&run_id);
535 schedule_log_drop(state.clone(), run_id.clone());
536 });
538}
539
540async 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 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
586async 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
604fn 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 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 assert_eq!(state.registry().queued(), 0);
731 }
732
733 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 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 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}