1use crate::auth_catalog::{AuthCatalog, build_auth_catalog};
7use crate::cli::ScheduleArgs;
8use crate::config::PipelineConfig;
9use crate::error::{CliError, CliResult};
10use crate::executor::{ExecuteOptions, RunSummary, run_expanded};
11use crate::expand::{ExpandedNode, expand};
12use crate::schedule::compiled::CompiledSchedule;
13use crate::schedule::metrics as m;
14use crate::schedule::state::{AfterRun, RunOutcome, SchedulerState, TickAction};
15use chrono::{DateTime, Utc};
16use std::time::Duration;
17use tokio::task::JoinHandle;
18use tokio::time::Instant;
19use tracing::Instrument;
20
21struct RunningRun {
23 handle: JoinHandle<CliResult<RunSummary>>,
24 started: Instant,
25}
26
27struct RunFinished {
29 outcome: RunOutcome,
30 duration: Duration,
31 detail: Option<String>,
32 cooldown: Option<Duration>,
34}
35
36struct Shutdown {
38 #[cfg(unix)]
39 sigterm: tokio::signal::unix::Signal,
40}
41
42impl Shutdown {
43 fn new() -> CliResult<Self> {
44 #[cfg(unix)]
45 {
46 use tokio::signal::unix::{SignalKind, signal};
47 let sigterm = signal(SignalKind::terminate()).map_err(|e| {
48 CliError::Internal(format!("failed to install SIGTERM handler: {e}"))
49 })?;
50 Ok(Self { sigterm })
51 }
52 #[cfg(not(unix))]
53 {
54 Ok(Self {})
55 }
56 }
57
58 async fn recv(&mut self) {
60 #[cfg(unix)]
61 {
62 tokio::select! {
63 _ = tokio::signal::ctrl_c() => {}
64 _ = self.sigterm.recv() => {}
65 }
66 }
67 #[cfg(not(unix))]
68 {
69 let _ = tokio::signal::ctrl_c().await;
70 }
71 }
72}
73
74const MAX_SLEEP: Duration = Duration::from_secs(30);
77
78pub async fn run(args: ScheduleArgs) -> CliResult<()> {
80 let cwd = std::env::current_dir()?;
81 let env_path =
82 crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
83 crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
84 let path = match args.config {
85 Some(p) => p,
86 None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
87 };
88
89 let cfg = PipelineConfig::from_path_async(&path, args.profile.as_deref()).await?;
90 let spec = cfg.schedule.as_ref().ok_or_else(|| {
91 CliError::Config(
92 "no `schedule:` block in config — use `faucet run` for a one-shot run, or add a `schedule:` block"
93 .into(),
94 )
95 })?;
96 let compiled = CompiledSchedule::compile(spec)?;
97 let cron = spec.cron.clone();
98 let timezone = spec.timezone.clone();
99
100 crate::obs::install(&cfg)?;
101
102 let pipeline_name = cfg.name.clone().unwrap_or_else(|| {
103 path.file_stem()
104 .and_then(|s| s.to_str())
105 .unwrap_or("pipeline")
106 .to_owned()
107 });
108
109 let auth = build_auth_catalog(cfg.auth.as_ref())?;
110 #[cfg(feature = "lineage")]
113 let lineage = crate::lineage_glue::build_emitter(cfg.lineage.as_ref())
114 .map_err(|e| CliError::Config(format!("lineage: {e}")))?;
115 #[cfg(feature = "lineage")]
116 let lineage_cfg = cfg.lineage.clone();
117 let nodes = expand(&cfg)?; let execution = cfg.execution.clone();
119 let resilience = match &cfg.resilience {
120 Some(spec) => Some(spec.to_policy()?),
121 None => None,
122 };
123
124 if args.once {
125 return run_once(
126 &nodes,
127 &auth,
128 &execution,
129 &compiled,
130 &pipeline_name,
131 &resilience,
132 #[cfg(feature = "lineage")]
133 &lineage,
134 #[cfg(feature = "lineage")]
135 &lineage_cfg,
136 )
137 .await;
138 }
139
140 run_loop(
141 compiled,
142 nodes,
143 auth,
144 execution,
145 pipeline_name,
146 cron,
147 timezone,
148 resilience,
149 #[cfg(feature = "lineage")]
150 lineage,
151 #[cfg(feature = "lineage")]
152 lineage_cfg,
153 )
154 .await
155}
156
157#[allow(clippy::too_many_arguments)]
160fn make_opts(
161 pipeline_name: &str,
162 execution: &Option<crate::config::ExecutionSpec>,
163 auth: &AuthCatalog,
164 clock: chrono::DateTime<chrono::FixedOffset>,
165 resilience: &Option<faucet_core::ResiliencePolicy>,
166 #[cfg(feature = "lineage")] lineage: &Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
167 #[cfg(feature = "lineage")] lineage_cfg: &Option<faucet_lineage::LineageConfig>,
168) -> ExecuteOptions {
169 ExecuteOptions {
170 pipeline_name: pipeline_name.to_string(),
171 execution: execution.clone(),
172 dry_run: false,
173 limit: None,
174 state_path_override: None,
175 shard: None,
176 auth: auth.clone(),
177 clock,
178 cancel: None,
179 resilience: resilience.clone(),
180 #[cfg(feature = "lineage")]
181 lineage: lineage.clone(),
182 #[cfg(feature = "lineage")]
183 lineage_cfg: lineage_cfg.clone(),
184 }
185}
186
187fn run_span(run_ordinal: u64, scheduled_for: DateTime<Utc>, tick: DateTime<Utc>) -> tracing::Span {
191 tracing::info_span!(
192 "faucet.schedule.run",
193 run_ordinal,
194 scheduled_for_unix_seconds = scheduled_for.timestamp(),
195 tick_unix_seconds = tick.timestamp(),
196 )
197}
198
199fn spawn_run(
202 nodes: Vec<ExpandedNode>,
203 opts: ExecuteOptions,
204 timeout: Option<Duration>,
205 span: tracing::Span,
206) -> JoinHandle<CliResult<RunSummary>> {
207 tokio::spawn(
208 async move {
209 match timeout {
210 Some(d) => match tokio::time::timeout(d, run_expanded(nodes, opts)).await {
211 Ok(r) => r,
212 Err(_) => Err(CliError::Internal(format!(
213 "scheduled run exceeded run_timeout_secs ({}s) and was aborted",
214 d.as_secs()
215 ))),
216 },
217 None => run_expanded(nodes, opts).await,
218 }
219 }
220 .instrument(span),
221 )
222}
223
224const CIRCUIT_OPEN_PREFIX: &str = "Circuit open after";
230
231fn classify(
235 joined: Result<CliResult<RunSummary>, tokio::task::JoinError>,
236 breaker_cooldown: Option<Duration>,
237) -> RunFinished {
238 let circuit_open = match &joined {
240 Ok(Ok(summary)) => summary
241 .invocations
242 .iter()
243 .filter_map(|i| i.error.as_deref())
244 .any(|e| e.starts_with(CIRCUIT_OPEN_PREFIX)),
245 Ok(Err(e)) => e.to_string().contains(CIRCUIT_OPEN_PREFIX),
246 Err(_) => false,
247 };
248 let cooldown = if circuit_open {
251 let reconstructed: Result<(), faucet_core::FaucetError> =
252 Err(faucet_core::FaucetError::CircuitOpen {
253 failures: 0,
254 cooldown: breaker_cooldown.unwrap_or(Duration::ZERO),
255 });
256 crate::schedule::state::cooldown_delay(&reconstructed).filter(|d| !d.is_zero())
257 } else {
258 None
259 };
260
261 let (outcome, detail) = match joined {
262 Ok(Ok(summary)) if summary.had_failures() => (
263 RunOutcome::Failure,
264 Some(format!("{} invocation(s) failed", summary.failure_count())),
265 ),
266 Ok(Ok(_)) => (RunOutcome::Success, None),
267 Ok(Err(e)) => (RunOutcome::Failure, Some(e.to_string())),
268 Err(je) => (
269 RunOutcome::Failure,
270 Some(format!("run task panicked: {je}")),
271 ),
272 };
273 RunFinished {
274 outcome,
275 duration: Duration::ZERO,
276 detail,
277 cooldown,
278 }
279}
280
281#[allow(clippy::too_many_arguments)]
283async fn run_once(
284 nodes: &[ExpandedNode],
285 auth: &AuthCatalog,
286 execution: &Option<crate::config::ExecutionSpec>,
287 compiled: &CompiledSchedule,
288 pipeline_name: &str,
289 resilience: &Option<faucet_core::ResiliencePolicy>,
290 #[cfg(feature = "lineage")] lineage: &Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
291 #[cfg(feature = "lineage")] lineage_cfg: &Option<faucet_lineage::LineageConfig>,
292) -> CliResult<()> {
293 tracing::info!(pipeline = %pipeline_name, "schedule --once: running one pipeline now");
294 let now = chrono::Utc::now();
295 let opts = make_opts(
296 pipeline_name,
297 execution,
298 auth,
299 compiled.clock_at(now),
300 resilience,
301 #[cfg(feature = "lineage")]
302 lineage,
303 #[cfg(feature = "lineage")]
304 lineage_cfg,
305 );
306 let span = run_span(1, now, now);
307 let fut = run_expanded(nodes.to_vec(), opts).instrument(span);
308 let summary = match compiled.run_timeout {
309 Some(d) => tokio::time::timeout(d, fut).await.map_err(|_| {
310 CliError::Internal(format!(
311 "--once run exceeded run_timeout_secs ({}s)",
312 d.as_secs()
313 ))
314 })??,
315 None => fut.await?,
316 };
317 if summary.had_failures() {
318 return Err(CliError::PipelineHadFailures {
319 count: summary.failure_count(),
320 });
321 }
322 Ok(())
323}
324
325#[allow(clippy::too_many_arguments)]
327async fn run_loop(
328 compiled: CompiledSchedule,
329 nodes: Vec<ExpandedNode>,
330 auth: AuthCatalog,
331 execution: Option<crate::config::ExecutionSpec>,
332 pipeline_name: String,
333 cron: String,
334 timezone: String,
335 resilience: Option<faucet_core::ResiliencePolicy>,
336 #[cfg(feature = "lineage")] lineage: Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
337 #[cfg(feature = "lineage")] lineage_cfg: Option<faucet_lineage::LineageConfig>,
338) -> CliResult<()> {
339 let mut state = SchedulerState::new(&compiled);
340 let breaker_cooldown = resilience
343 .as_ref()
344 .and_then(|r| r.circuit_breaker)
345 .map(|cb| cb.cooldown);
346 let mut shutdown = Shutdown::new()?;
347 let mut running: Option<RunningRun> = None;
348 let mut pending_scheduled_for: Option<DateTime<Utc>> = None;
349 let mut run_ordinal: u64 = 0;
350
351 let mut next_due = if compiled.start_immediately {
352 Utc::now()
353 } else {
354 compiled
355 .next_after(Utc::now())
356 .ok_or_else(|| CliError::Config("schedule: no upcoming occurrence".into()))?
357 };
358
359 let upcoming: Vec<String> = {
362 let mut t = Utc::now();
363 let mut v = Vec::with_capacity(3);
364 while v.len() < 3 {
365 match compiled.next_after(t) {
366 Some(n) => {
367 v.push(n.to_rfc3339());
368 t = n;
369 }
370 None => break,
371 }
372 }
373 v
374 };
375 tracing::info!(
376 pipeline = %pipeline_name,
377 cron = %cron,
378 timezone = %timezone,
379 next_occurrences = ?upcoming,
380 "scheduler started (Ctrl-C / SIGTERM to stop)"
381 );
382
383 m::describe();
389 m::in_flight(&pipeline_name, 0);
390 m::consecutive_failures(&pipeline_name, 0);
391
392 loop {
393 let now = Utc::now();
394
395 if now >= next_due {
396 match state.on_tick(running.is_some()) {
397 TickAction::Dispatch => {
398 run_ordinal += 1;
399 let opts = make_opts(
400 &pipeline_name,
401 &execution,
402 &auth,
403 compiled.clock_at(next_due),
404 &resilience,
405 #[cfg(feature = "lineage")]
406 &lineage,
407 #[cfg(feature = "lineage")]
408 &lineage_cfg,
409 );
410 let span = run_span(run_ordinal, next_due, now);
411 let handle = spawn_run(nodes.clone(), opts, compiled.run_timeout, span);
412 m::in_flight(&pipeline_name, 1);
413 m::last_run_started(&pipeline_name, now);
414 m::lateness(&pipeline_name, now - next_due);
415 tracing::info!(pipeline = %pipeline_name, run_ordinal, scheduled_for = %next_due, "run started");
416 running = Some(RunningRun {
417 handle,
418 started: Instant::now(),
419 });
420 }
421 TickAction::Skip => {
422 m::overlap(&pipeline_name, "skip");
423 m::run_outcome(&pipeline_name, "skipped");
424 tracing::warn!(pipeline = %pipeline_name, scheduled_for = %next_due, "tick skipped — previous run still in progress");
425 }
426 TickAction::Queue => {
427 m::overlap(&pipeline_name, "queue");
428 if pending_scheduled_for.is_none() {
429 pending_scheduled_for = Some(next_due);
430 }
431 tracing::warn!(pipeline = %pipeline_name, scheduled_for = %next_due, "tick queued — will run after current run finishes");
432 }
433 TickAction::ForbidAbort => {
434 m::overlap(&pipeline_name, "forbid");
435 m::in_flight(&pipeline_name, 0);
439 return Err(CliError::ScheduleOverlapForbidden);
440 }
441 }
442 next_due = match compiled.next_due_after_tick(next_due, Utc::now()) {
447 Some(t) => t,
448 None => {
449 tracing::info!(pipeline = %pipeline_name, "no further scheduled occurrences; exiting");
450 return Ok(());
451 }
452 };
453 }
454
455 let now2 = Utc::now();
456 m::heartbeat(&pipeline_name, now2);
457 m::next_tick(&pipeline_name, next_due);
458 let chunk = (next_due - now2)
459 .to_std()
460 .unwrap_or(Duration::ZERO)
461 .min(MAX_SLEEP);
462
463 tokio::select! {
464 biased;
465
466 _ = shutdown.recv() => {
467 tracing::info!(pipeline = %pipeline_name, "shutdown signal received; draining in-flight run");
468 graceful_shutdown(running.take(), compiled.shutdown_grace, &pipeline_name).await;
469 faucet_core::shutdown_otel();
472 return Ok(());
473 }
474
475 finished = wait_for_run(&mut running, breaker_cooldown) => {
476 let mut finished = finished;
477 if let Some(rr) = running.take() {
478 finished.duration = rr.started.elapsed();
479 }
480 m::in_flight(&pipeline_name, 0);
481 let done_at = Utc::now();
482
483 if let Some(d) = finished.cooldown
491 && let Ok(delta) = chrono::Duration::from_std(d)
492 {
493 let resume = done_at + delta;
494 if resume > next_due {
495 next_due = resume;
496 }
497 tracing::warn!(
498 pipeline = %pipeline_name,
499 cooldown_secs = d.as_secs(),
500 next_due = %next_due,
501 "circuit breaker opened; delaying re-entry by cooldown"
502 );
503 }
504 m::last_run_completed(&pipeline_name, done_at);
505 m::last_run_duration(&pipeline_name, finished.duration);
506 m::run_outcome(&pipeline_name, match finished.outcome {
507 RunOutcome::Success => "ok",
508 RunOutcome::Failure => "err",
509 });
510 match finished.outcome {
511 RunOutcome::Success => tracing::info!(
512 pipeline = %pipeline_name, secs = finished.duration.as_secs_f64(), "run completed"
513 ),
514 RunOutcome::Failure => tracing::error!(
515 pipeline = %pipeline_name, detail = finished.detail.as_deref().unwrap_or("unknown"),
516 "run failed"
517 ),
518 }
519
520 let after = state.on_run_finished(finished.outcome);
521 m::consecutive_failures(&pipeline_name, state.consecutive_failures());
522 match after {
523 AfterRun::ExitOk => {
524 tracing::info!(pipeline = %pipeline_name, "max_runs reached; exiting");
525 return Ok(());
526 }
527 AfterRun::ExitFailure { consecutive } => {
528 return Err(CliError::PipelineHadFailures { count: consecutive as usize });
529 }
530 AfterRun::Continue { dispatch_pending } => {
531 if dispatch_pending {
532 run_ordinal += 1;
533 let sched_for = pending_scheduled_for.take().unwrap_or(done_at);
534 let opts = make_opts(
535 &pipeline_name,
536 &execution,
537 &auth,
538 compiled.clock_at(sched_for),
539 &resilience,
540 #[cfg(feature = "lineage")]
541 &lineage,
542 #[cfg(feature = "lineage")]
543 &lineage_cfg,
544 );
545 let span = run_span(run_ordinal, sched_for, done_at);
546 let handle = spawn_run(nodes.clone(), opts, compiled.run_timeout, span);
547 m::in_flight(&pipeline_name, 1);
548 m::last_run_started(&pipeline_name, done_at);
549 m::lateness(&pipeline_name, done_at - sched_for);
550 tracing::info!(pipeline = %pipeline_name, run_ordinal, scheduled_for = %sched_for, "queued run started");
551 running = Some(RunningRun { handle, started: Instant::now() });
552 }
553 }
554 }
555 }
556
557 _ = tokio::time::sleep(chunk) => { }
558 }
559 }
560}
561
562async fn wait_for_run(
565 running: &mut Option<RunningRun>,
566 breaker_cooldown: Option<Duration>,
567) -> RunFinished {
568 match running {
569 Some(rr) => classify((&mut rr.handle).await, breaker_cooldown),
570 None => std::future::pending().await,
571 }
572}
573
574async fn graceful_shutdown(running: Option<RunningRun>, grace: Duration, pipeline_name: &str) {
576 if let Some(mut rr) = running {
577 match tokio::time::timeout(grace, &mut rr.handle).await {
578 Ok(_) => {
579 tracing::info!(pipeline = %pipeline_name, "in-flight run finished during shutdown grace")
580 }
581 Err(_) => {
582 rr.handle.abort();
583 tracing::warn!(
584 pipeline = %pipeline_name,
585 grace_secs = grace.as_secs(),
586 "in-flight run exceeded shutdown grace; aborted (partial sink state possible; bookmark preserved for the next run)"
587 );
588 }
589 }
590 m::in_flight(pipeline_name, 0);
591 }
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597 use crate::schedule::spec::ScheduleSpec;
598
599 fn compiled(yaml: &str) -> CompiledSchedule {
600 let spec: ScheduleSpec = serde_yaml::from_str(yaml).unwrap();
601 CompiledSchedule::compile(&spec).unwrap()
602 }
603
604 fn summary(failures: usize, total: usize) -> RunSummary {
605 let mut invocations = Vec::new();
606 for i in 0..total {
607 invocations.push(crate::executor::InvocationOutcome {
608 row_id: format!("r{i}"),
609 parent_record_key: None,
610 records_written: if i < failures { 0 } else { 3 },
611 error: if i < failures {
612 Some("boom".into())
613 } else {
614 None
615 },
616 });
617 }
618 RunSummary { invocations }
619 }
620
621 #[test]
622 fn classify_success_when_no_failures() {
623 let joined = Ok(Ok(summary(0, 2)));
624 let f = classify(joined, None);
625 assert_eq!(f.outcome, RunOutcome::Success);
626 assert!(f.detail.is_none());
627 assert!(f.cooldown.is_none());
628 }
629
630 #[test]
631 fn classify_failure_when_some_invocations_failed() {
632 let joined = Ok(Ok(summary(2, 5)));
633 let f = classify(joined, None);
634 assert_eq!(f.outcome, RunOutcome::Failure);
635 assert_eq!(f.detail.as_deref(), Some("2 invocation(s) failed"));
636 assert!(f.cooldown.is_none());
637 }
638
639 #[test]
640 fn classify_failure_when_run_errored() {
641 let joined: Result<CliResult<RunSummary>, tokio::task::JoinError> =
642 Ok(Err(CliError::Internal("disk full".into())));
643 let f = classify(joined, None);
644 assert_eq!(f.outcome, RunOutcome::Failure);
645 assert!(f.detail.as_deref().unwrap().contains("disk full"));
646 }
647
648 #[tokio::test]
649 async fn classify_failure_when_task_panicked() {
650 let handle = tokio::spawn(async { panic!("kaboom") });
652 let joined: Result<CliResult<RunSummary>, tokio::task::JoinError> = handle.await.map(Ok);
653 let f = classify(joined, None);
654 assert_eq!(f.outcome, RunOutcome::Failure);
655 assert!(
656 f.detail.as_deref().unwrap().contains("panicked"),
657 "{:?}",
658 f.detail
659 );
660 }
661
662 #[test]
663 fn classify_recovers_cooldown_from_circuit_open_invocation() {
664 let circuit_open_msg = faucet_core::FaucetError::CircuitOpen {
667 failures: 3,
668 cooldown: Duration::from_secs(60),
669 }
670 .to_string();
671 let invocations = vec![crate::executor::InvocationOutcome {
672 row_id: "r0".into(),
673 parent_record_key: None,
674 records_written: 0,
675 error: Some(circuit_open_msg),
676 }];
677 let joined = Ok(Ok(RunSummary { invocations }));
678 let f = classify(joined, Some(Duration::from_secs(45)));
679 assert_eq!(f.outcome, RunOutcome::Failure);
680 assert_eq!(f.cooldown, Some(Duration::from_secs(45)));
681 }
682
683 #[test]
684 fn classify_circuit_open_without_configured_cooldown_yields_none() {
685 let circuit_open_msg = faucet_core::FaucetError::CircuitOpen {
688 failures: 1,
689 cooldown: Duration::from_secs(10),
690 }
691 .to_string();
692 let invocations = vec![crate::executor::InvocationOutcome {
693 row_id: "r0".into(),
694 parent_record_key: None,
695 records_written: 0,
696 error: Some(circuit_open_msg),
697 }];
698 let joined = Ok(Ok(RunSummary { invocations }));
699 let f = classify(joined, None);
700 assert_eq!(f.outcome, RunOutcome::Failure);
701 assert!(f.cooldown.is_none());
702 }
703
704 #[test]
705 fn classify_no_cooldown_for_ordinary_failure() {
706 let joined = Ok(Ok(summary(1, 2)));
709 let f = classify(joined, Some(Duration::from_secs(30)));
710 assert_eq!(f.outcome, RunOutcome::Failure);
711 assert!(f.cooldown.is_none());
712 }
713
714 #[test]
715 fn run_span_carries_ordinal_and_times() {
716 let scheduled = Utc::now();
717 let tick = scheduled + chrono::Duration::seconds(3);
718 let span = run_span(7, scheduled, tick);
719 assert_eq!(span.metadata().unwrap().name(), "faucet.schedule.run");
722 }
723
724 #[tokio::test]
725 async fn wait_for_run_returns_classified_outcome() {
726 let handle = tokio::spawn(async { Ok(summary(0, 1)) });
727 let mut running = Some(RunningRun {
728 handle,
729 started: Instant::now(),
730 });
731 let finished = wait_for_run(&mut running, None).await;
732 assert_eq!(finished.outcome, RunOutcome::Success);
733 }
734
735 #[tokio::test]
736 async fn spawn_run_times_out_into_internal_error() {
737 let dir = tempfile::tempdir().unwrap();
740 let input = dir.path().join("in.csv");
741 let output = dir.path().join("out.jsonl");
742 std::fs::write(&input, "name\nx\n").unwrap();
743 let yaml = format!(
745 "version: 1\npipeline:\n source: {{ type: csv, config: {{ path: {input} }} }}\n sink: {{ type: jsonl, config: {{ path: {output} }} }}\n",
746 input = input.display(),
747 output = output.display(),
748 );
749 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
750 let nodes = expand(&cfg).unwrap();
751 let auth = AuthCatalog::new();
752 let opts = make_opts(
753 "to",
754 &None,
755 &auth,
756 Utc::now().fixed_offset(),
757 &None,
758 #[cfg(feature = "lineage")]
759 &None,
760 #[cfg(feature = "lineage")]
761 &None,
762 );
763 let handle = spawn_run(
766 nodes,
767 opts,
768 Some(Duration::from_nanos(1)),
769 run_span(1, Utc::now(), Utc::now()),
770 );
771 let joined = handle.await.unwrap();
772 if let Err(CliError::Internal(msg)) = &joined {
776 assert!(msg.contains("run_timeout_secs"), "{msg}");
777 }
778 }
779
780 #[tokio::test]
781 async fn make_opts_disables_dry_run_limit_and_state_override() {
782 let auth = AuthCatalog::new();
783 let clock = Utc::now().fixed_offset();
784 let opts = make_opts(
785 "p",
786 &None,
787 &auth,
788 clock,
789 &None,
790 #[cfg(feature = "lineage")]
791 &None,
792 #[cfg(feature = "lineage")]
793 &None,
794 );
795 assert_eq!(opts.pipeline_name, "p");
796 assert!(!opts.dry_run);
797 assert!(opts.limit.is_none());
798 assert!(opts.state_path_override.is_none());
799 assert!(opts.cancel.is_none());
800 assert_eq!(opts.clock, clock);
801 }
802
803 #[tokio::test]
804 async fn graceful_shutdown_awaits_finished_run() {
805 let c = compiled("cron: \"* * * * *\"\nshutdown_grace_secs: 5");
807 let handle = tokio::spawn(async { Ok(summary(0, 1)) });
808 let running = Some(RunningRun {
809 handle,
810 started: Instant::now(),
811 });
812 graceful_shutdown(running, c.shutdown_grace, "p").await;
814 }
815
816 #[tokio::test]
817 async fn graceful_shutdown_aborts_run_exceeding_grace() {
818 let handle = tokio::spawn(async {
820 tokio::time::sleep(Duration::from_secs(3600)).await;
821 Ok(summary(0, 1))
822 });
823 let running = Some(RunningRun {
824 handle,
825 started: Instant::now(),
826 });
827 graceful_shutdown(running, Duration::from_millis(50), "p").await;
829 }
830
831 #[tokio::test]
832 async fn graceful_shutdown_noop_when_idle() {
833 graceful_shutdown(None, Duration::from_secs(1), "p").await;
835 }
836}