1use crate::auth_catalog::build_auth_catalog;
8use crate::executor::{ExecuteOptions, RunSummary, run_expanded};
9use crate::registry::build_source;
10use crate::serve::error::ServeError;
11use crate::serve::history::{Claim, InvocationRecord, RunRecord, RunStatus};
12use crate::serve::history::{ClaimedShard, ShardInsert};
13use crate::serve::load::{ConfigFormat, LoadedSubmission, load_submission};
14use crate::serve::rbac::AuthContext;
15use crate::serve::state::ServerState;
16use crate::serve::{idempotency, metrics};
17use chrono::{DateTime, FixedOffset, Utc};
18use serde::{Deserialize, Serialize};
19use std::collections::BTreeMap;
20use std::time::Duration;
21use tokio_util::sync::CancellationToken;
22use tracing::Instrument;
23
24const QUEUE_FULL_RETRY_AFTER_SECS: u64 = 5;
26
27const RUN_FLUSH_GRACE: Duration = Duration::from_secs(30);
33
34#[derive(Debug, Deserialize)]
36pub struct SubmitRequest {
37 pub config: String,
38 #[serde(default)]
39 pub config_format: ConfigFormatWire,
40 pub name: Option<String>,
41 #[serde(default)]
42 pub labels: BTreeMap<String, String>,
43 pub timeout_secs: Option<u64>,
44 #[serde(default)]
45 pub doctor_first: bool,
46 pub idempotency_key: Option<String>,
47 pub clock: Option<String>,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
52#[serde(rename_all = "lowercase")]
53pub enum ConfigFormatWire {
54 #[default]
55 Yaml,
56 Json,
57}
58
59impl From<ConfigFormatWire> for ConfigFormat {
60 fn from(w: ConfigFormatWire) -> Self {
61 match w {
62 ConfigFormatWire::Yaml => ConfigFormat::Yaml,
63 ConfigFormatWire::Json => ConfigFormat::Json,
64 }
65 }
66}
67
68#[derive(Debug, Serialize)]
70pub struct SubmitResponse {
71 pub run_id: String,
72 pub status: RunStatus,
73 pub submitted_at: DateTime<Utc>,
74}
75
76pub fn resume_claimed_run(state: ServerState, rec: RunRecord) {
80 tokio::spawn(async move {
81 let run_id = rec.run_id.clone();
82 let Some(body) = rec.config_body.as_deref() else {
83 tracing::error!(run_id, "claimed run has no stored config; failing it");
84 finalize(
85 &state,
86 &run_id,
87 rec.submitted_at,
88 Terminal::Failed {
89 reason: "claimed run record missing config_body".into(),
90 records: 0,
91 invs: Vec::new(),
92 },
93 )
94 .await;
95 return;
96 };
97 let format = rec.config_format.unwrap_or_default();
98 let loaded = match load_submission(body, format, state.default_base()).await {
99 Ok(l) => l,
100 Err(e) => {
101 finalize(
102 &state,
103 &run_id,
104 rec.submitted_at,
105 Terminal::Failed {
106 reason: format!(
107 "re-loading claimed config: {}",
108 e.api_error().error.message
109 ),
110 records: 0,
111 invs: Vec::new(),
112 },
113 )
114 .await;
115 return;
116 }
117 };
118
119 if let Some(sh) = loaded.cfg.shard.clone()
124 && sh.count >= 2
125 {
126 match coordinate_sharded_run(&state, &run_id, &loaded, sh.count).await {
127 Ok(true) => return, Ok(false) => {} Err(e) => {
130 finalize(
131 &state,
132 &run_id,
133 rec.submitted_at,
134 Terminal::Failed {
135 reason: format!("sharding: {e}"),
136 records: 0,
137 invs: Vec::new(),
138 },
139 )
140 .await;
141 return;
142 }
143 }
144 }
145
146 let _permit = state
149 .semaphore()
150 .acquire_owned()
151 .await
152 .expect("semaphore not closed");
153 let run_token = CancellationToken::new();
156 state.registry().register(run_id.clone(), run_token.clone());
157 execute_run(
158 state.clone(),
159 loaded,
160 run_id,
161 run_token,
162 rec.submitted_at,
163 rec.timeout_secs,
164 rec.clock.clone(),
165 false,
166 )
167 .await;
168 });
169}
170
171async fn coordinate_sharded_run(
179 state: &ServerState,
180 run_id: &str,
181 loaded: &LoadedSubmission,
182 count: usize,
183) -> crate::error::CliResult<bool> {
184 use crate::error::CliError;
185
186 if loaded.nodes.len() != 1 {
189 tracing::warn!(
190 run_id,
191 nodes = loaded.nodes.len(),
192 "shard requested but the run is not a single-node pipeline; running it whole"
193 );
194 return Ok(false);
195 }
196 let node = &loaded.nodes[0];
197 let auth = build_auth_catalog(loaded.cfg.auth.as_ref())
198 .map_err(|e| CliError::Internal(format!("auth catalog: {e}")))?;
199 let source = build_source(&node.source.kind, node.source.config.clone(), &auth, None).await?;
200 if !source.is_shardable() {
201 tracing::warn!(
202 run_id,
203 kind = %node.source.kind,
204 "source is not shardable; running the run whole"
205 );
206 return Ok(false);
207 }
208
209 {
224 let mut r = state
225 .history()
226 .get(run_id)
227 .await
228 .map_err(|e| CliError::Internal(e.to_string()))?
229 .ok_or_else(|| CliError::Internal(format!("run {run_id} vanished before sharding")))?;
230 r.status = RunStatus::Sharded;
231 state
232 .history()
233 .upsert(&r)
234 .await
235 .map_err(|e| CliError::Internal(e.to_string()))?;
236 }
237
238 let shards = source
239 .enumerate_shards(count)
240 .await
241 .map_err(|e| CliError::Internal(format!("enumerate_shards: {e}")))?;
242 let inserts: Vec<ShardInsert> = shards
243 .iter()
244 .map(|s| ShardInsert {
245 shard_id: s.id.clone(),
246 descriptor: s.descriptor.clone(),
247 size_estimate: s.size_estimate,
248 })
249 .collect();
250 let inserted = state
251 .history()
252 .insert_shards(run_id, &inserts)
253 .await
254 .map_err(|e| CliError::Internal(e.to_string()))?;
255 tracing::info!(
256 run_id,
257 shards = inserts.len(),
258 inserted,
259 "expanded run into shards (Mode B)"
260 );
261
262 state.cluster().kick();
264 Ok(true)
265}
266
267pub fn resume_claimed_shard(state: ServerState, claimed: ClaimedShard) {
271 tokio::spawn(async move {
272 let ClaimedShard {
273 run_id,
274 shard_id,
275 descriptor,
276 run,
277 } = claimed;
278
279 let Some(body) = run.config_body.clone() else {
280 tracing::error!(run_id, shard_id, "claimed shard's run has no stored config");
281 let _ = state
282 .history()
283 .finalize_shard(&run_id, &shard_id, false)
284 .await;
285 maybe_finalize_parent(&state, &run_id).await;
286 return;
287 };
288 let format = run.config_format.unwrap_or_default();
289 let loaded = match load_submission(&body, format, state.default_base()).await {
290 Ok(l) => l,
291 Err(e) => {
292 tracing::error!(
293 run_id,
294 shard_id,
295 error = %e.api_error().error.message,
296 "re-loading shard config failed"
297 );
298 let _ = state
299 .history()
300 .finalize_shard(&run_id, &shard_id, false)
301 .await;
302 maybe_finalize_parent(&state, &run_id).await;
303 return;
304 }
305 };
306
307 let _permit = state
308 .semaphore()
309 .acquire_owned()
310 .await
311 .expect("semaphore not closed");
312
313 let shard = faucet_core::ShardSpec {
314 id: shard_id.clone(),
315 descriptor,
316 size_estimate: None,
317 };
318 let coop = CancellationToken::new();
325 state
326 .registry()
327 .register_shard(&run_id, &shard_id, coop.clone());
328 let success = execute_shard(
329 &state,
330 loaded,
331 &run_id,
332 &shard_id,
333 shard,
334 coop,
335 run.timeout_secs,
336 run.clock.clone(),
337 run.submitted_at,
338 )
339 .await;
340 state.registry().deregister_shard(&run_id, &shard_id);
341
342 match state
343 .history()
344 .finalize_shard(&run_id, &shard_id, success)
345 .await
346 {
347 Ok(true) => {}
348 Ok(false) => tracing::warn!(
349 run_id,
350 shard_id,
351 "shard was reclaimed by another instance; discarding result"
352 ),
353 Err(e) => tracing::error!(run_id, shard_id, error = %e, "finalize_shard failed"),
354 }
355 maybe_finalize_parent(&state, &run_id).await;
356 });
357}
358
359#[allow(clippy::too_many_arguments)]
363async fn execute_shard(
364 state: &ServerState,
365 loaded: LoadedSubmission,
366 run_id: &str,
367 shard_id: &str,
368 shard: faucet_core::ShardSpec,
369 coop: CancellationToken,
370 timeout_secs: Option<u64>,
371 clock_flag: Option<String>,
372 submitted_at: DateTime<Utc>,
373) -> bool {
374 let LoadedSubmission { cfg, nodes } = loaded;
375 let pipeline_name = cfg.name.clone().unwrap_or_else(|| "serve".to_string());
376
377 let auth = match build_auth_catalog(cfg.auth.as_ref()) {
378 Ok(a) => a,
379 Err(e) => {
380 tracing::error!(run_id, shard_id, "shard auth catalog: {e}");
381 return false;
382 }
383 };
384 let clock = match resolve_clock(clock_flag.as_deref(), submitted_at) {
385 Ok(c) => c,
386 Err(e) => {
387 tracing::error!(
388 run_id,
389 shard_id,
390 "shard clock: {}",
391 e.api_error().error.message
392 );
393 return false;
394 }
395 };
396 let resilience = match &cfg.resilience {
397 Some(spec) => match spec.to_policy() {
398 Ok(p) => Some(p),
399 Err(e) => {
400 tracing::error!(run_id, shard_id, "shard resilience: {e}");
401 return false;
402 }
403 },
404 None => None,
405 };
406 #[cfg(feature = "lineage")]
407 let lineage = match crate::lineage_glue::build_emitter(cfg.lineage.as_ref()) {
408 Ok(l) => l,
409 Err(e) => {
410 tracing::error!(run_id, shard_id, "shard lineage: {e}");
411 return false;
412 }
413 };
414
415 let opts = ExecuteOptions {
419 pipeline_name,
420 execution: cfg.execution.clone(),
421 dry_run: false,
422 limit: None,
423 state_path_override: None,
424 shard: Some(shard),
425 auth,
426 clock,
427 cancel: Some(coop.clone()),
428 resilience,
429 sla: cfg.sla.clone(),
432 #[cfg(feature = "lineage")]
433 lineage,
434 #[cfg(feature = "lineage")]
435 lineage_cfg: cfg.lineage.clone(),
436 #[cfg(feature = "notify")]
437 notifier: None,
438 #[cfg(feature = "catalog")]
441 catalog: None,
442 };
443
444 let server_shutdown = state.shutdown_token();
445 let span = tracing::info_span!("faucet.serve.shard", serve_run_id = %run_id, shard = %shard_id);
446 let work = async move { classify_run(run_expanded(nodes, opts).await) }.instrument(span);
447 tokio::pin!(work);
448 let timeout_fut = async {
449 match timeout_secs {
450 Some(s) => tokio::time::sleep(Duration::from_secs(s)).await,
451 None => std::future::pending::<()>().await,
452 }
453 };
454 tokio::pin!(timeout_fut);
455
456 let terminal = tokio::select! {
464 biased;
465 t = &mut work => t,
466 _ = coop.cancelled() => {
467 match tokio::time::timeout(RUN_FLUSH_GRACE, &mut work).await {
471 Ok(failed @ Terminal::Failed { .. }) => failed,
472 Ok(_) | Err(_) => Terminal::Cancelled,
473 }
474 }
475 _ = server_shutdown.cancelled() => {
476 coop.cancel();
477 match tokio::time::timeout(RUN_FLUSH_GRACE, &mut work).await {
478 Ok(failed @ Terminal::Failed { .. }) => failed,
479 Ok(_) | Err(_) => Terminal::ShutdownFailed,
480 }
481 }
482 _ = &mut timeout_fut => {
483 coop.cancel();
484 match tokio::time::timeout(RUN_FLUSH_GRACE, &mut work).await {
485 Ok(failed @ Terminal::Failed { .. }) => failed,
486 Ok(_) | Err(_) => Terminal::Timeout { secs: timeout_secs.unwrap_or(0) },
487 }
488 }
489 };
490 matches!(terminal, Terminal::Completed { .. })
491}
492
493async fn maybe_finalize_parent(state: &ServerState, run_id: &str) {
498 let progress = match state.history().shard_progress(run_id).await {
499 Ok(p) => p,
500 Err(e) => {
501 tracing::warn!(run_id, error = %e, "shard_progress failed");
502 return;
503 }
504 };
505 if !progress.all_terminal() {
506 return;
507 }
508 let success = progress.failed == 0;
509 let status = if success {
510 RunStatus::Completed
511 } else {
512 RunStatus::Failed
513 };
514 let error =
515 (!success).then(|| format!("{}/{} shard(s) failed", progress.failed, progress.total));
516 match state
521 .history()
522 .finalize_sharded_parent(run_id, status, Utc::now(), error)
523 .await
524 {
525 Ok(true) => {
526 metrics::record_run_finished(status, if success { "ok" } else { "error" });
527 tracing::info!(
528 run_id,
529 shards = progress.total,
530 failed = progress.failed,
531 "sharded run finalized"
532 );
533 }
534 Ok(false) => {} Err(e) => {
536 tracing::error!(run_id, error = %e, "finalizing sharded parent run failed");
537 }
538 }
539}
540
541fn at_least_once_risky_sinks(
555 loaded: &LoadedSubmission,
556 clustered: bool,
557 sharded: bool,
558) -> Vec<&str> {
559 if !(clustered || sharded) || loaded.cfg.delivery == faucet_core::DeliveryMode::ExactlyOnce {
560 return Vec::new();
561 }
562 loaded
563 .nodes
564 .iter()
565 .filter(|n| {
566 !matches!(
567 n.sink
568 .config
569 .get("write_mode")
570 .and_then(|v| v.as_str())
571 .unwrap_or("append"),
572 "upsert" | "delete"
573 )
574 })
575 .map(|n| n.sink.kind.as_str())
576 .collect()
577}
578
579fn warn_if_cluster_at_least_once(loaded: &LoadedSubmission, clustered: bool, sharded: bool) {
580 let risky = at_least_once_risky_sinks(loaded, clustered, sharded);
581 if !risky.is_empty() {
582 let scope = if sharded {
583 "source-sharded (Mode B)"
584 } else {
585 "clustered"
586 };
587 tracing::warn!(
588 sinks = ?risky,
589 "{scope} execution is at-least-once: a failover or shard reclaim can re-run work \
590 and write duplicate rows to an append-mode sink. Set `write_mode: upsert` (or \
591 `delivery: exactly_once`) on the destination to make re-execution idempotent (F26/F39)."
592 );
593 }
594}
595
596async fn release_orphaned_claim(state: &ServerState, req: &SubmitRequest, run_id: &str) {
600 if req.idempotency_key.is_some()
601 && let Err(e) = state.history().release_idempotency(run_id).await
602 {
603 tracing::warn!(
604 run_id,
605 error = %e,
606 "failed to release idempotency claim after a run-record write error; \
607 a replay of the key may 404 until the claim self-expires"
608 );
609 }
610}
611
612pub async fn submit(
614 state: ServerState,
615 req: SubmitRequest,
616 actor: AuthContext,
617) -> Result<SubmitResponse, ServeError> {
618 let format: ConfigFormat = req.config_format.into();
619 let loaded = load_submission(&req.config, format, state.default_base()).await?;
620
621 let sharded = loaded.cfg.shard.as_ref().is_some_and(|s| s.count >= 2);
624 warn_if_cluster_at_least_once(&loaded, state.cluster().enabled(), sharded);
625
626 if !state.registry().try_reserve() {
629 return Err(ServeError::QueueFull {
630 retry_after_secs: QUEUE_FULL_RETRY_AFTER_SECS,
631 });
632 }
633 let reservation = ReservationGuard::new(state.clone());
636
637 let doctor_report = if req.doctor_first {
643 Some(run_doctor_first(&state, &loaded).await?)
644 } else {
645 None
646 };
647
648 let run_id = uuid::Uuid::now_v7().to_string();
649
650 let merged = serde_json::to_value(&loaded.cfg).unwrap_or(serde_json::Value::Null);
653 let fp_config = idempotency::fingerprint(&merged, loaded.cfg.name.as_deref());
654
655 if let Some(key) = &req.idempotency_key {
657 let fp = idempotency::request_fingerprint(
662 &fp_config,
663 req.clock.as_deref(),
664 req.timeout_secs,
665 &req.labels,
666 );
667 match state
668 .history()
669 .claim_idempotency(key, &fp, &run_id, state.idempotency_retention())
670 .await
671 .map_err(|e| match e {
672 crate::serve::history::HistoryError::Degraded(m) => ServeError::Unavailable(m),
674 other => ServeError::Internal(other.to_string()),
675 })? {
676 Claim::Fresh => {}
677 Claim::Replay(existing) => {
678 metrics::record_idempotency_hit();
679 return replay_response(&state, &existing).await;
680 }
681 Claim::Conflict => {
682 return Err(ServeError::Conflict(
683 "idempotency key reused with a different payload".into(),
684 ));
685 }
686 }
687 }
692
693 let submitted_at = Utc::now();
694 let mut rec = RunRecord::queued(
695 run_id.clone(),
696 req.name.clone(),
697 req.labels.clone(),
698 req.idempotency_key.clone(),
699 submitted_at,
700 );
701 rec.doctor_report = doctor_report;
702
703 if state.cluster().enabled() {
704 if state.history().degraded() {
709 return Err(ServeError::Unavailable(
710 "clustered run-history backend is degraded; runs cannot be claimed \
711 by any instance — retry once it recovers"
712 .into(),
713 ));
714 }
715 rec.status = RunStatus::Pending;
719 rec.config_body = Some(req.config.clone());
720 rec.config_format = Some(req.config_format.into());
721 rec.timeout_secs = req.timeout_secs;
722 rec.clock = req.clock.clone();
723 if let Err(e) = state.history().upsert(&rec).await {
724 release_orphaned_claim(&state, &req, &run_id).await;
728 return Err(ServeError::Internal(e.to_string()));
729 }
730 drop(reservation);
733 state.cluster().kick();
734 crate::serve::audit::write(
735 &state,
736 &actor,
737 "run.submit",
738 Some(run_id.clone()),
739 Some(fp_config.clone()),
740 "ok",
741 )
742 .await;
743 return Ok(SubmitResponse {
744 run_id,
745 status: RunStatus::Pending,
746 submitted_at,
747 });
748 }
749
750 if let Err(e) = state.history().upsert(&rec).await {
751 release_orphaned_claim(&state, &req, &run_id).await;
753 return Err(ServeError::Internal(e.to_string()));
754 }
755
756 let run_token = CancellationToken::new();
757 state.registry().register(run_id.clone(), run_token.clone());
758 metrics::set_run_gauges(&state);
759
760 reservation.defuse();
762 spawn_run(
763 state.clone(),
764 loaded,
765 req,
766 run_id.clone(),
767 run_token,
768 submitted_at,
769 );
770
771 crate::serve::audit::write(
772 &state,
773 &actor,
774 "run.submit",
775 Some(run_id.clone()),
776 Some(fp_config.clone()),
777 "ok",
778 )
779 .await;
780
781 Ok(SubmitResponse {
782 run_id,
783 status: RunStatus::Queued,
784 submitted_at,
785 })
786}
787
788pub(crate) async fn run_doctor_first(
793 state: &ServerState,
794 loaded: &LoadedSubmission,
795) -> Result<serde_json::Value, ServeError> {
796 use faucet_core::check::CheckContext;
797 let auth =
798 build_auth_catalog(loaded.cfg.auth.as_ref()).map_err(|e| ServeError::Unprocessable {
799 message: e.to_string(),
800 details: None,
801 })?;
802 let ctx = CheckContext {
803 timeout: state.probe_timeout(),
804 };
805 let pipeline_name = loaded
808 .cfg
809 .name
810 .clone()
811 .unwrap_or_else(|| "serve".to_string());
812 let mut invs = crate::commands::doctor::probe_roots(
813 &loaded.nodes,
814 &auth,
815 &ctx,
816 loaded.cfg.sla.as_ref(),
817 &pipeline_name,
818 )
819 .await;
820 let failed = crate::commands::doctor::count_failures(&invs);
821 crate::commands::doctor::redact_invocations(&mut invs);
824 let report = serde_json::json!({ "invocations": invs });
825 if failed > 0 {
826 return Err(ServeError::Unprocessable {
827 message: format!("doctor_first preflight failed: {failed} probe(s) failed"),
828 details: Some(report),
829 });
830 }
831 Ok(report)
832}
833
834async fn replay_response(state: &ServerState, run_id: &str) -> Result<SubmitResponse, ServeError> {
836 let rec = state
837 .history()
838 .get(run_id)
839 .await
840 .map_err(|e| ServeError::Internal(e.to_string()))?
841 .ok_or(ServeError::NotFound)?;
842 Ok(SubmitResponse {
843 run_id: rec.run_id,
844 status: rec.status,
845 submitted_at: rec.submitted_at,
846 })
847}
848
849struct ReservationGuard {
855 state: Option<ServerState>,
856}
857
858impl ReservationGuard {
859 fn new(state: ServerState) -> Self {
860 Self { state: Some(state) }
861 }
862
863 fn defuse(mut self) {
865 self.state = None;
866 }
867}
868
869impl Drop for ReservationGuard {
870 fn drop(&mut self) {
871 if let Some(state) = self.state.take() {
872 state.registry().release_reservation();
873 metrics::set_run_gauges(&state);
874 }
875 }
876}
877
878struct InFlightGuard {
883 state: ServerState,
884 run_id: String,
885}
886
887impl Drop for InFlightGuard {
888 fn drop(&mut self) {
889 self.state.registry().mark_finished(&self.run_id);
890 metrics::set_run_gauges(&self.state);
891 }
892}
893
894enum Terminal {
896 Completed {
897 records: u64,
898 invs: Vec<InvocationRecord>,
899 },
900 Failed {
901 reason: String,
902 records: u64,
903 invs: Vec<InvocationRecord>,
904 },
905 Timeout {
906 secs: u64,
907 },
908 Cancelled,
909 ShutdownFailed,
910}
911
912impl Terminal {
913 fn into_parts(
915 self,
916 ) -> (
917 RunStatus,
918 &'static str,
919 u64,
920 Vec<InvocationRecord>,
921 Option<String>,
922 ) {
923 match self {
924 Terminal::Completed { records, invs } => {
925 (RunStatus::Completed, "ok", records, invs, None)
926 }
927 Terminal::Failed {
928 reason,
929 records,
930 invs,
931 } => (RunStatus::Failed, "error", records, invs, Some(reason)),
932 Terminal::Timeout { secs } => (
933 RunStatus::Failed,
934 "timeout",
935 0,
936 Vec::new(),
937 Some(format!("run exceeded timeout_secs ({secs}s)")),
938 ),
939 Terminal::Cancelled => (RunStatus::Cancelled, "cancelled", 0, Vec::new(), None),
940 Terminal::ShutdownFailed => (
941 RunStatus::Failed,
942 "server_shutdown",
943 0,
944 Vec::new(),
945 Some("server shutdown before the run finished".into()),
946 ),
947 }
948 }
949}
950
951fn classify_run(result: crate::error::CliResult<RunSummary>) -> Terminal {
953 match result {
954 Ok(summary) => {
955 let records: u64 = summary
956 .invocations
957 .iter()
958 .map(|i| i.records_written as u64)
959 .sum();
960 let invs: Vec<InvocationRecord> = summary
961 .invocations
962 .iter()
963 .map(InvocationRecord::from)
964 .collect();
965 if summary.had_failures() {
966 Terminal::Failed {
967 reason: format!("{} invocation(s) failed", summary.failure_count()),
968 records,
969 invs,
970 }
971 } else {
972 Terminal::Completed { records, invs }
973 }
974 }
975 Err(e) => Terminal::Failed {
976 reason: e.to_string(),
977 records: 0,
978 invs: Vec::new(),
979 },
980 }
981}
982
983fn resolve_clock(
985 flag: Option<&str>,
986 default: DateTime<Utc>,
987) -> Result<DateTime<FixedOffset>, ServeError> {
988 match flag {
989 None => Ok(default.fixed_offset()),
990 Some(s) => DateTime::parse_from_rfc3339(s)
991 .map_err(|_| ServeError::BadConfig(format!("clock '{s}' is not RFC3339"))),
992 }
993}
994
995fn spawn_run(
998 state: ServerState,
999 loaded: LoadedSubmission,
1000 req: SubmitRequest,
1001 run_id: String,
1002 run_token: CancellationToken,
1003 submitted_at: DateTime<Utc>,
1004) {
1005 let server_shutdown = state.shutdown_token();
1006 tokio::spawn(async move {
1007 let _permit = tokio::select! {
1013 biased;
1014 _ = run_token.cancelled() => {
1015 finalize_queued_cancel(&state, &run_id, submitted_at, Terminal::Cancelled).await;
1016 return;
1017 }
1018 _ = server_shutdown.cancelled() => {
1019 finalize_queued_cancel(&state, &run_id, submitted_at, Terminal::ShutdownFailed).await;
1020 return;
1021 }
1022 permit = state.semaphore().acquire_owned() => permit.expect("semaphore not closed"),
1023 };
1024 execute_run(
1025 state,
1026 loaded,
1027 run_id,
1028 run_token,
1029 submitted_at,
1030 req.timeout_secs,
1031 req.clock,
1032 true,
1033 )
1034 .await;
1035 });
1037}
1038
1039#[allow(clippy::too_many_arguments)]
1045async fn execute_run(
1046 state: ServerState,
1047 loaded: LoadedSubmission,
1048 run_id: String,
1049 run_token: CancellationToken,
1050 submitted_at: DateTime<Utc>,
1051 timeout_secs: Option<u64>,
1052 clock_flag: Option<String>,
1053 from_queue: bool,
1054) {
1055 let server_shutdown = state.shutdown_token();
1056 let LoadedSubmission { cfg, nodes } = loaded;
1057
1058 if from_queue {
1063 state.registry().mark_running();
1064 } else {
1065 state.registry().mark_running_unqueued();
1066 }
1067 let _guard = InFlightGuard {
1068 state: state.clone(),
1069 run_id: run_id.clone(),
1070 };
1071 let started = Utc::now();
1072 if let Ok(Some(mut rec)) = state.history().get(&run_id).await {
1073 rec.status = RunStatus::Running;
1074 rec.started_at = Some(started);
1075 let _ = state.history().upsert(&rec).await;
1076 }
1077 metrics::set_run_gauges(&state);
1078
1079 let pipeline_name = cfg.name.clone().unwrap_or_else(|| "serve".to_string());
1081 let auth = match build_auth_catalog(cfg.auth.as_ref()) {
1082 Ok(a) => a,
1083 Err(e) => {
1084 finalize(
1085 &state,
1086 &run_id,
1087 started,
1088 Terminal::Failed {
1089 reason: format!("auth catalog: {e}"),
1090 records: 0,
1091 invs: Vec::new(),
1092 },
1093 )
1094 .await;
1095 return;
1096 }
1097 };
1098 let clock = match resolve_clock(clock_flag.as_deref(), submitted_at) {
1099 Ok(c) => c,
1100 Err(e) => {
1101 finalize(
1102 &state,
1103 &run_id,
1104 started,
1105 Terminal::Failed {
1106 reason: e.api_error().error.message,
1107 records: 0,
1108 invs: Vec::new(),
1109 },
1110 )
1111 .await;
1112 return;
1113 }
1114 };
1115
1116 let coop = CancellationToken::new();
1121 let resilience = match &cfg.resilience {
1125 Some(spec) => match spec.to_policy() {
1126 Ok(p) => Some(p),
1127 Err(e) => {
1128 finalize(
1129 &state,
1130 &run_id,
1131 started,
1132 Terminal::Failed {
1133 reason: format!("resilience: {e}"),
1134 records: 0,
1135 invs: Vec::new(),
1136 },
1137 )
1138 .await;
1139 return;
1140 }
1141 },
1142 None => None,
1143 };
1144 #[cfg(feature = "lineage")]
1148 let lineage = match crate::lineage_glue::build_emitter(cfg.lineage.as_ref()) {
1149 Ok(l) => l,
1150 Err(e) => {
1151 finalize(
1152 &state,
1153 &run_id,
1154 started,
1155 Terminal::Failed {
1156 reason: format!("lineage: {e}"),
1157 records: 0,
1158 invs: Vec::new(),
1159 },
1160 )
1161 .await;
1162 return;
1163 }
1164 };
1165 #[cfg(feature = "notify")]
1169 let notifier = crate::notify::Notifier::from_specs(&cfg.notifications).unwrap_or_else(|e| {
1170 tracing::error!(%run_id, "notifications config invalid, disabling: {e}");
1171 None
1172 });
1173 let opts = ExecuteOptions {
1174 pipeline_name,
1175 execution: cfg.execution.clone(),
1176 dry_run: false,
1177 limit: None,
1178 state_path_override: None,
1179 shard: None,
1180 auth,
1181 clock,
1182 cancel: Some(coop.clone()),
1183 resilience,
1184 sla: cfg.sla.clone(),
1185 #[cfg(feature = "lineage")]
1186 lineage,
1187 #[cfg(feature = "lineage")]
1188 lineage_cfg: cfg.lineage.clone(),
1189 #[cfg(feature = "notify")]
1190 notifier,
1191 #[cfg(feature = "catalog")]
1195 catalog: Some(crate::catalog::CatalogHandle {
1196 store: state.history(),
1197 run_id: Some(run_id.clone()),
1198 sample_records: crate::catalog::DEFAULT_SAMPLE_RECORDS,
1199 }),
1200 };
1201
1202 let span = tracing::info_span!("faucet.serve.run", serve_run_id = %run_id);
1203 let work = async move {
1204 tracing::info!("pipeline run starting");
1207 classify_run(run_expanded(nodes, opts).await)
1208 }
1209 .instrument(span);
1210 tokio::pin!(work);
1211
1212 let timeout_fut = async {
1215 match timeout_secs {
1216 Some(s) => tokio::time::sleep(Duration::from_secs(s)).await,
1217 None => std::future::pending::<()>().await,
1218 }
1219 };
1220 tokio::pin!(timeout_fut);
1221
1222 enum Trigger {
1223 Done(Terminal),
1224 Cancel,
1225 Shutdown,
1226 Timeout(u64),
1227 }
1228
1229 let trigger = tokio::select! {
1232 biased;
1233 t = &mut work => Trigger::Done(t),
1234 _ = run_token.cancelled() => Trigger::Cancel,
1235 _ = server_shutdown.cancelled() => Trigger::Shutdown,
1236 _ = &mut timeout_fut => Trigger::Timeout(timeout_secs.unwrap_or(0)),
1237 };
1238
1239 let terminal = match trigger {
1240 Trigger::Done(t) => t,
1241 triggered => {
1242 coop.cancel();
1247 let trigger_terminal = match triggered {
1248 Trigger::Cancel => Terminal::Cancelled,
1249 Trigger::Shutdown => Terminal::ShutdownFailed,
1250 Trigger::Timeout(secs) => Terminal::Timeout { secs },
1251 Trigger::Done(_) => unreachable!("matched in the outer arm"),
1252 };
1253 match tokio::time::timeout(RUN_FLUSH_GRACE, &mut work).await {
1259 Ok(failed @ Terminal::Failed { .. }) => failed,
1260 Ok(_) | Err(_) => trigger_terminal,
1261 }
1262 }
1263 };
1264
1265 finalize(&state, &run_id, started, terminal).await;
1266 state.log_hub().finish(&run_id);
1269 schedule_log_drop(state.clone(), run_id.clone());
1270 }
1272
1273async fn finalize(state: &ServerState, run_id: &str, started: DateTime<Utc>, term: Terminal) {
1275 let finished = Utc::now();
1276 let elapsed = (finished - started).to_std().ok().map(|d| d.as_secs_f64());
1277 let (status, reason, records, invs, error) = term.into_parts();
1278 let mut rec = match state.history().get(run_id).await {
1285 Ok(Some(rec)) => rec,
1286 Ok(None) => {
1287 tracing::warn!(
1288 run_id,
1289 "finalize: run record not found; writing a fresh terminal record"
1290 );
1291 RunRecord::queued(run_id.to_string(), None, BTreeMap::new(), None, started)
1292 }
1293 Err(e) => {
1294 tracing::warn!(
1295 run_id,
1296 error = %e,
1297 "finalize: failed to read run record; writing a fresh terminal record"
1298 );
1299 RunRecord::queued(run_id.to_string(), None, BTreeMap::new(), None, started)
1300 }
1301 };
1302 rec.status = status;
1303 rec.started_at.get_or_insert(started);
1304 rec.finished_at = Some(finished);
1305 rec.elapsed_secs = elapsed;
1306 rec.records_written = records;
1307 rec.invocations = invs;
1308 rec.error = error;
1309 if state.cluster().enabled() {
1310 match state.history().finalize_owned(&rec).await {
1314 Ok(true) => metrics::record_run_finished(status, reason),
1315 Ok(false) => tracing::warn!(
1316 run_id,
1317 "finalize: run was reclaimed by another instance; discarding result"
1318 ),
1319 Err(e) => {
1320 tracing::error!(run_id, error = %e, "finalize: owner-fenced write failed")
1321 }
1322 }
1323 } else {
1324 if let Err(e) = state.history().upsert(&rec).await {
1325 tracing::error!(
1326 run_id,
1327 error = %e,
1328 "finalize: failed to persist terminal run record"
1329 );
1330 }
1331 metrics::record_run_finished(status, reason);
1332 }
1333}
1334
1335async fn finalize_queued_cancel(
1341 state: &ServerState,
1342 run_id: &str,
1343 submitted_at: DateTime<Utc>,
1344 term: Terminal,
1345) {
1346 state.registry().mark_queued_cancelled(run_id);
1347 finalize(state, run_id, submitted_at, term).await;
1348 state.log_hub().finish(run_id);
1349 schedule_log_drop(state.clone(), run_id.to_string());
1350 metrics::set_run_gauges(state);
1351}
1352
1353fn schedule_log_drop(state: ServerState, run_id: String) {
1356 tokio::spawn(async move {
1357 tokio::time::sleep(crate::serve::logs::LOG_DRAIN).await;
1358 state.log_hub().drop_run(&run_id);
1359 });
1360}
1361
1362#[cfg(test)]
1363mod tests {
1364 use super::*;
1365
1366 fn admin_actor() -> AuthContext {
1368 AuthContext {
1369 principal: "test".into(),
1370 role: crate::serve::rbac::Role::Admin,
1371 source_ip: None,
1372 }
1373 }
1374
1375 #[test]
1376 fn classify_ok_no_failures_is_completed() {
1377 let summary = RunSummary {
1378 invocations: vec![crate::executor::InvocationOutcome {
1379 row_id: "r".into(),
1380 parent_record_key: None,
1381 records_written: 3,
1382 error: None,
1383 }],
1384 };
1385 let (status, reason, records, _, error) = classify_run(Ok(summary)).into_parts();
1386 assert_eq!(status, RunStatus::Completed);
1387 assert_eq!(reason, "ok");
1388 assert_eq!(records, 3);
1389 assert!(error.is_none());
1390 }
1391
1392 #[test]
1393 fn classify_ok_with_failures_is_failed() {
1394 let summary = RunSummary {
1395 invocations: vec![crate::executor::InvocationOutcome {
1396 row_id: "r".into(),
1397 parent_record_key: None,
1398 records_written: 0,
1399 error: Some("boom".into()),
1400 }],
1401 };
1402 let (status, reason, _, _, error) = classify_run(Ok(summary)).into_parts();
1403 assert_eq!(status, RunStatus::Failed);
1404 assert_eq!(reason, "error");
1405 assert!(error.unwrap().contains("invocation(s) failed"));
1406 }
1407
1408 #[test]
1409 fn timeout_maps_to_failed_with_timeout_reason() {
1410 let (status, reason, _, _, error) = Terminal::Timeout { secs: 30 }.into_parts();
1411 assert_eq!(status, RunStatus::Failed);
1412 assert_eq!(reason, "timeout");
1413 assert!(error.unwrap().contains("30s"));
1414 }
1415
1416 #[test]
1417 fn resolve_clock_defaults_and_parses() {
1418 let default = Utc::now();
1419 assert_eq!(
1420 resolve_clock(None, default).unwrap(),
1421 default.fixed_offset()
1422 );
1423 assert!(resolve_clock(Some("2026-01-31T00:00:00Z"), default).is_ok());
1424 assert!(resolve_clock(Some("not-a-time"), default).is_err());
1425 }
1426
1427 #[tokio::test]
1428 async fn conflict_releases_reservation() {
1429 use crate::serve::config::{AuthMode, HistoryBackendSpec, ServeConfig};
1430 use crate::serve::history::RunHistory;
1431 use crate::serve::history::memory::MemoryHistory;
1432 use crate::serve::state::ServerState;
1433 use std::sync::Arc;
1434 use tokio_util::sync::CancellationToken;
1435
1436 let cfg = ServeConfig {
1437 listen: "127.0.0.1:0".parse().unwrap(),
1438 auth: AuthMode::None,
1439 max_concurrent_runs: 4,
1440 max_queued_runs: 4,
1441 default_config_path: None,
1442 history: HistoryBackendSpec::Memory,
1443 cors_origins: vec![],
1444 body_limit_bytes: 1_048_576,
1445 shutdown_grace: Duration::from_secs(60),
1446 retain_terminal_runs: Duration::from_secs(60),
1447 idempotency_retention: Duration::from_secs(60),
1448 lease_ttl: Duration::from_secs(30),
1449 probe_timeout: Duration::from_secs(10),
1450 env_file: None,
1451 no_env_file: false,
1452 log_level: "info".into(),
1453 ui_enabled: true,
1454 cluster: crate::serve::cluster::ClusterConfig::disabled(),
1455 triggers_path: None,
1456 };
1457 let history = Arc::new(MemoryHistory::new(Duration::from_secs(60))) as Arc<dyn RunHistory>;
1458 let state = ServerState::new(
1459 &cfg,
1460 None,
1461 CancellationToken::new(),
1462 history,
1463 crate::serve::logs::LogHub::new(),
1464 None,
1465 #[cfg(feature = "triggers")]
1466 crate::serve::triggers::health::TriggersHandle::empty(),
1467 );
1468
1469 state
1471 .history()
1472 .claim_idempotency("k", "different-fp", "prior", Duration::from_secs(60))
1473 .await
1474 .unwrap();
1475
1476 let req = SubmitRequest {
1477 config: "version: 1\npipeline:\n source: { type: csv, config: { path: x.csv } }\n sink: { type: jsonl, config: { path: out.jsonl } }\n".into(),
1478 config_format: ConfigFormatWire::Yaml,
1479 name: None,
1480 labels: BTreeMap::new(),
1481 timeout_secs: None,
1482 doctor_first: false,
1483 idempotency_key: Some("k".into()),
1484 clock: None,
1485 };
1486
1487 let err = submit(state.clone(), req, admin_actor()).await.unwrap_err();
1488 assert!(
1489 matches!(err, ServeError::Conflict(_)),
1490 "expected Conflict, got {err:?}"
1491 );
1492 assert_eq!(state.registry().queued(), 0);
1494 }
1495
1496 #[tokio::test]
1497 async fn cluster_submit_writes_pending_with_config_and_does_not_spawn() {
1498 use crate::serve::cluster::ClusterConfig;
1499 use crate::serve::config::{AuthMode, HistoryBackendSpec, ServeConfig};
1500 use crate::serve::history::RunHistory;
1501 use crate::serve::history::memory::MemoryHistory;
1502 use crate::serve::state::ServerState;
1503 use std::sync::Arc;
1504 use tokio_util::sync::CancellationToken;
1505
1506 let mut cluster = ClusterConfig::disabled();
1507 cluster.enabled = true;
1508 let cfg = ServeConfig {
1509 listen: "127.0.0.1:0".parse().unwrap(),
1510 auth: AuthMode::None,
1511 max_concurrent_runs: 4,
1512 max_queued_runs: 4,
1513 default_config_path: None,
1514 history: HistoryBackendSpec::Memory,
1515 cors_origins: vec![],
1516 body_limit_bytes: 1_048_576,
1517 shutdown_grace: Duration::from_secs(60),
1518 retain_terminal_runs: Duration::from_secs(60),
1519 idempotency_retention: Duration::from_secs(60),
1520 lease_ttl: Duration::from_secs(30),
1521 probe_timeout: Duration::from_secs(10),
1522 env_file: None,
1523 no_env_file: false,
1524 log_level: "info".into(),
1525 ui_enabled: true,
1526 cluster,
1527 triggers_path: None,
1528 };
1529 let history = Arc::new(MemoryHistory::new(Duration::from_secs(60))) as Arc<dyn RunHistory>;
1530 let state = ServerState::new(
1531 &cfg,
1532 None,
1533 CancellationToken::new(),
1534 history,
1535 crate::serve::logs::LogHub::new(),
1536 None,
1537 #[cfg(feature = "triggers")]
1538 crate::serve::triggers::health::TriggersHandle::empty(),
1539 );
1540
1541 let req = SubmitRequest {
1542 config: "version: 1\npipeline:\n source: { type: csv, config: { path: x.csv } }\n sink: { type: jsonl, config: { path: out.jsonl } }\n".into(),
1543 config_format: ConfigFormatWire::Yaml,
1544 name: Some("n".into()),
1545 labels: BTreeMap::new(),
1546 timeout_secs: Some(99),
1547 doctor_first: false,
1548 idempotency_key: None,
1549 clock: None,
1550 };
1551 let resp = submit(state.clone(), req, admin_actor()).await.unwrap();
1552 assert_eq!(resp.status, RunStatus::Pending);
1553 assert_eq!(state.registry().queued(), 0);
1555 let rec = state.history().get(&resp.run_id).await.unwrap().unwrap();
1556 assert_eq!(rec.status, RunStatus::Pending);
1557 assert!(rec.config_body.as_deref().unwrap().contains("version: 1"));
1558 assert_eq!(rec.timeout_secs, Some(99));
1559 }
1560
1561 fn memory_state() -> crate::serve::state::ServerState {
1563 use crate::serve::config::{AuthMode, HistoryBackendSpec, ServeConfig};
1564 use crate::serve::history::RunHistory;
1565 use crate::serve::history::memory::MemoryHistory;
1566 use crate::serve::state::ServerState;
1567 use std::sync::Arc;
1568 use tokio_util::sync::CancellationToken;
1569
1570 let cfg = ServeConfig {
1571 listen: "127.0.0.1:0".parse().unwrap(),
1572 auth: AuthMode::None,
1573 max_concurrent_runs: 4,
1574 max_queued_runs: 4,
1575 default_config_path: None,
1576 history: HistoryBackendSpec::Memory,
1577 cors_origins: vec![],
1578 body_limit_bytes: 1_048_576,
1579 shutdown_grace: Duration::from_secs(60),
1580 retain_terminal_runs: Duration::from_secs(60),
1581 idempotency_retention: Duration::from_secs(60),
1582 lease_ttl: Duration::from_secs(30),
1583 probe_timeout: Duration::from_secs(10),
1584 env_file: None,
1585 no_env_file: false,
1586 log_level: "info".into(),
1587 ui_enabled: true,
1588 cluster: crate::serve::cluster::ClusterConfig::disabled(),
1589 triggers_path: None,
1590 };
1591 let history = Arc::new(MemoryHistory::new(Duration::from_secs(60))) as Arc<dyn RunHistory>;
1592 ServerState::new(
1593 &cfg,
1594 None,
1595 CancellationToken::new(),
1596 history,
1597 crate::serve::logs::LogHub::new(),
1598 None,
1599 #[cfg(feature = "triggers")]
1600 crate::serve::triggers::health::TriggersHandle::empty(),
1601 )
1602 }
1603
1604 #[tokio::test]
1605 async fn finalize_writes_terminal_record_when_record_is_missing() {
1606 let state = memory_state();
1612 let started = Utc::now();
1613 finalize(
1614 &state,
1615 "ghost",
1616 started,
1617 Terminal::Failed {
1618 reason: "boom".into(),
1619 records: 0,
1620 invs: Vec::new(),
1621 },
1622 )
1623 .await;
1624 let rec = state
1625 .history()
1626 .get("ghost")
1627 .await
1628 .unwrap()
1629 .expect("finalize must create a terminal record even when none existed");
1630 assert_eq!(rec.status, RunStatus::Failed);
1631 assert!(rec.finished_at.is_some());
1632 assert!(rec.started_at.is_some());
1633 assert_eq!(rec.error.as_deref(), Some("boom"));
1634 }
1635
1636 #[tokio::test]
1637 async fn finalize_preserves_metadata_of_existing_record() {
1638 let state = memory_state();
1640 let started = Utc::now();
1641 let mut rec = RunRecord::queued(
1642 "r1".into(),
1643 Some("nightly".into()),
1644 BTreeMap::new(),
1645 Some("idem-k".into()),
1646 started,
1647 );
1648 rec.status = RunStatus::Running;
1649 rec.started_at = Some(started);
1650 state.history().upsert(&rec).await.unwrap();
1651
1652 finalize(
1653 &state,
1654 "r1",
1655 started,
1656 Terminal::Completed {
1657 records: 5,
1658 invs: Vec::new(),
1659 },
1660 )
1661 .await;
1662 let got = state.history().get("r1").await.unwrap().unwrap();
1663 assert_eq!(got.status, RunStatus::Completed);
1664 assert_eq!(got.records_written, 5);
1665 assert_eq!(got.name.as_deref(), Some("nightly"));
1666 assert_eq!(got.idempotency_key.as_deref(), Some("idem-k"));
1667 }
1668
1669 #[cfg(any(feature = "serve-history-sqlite", feature = "serve-history-postgres"))]
1670 #[tokio::test]
1671 async fn cluster_submit_503s_when_history_degraded() {
1672 use crate::serve::cluster::ClusterConfig;
1675 use crate::serve::config::{AuthMode, HistoryBackendSpec, ServeConfig};
1676 use crate::serve::history::RunHistory;
1677 use crate::serve::history::fallback::FallbackHistory;
1678 use crate::serve::state::ServerState;
1679 use std::sync::Arc;
1680 use tokio_util::sync::CancellationToken;
1681
1682 let mut cluster = ClusterConfig::disabled();
1683 cluster.enabled = true;
1684 let cfg = ServeConfig {
1685 listen: "127.0.0.1:0".parse().unwrap(),
1686 auth: AuthMode::None,
1687 max_concurrent_runs: 4,
1688 max_queued_runs: 4,
1689 default_config_path: None,
1690 history: HistoryBackendSpec::Memory,
1691 cors_origins: vec![],
1692 body_limit_bytes: 1_048_576,
1693 shutdown_grace: Duration::from_secs(60),
1694 retain_terminal_runs: Duration::from_secs(60),
1695 idempotency_retention: Duration::from_secs(60),
1696 lease_ttl: Duration::from_secs(30),
1697 probe_timeout: Duration::from_secs(10),
1698 env_file: None,
1699 no_env_file: false,
1700 log_level: "info".into(),
1701 ui_enabled: true,
1702 cluster,
1703 triggers_path: None,
1704 };
1705 let history = Arc::new(FallbackHistory::degraded_at_startup(
1707 Duration::from_secs(60),
1708 "test",
1709 )) as Arc<dyn RunHistory>;
1710 assert!(history.degraded());
1711 let state = ServerState::new(
1712 &cfg,
1713 None,
1714 CancellationToken::new(),
1715 history,
1716 crate::serve::logs::LogHub::new(),
1717 None,
1718 #[cfg(feature = "triggers")]
1719 crate::serve::triggers::health::TriggersHandle::empty(),
1720 );
1721 let req = SubmitRequest {
1722 config: "version: 1\npipeline:\n source: { type: csv, config: { path: x.csv } }\n sink: { type: jsonl, config: { path: out.jsonl } }\n".into(),
1723 config_format: ConfigFormatWire::Yaml,
1724 name: None,
1725 labels: BTreeMap::new(),
1726 timeout_secs: None,
1727 doctor_first: false,
1728 idempotency_key: None,
1729 clock: None,
1730 };
1731 let err = submit(state.clone(), req, admin_actor()).await.unwrap_err();
1732 assert!(
1733 matches!(err, ServeError::Unavailable(_)),
1734 "expected 503 Unavailable, got {err:?}"
1735 );
1736 assert_eq!(state.registry().queued(), 0);
1738 }
1739
1740 #[cfg(feature = "serve-history-sqlite")]
1746 mod shards {
1747 use super::*;
1748 use crate::serve::config::{AuthMode, HistoryBackendSpec, ServeConfig};
1749 use crate::serve::history::RunHistory;
1750 use crate::serve::history::sqlite::SqliteHistory;
1751 use crate::serve::load::{ConfigFormat, load_submission};
1752 use crate::serve::state::ServerState;
1753 use faucet_core::ShardSpec;
1754 use std::collections::BTreeMap;
1755 use std::sync::Arc;
1756 use tokio_util::sync::CancellationToken;
1757
1758 async fn sqlite_state(dir: &std::path::Path) -> ServerState {
1759 let url = format!("sqlite://{}/h.db", dir.display());
1760 let history = Arc::new(
1761 SqliteHistory::connect(
1762 &url,
1763 Duration::from_secs(300),
1764 Duration::from_secs(300),
1765 "inst-test".into(),
1766 )
1767 .await
1768 .expect("sqlite history"),
1769 ) as Arc<dyn RunHistory>;
1770 let cfg = ServeConfig {
1771 listen: "127.0.0.1:0".parse().unwrap(),
1772 auth: AuthMode::None,
1773 max_concurrent_runs: 4,
1774 max_queued_runs: 4,
1775 default_config_path: None,
1776 history: HistoryBackendSpec::Memory,
1777 cors_origins: vec![],
1778 body_limit_bytes: 1_048_576,
1779 shutdown_grace: Duration::from_secs(60),
1780 retain_terminal_runs: Duration::from_secs(60),
1781 idempotency_retention: Duration::from_secs(60),
1782 lease_ttl: Duration::from_secs(30),
1783 probe_timeout: Duration::from_secs(10),
1784 env_file: None,
1785 no_env_file: false,
1786 log_level: "info".into(),
1787 ui_enabled: true,
1788 cluster: crate::serve::cluster::ClusterConfig::disabled(),
1789 triggers_path: None,
1790 };
1791 ServerState::new(
1792 &cfg,
1793 None,
1794 CancellationToken::new(),
1795 history,
1796 crate::serve::logs::LogHub::new(),
1797 None,
1798 #[cfg(feature = "triggers")]
1799 crate::serve::triggers::health::TriggersHandle::empty(),
1800 )
1801 }
1802
1803 async fn loaded(yaml: &str) -> LoadedSubmission {
1804 load_submission(yaml, ConfigFormat::Yaml, None)
1805 .await
1806 .expect("load submission")
1807 }
1808
1809 async fn seed_run(state: &ServerState, run_id: &str, status: RunStatus) {
1810 let mut rec = RunRecord::queued(run_id.into(), None, BTreeMap::new(), None, Utc::now());
1811 rec.status = status;
1812 rec.config_body = Some("version: 1".into());
1813 state.history().upsert(&rec).await.expect("seed run");
1814 }
1815
1816 #[tokio::test]
1817 async fn coordinate_matrix_run_is_not_shardable() {
1818 let dir = tempfile::tempdir().unwrap();
1820 let state = sqlite_state(dir.path()).await;
1821 let l = loaded(
1822 "version: 1\nname: m\nmatrix:\n - id: a\n - id: b\npipeline:\n \
1823 source: { type: rest, config: { url: \"http://localhost/x\" } }\n \
1824 sink: { type: stdout, config: {} }\n",
1825 )
1826 .await;
1827 assert!(!coordinate_sharded_run(&state, "r", &l, 4).await.unwrap());
1828 }
1829
1830 #[tokio::test]
1831 async fn coordinate_non_shardable_source_runs_whole() {
1832 let dir = tempfile::tempdir().unwrap();
1834 let state = sqlite_state(dir.path()).await;
1835 let input = dir.path().join("in.csv");
1836 std::fs::write(&input, "id\n1\n").unwrap();
1837 let l = loaded(&format!(
1838 "version: 1\npipeline:\n \
1839 source: {{ type: csv, config: {{ path: \"{}\" }} }}\n \
1840 sink: {{ type: stdout, config: {{}} }}\n",
1841 input.display()
1842 ))
1843 .await;
1844 assert!(!coordinate_sharded_run(&state, "r", &l, 4).await.unwrap());
1845 }
1846
1847 #[tokio::test]
1848 async fn coordinate_s3_source_inserts_shards_and_marks_sharded() {
1849 let dir = tempfile::tempdir().unwrap();
1850 let state = sqlite_state(dir.path()).await;
1851 seed_run(&state, "r", RunStatus::Running).await;
1852 let l = loaded(
1853 "version: 1\npipeline:\n \
1854 source: { type: s3, config: { bucket: my-bucket, prefix: null, \
1855 region: null, endpoint_url: null, file_format: json_lines, \
1856 max_objects: null, concurrency: 10 } }\n \
1857 sink: { type: stdout, config: {} }\n",
1858 )
1859 .await;
1860 assert!(coordinate_sharded_run(&state, "r", &l, 4).await.unwrap());
1861 let prog = state.history().shard_progress("r").await.unwrap();
1863 assert_eq!(prog.total, 4);
1864 assert_eq!(prog.pending, 4);
1865 assert_eq!(
1866 state.history().get("r").await.unwrap().unwrap().status,
1867 RunStatus::Sharded
1868 );
1869 }
1870
1871 #[tokio::test]
1872 async fn at_least_once_risky_sinks_flags_append_cluster_and_shard() {
1873 let append = loaded(
1876 "version: 1\npipeline:\n \
1877 source: { type: rest, config: { url: \"http://localhost/x\" } }\n \
1878 sink: { type: stdout, config: {} }\n",
1879 )
1880 .await;
1881 assert!(at_least_once_risky_sinks(&append, false, false).is_empty());
1883 assert_eq!(
1885 at_least_once_risky_sinks(&append, true, false),
1886 vec!["stdout"]
1887 );
1888 assert_eq!(
1890 at_least_once_risky_sinks(&append, false, true),
1891 vec!["stdout"]
1892 );
1893 }
1894
1895 #[tokio::test]
1896 async fn at_least_once_risky_sinks_safe_for_upsert_and_exactly_once() {
1897 let upsert = loaded(
1899 "version: 1\npipeline:\n \
1900 source: { type: postgres, config: { connection_url: \"postgres://x\", \
1901 query: \"select 1\" } }\n \
1902 sink: { type: postgres, config: { connection_url: \"postgres://y\", \
1903 table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }\n",
1904 )
1905 .await;
1906 assert!(at_least_once_risky_sinks(&upsert, true, true).is_empty());
1907
1908 let eo = loaded(
1910 "version: 1\ndelivery: exactly_once\npipeline:\n \
1911 source: { type: postgres-cdc, config: {} }\n \
1912 sink: { type: sqlite, config: {} }\n \
1913 state: { type: file, config: { path: \"/tmp/x.json\" } }\n",
1914 )
1915 .await;
1916 assert!(at_least_once_risky_sinks(&eo, true, false).is_empty());
1917 }
1918
1919 async fn seed_sharded_with_shards(state: &ServerState, run_id: &str, n: usize) {
1920 use crate::serve::history::ShardInsert;
1921 seed_run(state, run_id, RunStatus::Sharded).await;
1922 let shards: Vec<ShardInsert> = (0..n)
1923 .map(|i| ShardInsert {
1924 shard_id: i.to_string(),
1925 descriptor: serde_json::json!({ "i": i }),
1926 size_estimate: None,
1927 })
1928 .collect();
1929 state
1930 .history()
1931 .insert_shards(run_id, &shards)
1932 .await
1933 .unwrap();
1934 let claimed = state.history().claim_shards(n).await.unwrap();
1936 assert_eq!(claimed.len(), n);
1937 }
1938
1939 #[tokio::test]
1940 async fn maybe_finalize_parent_completes_when_all_shards_succeed() {
1941 let dir = tempfile::tempdir().unwrap();
1942 let state = sqlite_state(dir.path()).await;
1943 seed_sharded_with_shards(&state, "r", 3).await;
1944 for i in 0..3 {
1945 state
1946 .history()
1947 .finalize_shard("r", &i.to_string(), true)
1948 .await
1949 .unwrap();
1950 }
1951 maybe_finalize_parent(&state, "r").await;
1952 assert_eq!(
1953 state.history().get("r").await.unwrap().unwrap().status,
1954 RunStatus::Completed
1955 );
1956 }
1957
1958 #[tokio::test]
1959 async fn maybe_finalize_parent_fails_when_a_shard_fails() {
1960 let dir = tempfile::tempdir().unwrap();
1961 let state = sqlite_state(dir.path()).await;
1962 seed_sharded_with_shards(&state, "r", 2).await;
1963 state
1964 .history()
1965 .finalize_shard("r", "0", true)
1966 .await
1967 .unwrap();
1968 state
1969 .history()
1970 .finalize_shard("r", "1", false)
1971 .await
1972 .unwrap();
1973 maybe_finalize_parent(&state, "r").await;
1974 assert_eq!(
1975 state.history().get("r").await.unwrap().unwrap().status,
1976 RunStatus::Failed
1977 );
1978 }
1979
1980 #[tokio::test]
1981 async fn maybe_finalize_parent_keeps_sharded_until_all_terminal() {
1982 let dir = tempfile::tempdir().unwrap();
1983 let state = sqlite_state(dir.path()).await;
1984 seed_sharded_with_shards(&state, "r", 2).await;
1985 state
1987 .history()
1988 .finalize_shard("r", "0", true)
1989 .await
1990 .unwrap();
1991 maybe_finalize_parent(&state, "r").await;
1992 assert_eq!(
1993 state.history().get("r").await.unwrap().unwrap().status,
1994 RunStatus::Sharded
1995 );
1996 }
1997
1998 #[tokio::test]
1999 async fn finalize_sharded_parent_is_status_fenced_and_idempotent() {
2000 let dir = tempfile::tempdir().unwrap();
2004 let state = sqlite_state(dir.path()).await;
2005 seed_sharded_with_shards(&state, "r", 2).await;
2006
2007 let first = state
2008 .history()
2009 .finalize_sharded_parent("r", RunStatus::Completed, Utc::now(), None)
2010 .await
2011 .unwrap();
2012 assert!(first, "first finalize wins");
2013 assert_eq!(
2014 state.history().get("r").await.unwrap().unwrap().status,
2015 RunStatus::Completed
2016 );
2017
2018 let second = state
2020 .history()
2021 .finalize_sharded_parent("r", RunStatus::Failed, Utc::now(), Some("late".into()))
2022 .await
2023 .unwrap();
2024 assert!(!second, "second finalize is a no-op");
2025 let r = state.history().get("r").await.unwrap().unwrap();
2026 assert_eq!(r.status, RunStatus::Completed, "status not overwritten");
2027 assert!(r.error.is_none(), "late error must not be stamped");
2028
2029 let missing = state
2031 .history()
2032 .finalize_sharded_parent("does-not-exist", RunStatus::Completed, Utc::now(), None)
2033 .await
2034 .unwrap();
2035 assert!(!missing);
2036 }
2037
2038 #[tokio::test]
2039 async fn execute_shard_runs_a_csv_to_jsonl_shard() {
2040 let dir = tempfile::tempdir().unwrap();
2041 let state = sqlite_state(dir.path()).await;
2042 let input = dir.path().join("in.csv");
2043 std::fs::write(&input, "id,name\n1,alice\n2,bob\n").unwrap();
2044 let output = dir.path().join("out.jsonl");
2045 let yaml = format!(
2046 "version: 1\npipeline:\n \
2047 source: {{ type: csv, config: {{ path: \"{}\" }} }}\n \
2048 sink: {{ type: jsonl, config: {{ path: \"{}\" }} }}\n",
2049 input.display(),
2050 output.display()
2051 );
2052 let l = loaded(&yaml).await;
2053 let ok = execute_shard(
2056 &state,
2057 l,
2058 "r",
2059 "0",
2060 ShardSpec::whole(),
2061 CancellationToken::new(),
2062 None,
2063 None,
2064 Utc::now(),
2065 )
2066 .await;
2067 assert!(ok, "csv→jsonl shard should complete");
2068 let written = std::fs::read_to_string(&output).unwrap();
2069 assert_eq!(written.lines().count(), 2, "both rows written");
2070 assert!(written.contains("alice") && written.contains("bob"));
2071 }
2072
2073 #[tokio::test]
2074 async fn resume_claimed_shard_executes_and_finalizes_parent() {
2075 let dir = tempfile::tempdir().unwrap();
2079 let state = sqlite_state(dir.path()).await;
2080 let input = dir.path().join("in.csv");
2081 std::fs::write(&input, "id,name\n1,alice\n").unwrap();
2082 let output = dir.path().join("out.jsonl");
2083 let yaml = format!(
2084 "version: 1\npipeline:\n \
2085 source: {{ type: csv, config: {{ path: \"{}\" }} }}\n \
2086 sink: {{ type: jsonl, config: {{ path: \"{}\" }} }}\n",
2087 input.display(),
2088 output.display()
2089 );
2090 let mut rec = RunRecord::queued("r".into(), None, BTreeMap::new(), None, Utc::now());
2092 rec.status = RunStatus::Sharded;
2093 rec.config_body = Some(yaml);
2094 state.history().upsert(&rec).await.unwrap();
2095 use crate::serve::history::ShardInsert;
2096 state
2097 .history()
2098 .insert_shards(
2099 "r",
2100 &[ShardInsert {
2101 shard_id: "0".into(),
2102 descriptor: serde_json::Value::Null,
2103 size_estimate: None,
2104 }],
2105 )
2106 .await
2107 .unwrap();
2108 let claimed = state.history().claim_shards(1).await.unwrap();
2109 assert_eq!(claimed.len(), 1);
2110
2111 resume_claimed_shard(state.clone(), claimed.into_iter().next().unwrap());
2112
2113 let mut status = RunStatus::Sharded;
2116 for _ in 0..100 {
2117 tokio::time::sleep(Duration::from_millis(50)).await;
2118 status = state.history().get("r").await.unwrap().unwrap().status;
2119 if status.is_terminal() {
2120 break;
2121 }
2122 }
2123 assert_eq!(status, RunStatus::Completed, "shard ran → parent completed");
2124 assert!(output.exists(), "shard wrote its output");
2125 }
2126
2127 #[tokio::test]
2128 async fn resume_claimed_shard_with_no_config_fails_the_shard() {
2129 let dir = tempfile::tempdir().unwrap();
2132 let state = sqlite_state(dir.path()).await;
2133 let mut rec = RunRecord::queued("r".into(), None, BTreeMap::new(), None, Utc::now());
2134 rec.status = RunStatus::Sharded; state.history().upsert(&rec).await.unwrap();
2136 use crate::serve::history::ShardInsert;
2137 state
2138 .history()
2139 .insert_shards(
2140 "r",
2141 &[ShardInsert {
2142 shard_id: "0".into(),
2143 descriptor: serde_json::Value::Null,
2144 size_estimate: None,
2145 }],
2146 )
2147 .await
2148 .unwrap();
2149 let claimed = state.history().claim_shards(1).await.unwrap();
2150 resume_claimed_shard(state.clone(), claimed.into_iter().next().unwrap());
2151
2152 let mut status = RunStatus::Sharded;
2153 for _ in 0..100 {
2154 tokio::time::sleep(Duration::from_millis(50)).await;
2155 status = state.history().get("r").await.unwrap().unwrap().status;
2156 if status.is_terminal() {
2157 break;
2158 }
2159 }
2160 assert_eq!(status, RunStatus::Failed, "no-config shard → parent failed");
2161 }
2162
2163 #[tokio::test]
2164 async fn coordinate_returns_err_when_source_build_fails() {
2165 let dir = tempfile::tempdir().unwrap();
2168 let state = sqlite_state(dir.path()).await;
2169 let l = loaded(
2171 "version: 1\npipeline:\n \
2172 source: { type: s3, config: { bucket: b } }\n \
2173 sink: { type: stdout, config: {} }\n",
2174 )
2175 .await;
2176 assert!(coordinate_sharded_run(&state, "r", &l, 4).await.is_err());
2177 }
2178
2179 #[tokio::test]
2180 async fn execute_shard_returns_false_on_malformed_resilience() {
2181 let dir = tempfile::tempdir().unwrap();
2184 let state = sqlite_state(dir.path()).await;
2185 let input = dir.path().join("in.csv");
2186 std::fs::write(&input, "id\n1\n").unwrap();
2187 let yaml = format!(
2188 "version: 1\nresilience:\n retry:\n max_attempts: 0\npipeline:\n \
2189 source: {{ type: csv, config: {{ path: \"{}\" }} }}\n \
2190 sink: {{ type: stdout, config: {{}} }}\n",
2191 input.display()
2192 );
2193 let l = loaded(&yaml).await;
2194 let ok = execute_shard(
2195 &state,
2196 l,
2197 "r",
2198 "0",
2199 ShardSpec::whole(),
2200 CancellationToken::new(),
2201 None,
2202 None,
2203 Utc::now(),
2204 )
2205 .await;
2206 assert!(!ok, "malformed resilience → shard fails fast");
2207 }
2208
2209 #[tokio::test]
2210 async fn resume_claimed_shard_with_unloadable_config_fails_the_shard() {
2211 let dir = tempfile::tempdir().unwrap();
2214 let state = sqlite_state(dir.path()).await;
2215 let mut rec = RunRecord::queued("r".into(), None, BTreeMap::new(), None, Utc::now());
2216 rec.status = RunStatus::Sharded;
2217 rec.config_body = Some("this: is: not: valid: yaml: [".into());
2218 state.history().upsert(&rec).await.unwrap();
2219 use crate::serve::history::ShardInsert;
2220 state
2221 .history()
2222 .insert_shards(
2223 "r",
2224 &[ShardInsert {
2225 shard_id: "0".into(),
2226 descriptor: serde_json::Value::Null,
2227 size_estimate: None,
2228 }],
2229 )
2230 .await
2231 .unwrap();
2232 let claimed = state.history().claim_shards(1).await.unwrap();
2233 resume_claimed_shard(state.clone(), claimed.into_iter().next().unwrap());
2234
2235 let mut status = RunStatus::Sharded;
2236 for _ in 0..100 {
2237 tokio::time::sleep(Duration::from_millis(50)).await;
2238 status = state.history().get("r").await.unwrap().unwrap().status;
2239 if status.is_terminal() {
2240 break;
2241 }
2242 }
2243 assert_eq!(
2244 status,
2245 RunStatus::Failed,
2246 "unloadable config → parent failed"
2247 );
2248 }
2249
2250 #[tokio::test]
2253 async fn request_cancel_flags_a_sharded_parent() {
2254 let dir = tempfile::tempdir().unwrap();
2257 let state = sqlite_state(dir.path()).await;
2258 seed_run(&state, "r", RunStatus::Sharded).await;
2259 state.history().request_cancel("r").await.unwrap();
2262 use crate::serve::history::ShardInsert;
2264 state
2265 .history()
2266 .insert_shards(
2267 "r",
2268 &[ShardInsert {
2269 shard_id: "0".into(),
2270 descriptor: serde_json::Value::Null,
2271 size_estimate: None,
2272 }],
2273 )
2274 .await
2275 .unwrap();
2276 let claimed = state.history().claim_shards(1).await.unwrap();
2277 assert_eq!(claimed.len(), 1, "shard claimed (running, owned)");
2278
2279 let flagged = state.history().pending_shard_cancellations().await.unwrap();
2280 assert_eq!(
2281 flagged,
2282 vec!["r".to_string()],
2283 "the flagged sharded parent's run id is returned for its running shard"
2284 );
2285 }
2286
2287 #[tokio::test]
2288 async fn pending_shard_cancellations_filters_unflagged_and_pending_shards() {
2289 let dir = tempfile::tempdir().unwrap();
2290 let state = sqlite_state(dir.path()).await;
2291 use crate::serve::history::ShardInsert;
2292 let one = |id: &str| {
2293 vec![ShardInsert {
2294 shard_id: id.into(),
2295 descriptor: serde_json::Value::Null,
2296 size_estimate: None,
2297 }]
2298 };
2299
2300 seed_run(&state, "A", RunStatus::Sharded).await;
2303 state.history().request_cancel("A").await.unwrap();
2304 state.history().insert_shards("A", &one("0")).await.unwrap();
2305 seed_run(&state, "C", RunStatus::Sharded).await;
2306 state.history().insert_shards("C", &one("0")).await.unwrap();
2307 let claimed = state.history().claim_shards(8).await.unwrap();
2308 assert_eq!(claimed.len(), 2, "A and C shards claimed (running)");
2309
2310 seed_run(&state, "B", RunStatus::Sharded).await;
2314 state.history().request_cancel("B").await.unwrap();
2315 state.history().insert_shards("B", &one("0")).await.unwrap();
2316
2317 let flagged = state.history().pending_shard_cancellations().await.unwrap();
2318 assert_eq!(
2319 flagged,
2320 vec!["A".to_string()],
2321 "only A (flagged + a running owned shard); B pending-shard, C unflagged"
2322 );
2323 }
2324
2325 #[tokio::test]
2328 async fn finalize_sweep_completes_an_all_success_sharded_parent() {
2329 let dir = tempfile::tempdir().unwrap();
2330 let state = sqlite_state(dir.path()).await;
2331 seed_sharded_with_shards(&state, "r", 3).await;
2332 for i in 0..3 {
2333 state
2334 .history()
2335 .finalize_shard("r", &i.to_string(), true)
2336 .await
2337 .unwrap();
2338 }
2339 let n = state
2341 .history()
2342 .finalize_completed_sharded_parents()
2343 .await
2344 .unwrap();
2345 assert_eq!(n, 1, "one sharded parent finalized");
2346 let rec = state.history().get("r").await.unwrap().unwrap();
2347 assert_eq!(rec.status, RunStatus::Completed);
2348 assert!(rec.finished_at.is_some());
2349 assert!(rec.error.is_none());
2350
2351 assert_eq!(
2353 state
2354 .history()
2355 .finalize_completed_sharded_parents()
2356 .await
2357 .unwrap(),
2358 0,
2359 "already-terminal parent is not re-finalized"
2360 );
2361 }
2362
2363 #[tokio::test]
2364 async fn finalize_sweep_fails_a_parent_with_a_failed_shard() {
2365 let dir = tempfile::tempdir().unwrap();
2366 let state = sqlite_state(dir.path()).await;
2367 seed_sharded_with_shards(&state, "r", 3).await;
2368 state
2369 .history()
2370 .finalize_shard("r", "0", true)
2371 .await
2372 .unwrap();
2373 state
2374 .history()
2375 .finalize_shard("r", "1", false)
2376 .await
2377 .unwrap();
2378 state
2379 .history()
2380 .finalize_shard("r", "2", true)
2381 .await
2382 .unwrap();
2383 let n = state
2384 .history()
2385 .finalize_completed_sharded_parents()
2386 .await
2387 .unwrap();
2388 assert_eq!(n, 1);
2389 let rec = state.history().get("r").await.unwrap().unwrap();
2390 assert_eq!(rec.status, RunStatus::Failed);
2391 assert!(rec.finished_at.is_some());
2392 assert_eq!(rec.error.as_deref(), Some("1/3 shard(s) failed"));
2393 }
2394
2395 #[tokio::test]
2396 async fn finalize_sweep_leaves_a_not_all_terminal_parent_sharded() {
2397 let dir = tempfile::tempdir().unwrap();
2398 let state = sqlite_state(dir.path()).await;
2399 seed_sharded_with_shards(&state, "r", 2).await;
2400 state
2402 .history()
2403 .finalize_shard("r", "0", true)
2404 .await
2405 .unwrap();
2406 let n = state
2407 .history()
2408 .finalize_completed_sharded_parents()
2409 .await
2410 .unwrap();
2411 assert_eq!(n, 0, "parent with a still-running shard is not finalized");
2412 assert_eq!(
2413 state.history().get("r").await.unwrap().unwrap().status,
2414 RunStatus::Sharded
2415 );
2416 }
2417 }
2418}