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 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 let _permit = state
119 .semaphore()
120 .acquire_owned()
121 .await
122 .expect("semaphore not closed");
123 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
141pub 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 if !state.registry().try_reserve() {
149 return Err(ServeError::QueueFull {
150 retry_after_secs: QUEUE_FULL_RETRY_AFTER_SECS,
151 });
152 }
153 let reservation = ReservationGuard::new(state.clone());
156
157 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 if let Some(key) = &req.idempotency_key {
172 let merged = serde_json::to_value(&loaded.cfg).unwrap_or(serde_json::Value::Null);
173 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 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 }
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 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 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 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 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
287pub(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 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
319async 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
334struct ReservationGuard {
340 state: Option<ServerState>,
341}
342
343impl ReservationGuard {
344 fn new(state: ServerState) -> Self {
345 Self { state: Some(state) }
346 }
347
348 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
363struct 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
379enum 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 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
436fn 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
468fn 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
480fn 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 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 });
522}
523
524#[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 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 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 let coop = CancellationToken::new();
606 #[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 tracing::info!("pipeline run starting");
647 classify_run(run_expanded(nodes, opts).await)
648 }
649 .instrument(span);
650 tokio::pin!(work);
651
652 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 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 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 state.log_hub().finish(&run_id);
701 schedule_log_drop(state.clone(), run_id.clone());
702 }
704
705async 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 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 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
767async 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
785fn 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 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 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 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 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 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 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 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 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 assert_eq!(state.registry().queued(), 0);
1161 }
1162}