Skip to main content

faucet_cli/commands/
schedule.rs

1//! `faucet schedule` — run a pipeline on a cron schedule in one long-running
2//! process. Reuses `expand` + `executor::run_expanded` per tick; keeps no state
3//! of its own (resumability rides the pipeline's per-page bookmark). See
4//! `docs/superpowers/specs/2026-05-30-faucet-schedule-design.md`.
5
6use 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
21/// An in-flight run.
22struct RunningRun {
23    handle: JoinHandle<CliResult<RunSummary>>,
24    started: Instant,
25}
26
27/// The data the loop needs after a run finishes.
28struct RunFinished {
29    outcome: RunOutcome,
30    duration: Duration,
31    detail: Option<String>,
32}
33
34/// Cross-platform shutdown-signal source, registered once.
35struct Shutdown {
36    #[cfg(unix)]
37    sigterm: tokio::signal::unix::Signal,
38}
39
40impl Shutdown {
41    fn new() -> CliResult<Self> {
42        #[cfg(unix)]
43        {
44            use tokio::signal::unix::{SignalKind, signal};
45            let sigterm = signal(SignalKind::terminate()).map_err(|e| {
46                CliError::Internal(format!("failed to install SIGTERM handler: {e}"))
47            })?;
48            Ok(Self { sigterm })
49        }
50        #[cfg(not(unix))]
51        {
52            Ok(Self {})
53        }
54    }
55
56    /// Resolve when SIGTERM (Unix) or Ctrl-C (any platform) is received.
57    async fn recv(&mut self) {
58        #[cfg(unix)]
59        {
60            tokio::select! {
61                _ = tokio::signal::ctrl_c() => {}
62                _ = self.sigterm.recv() => {}
63            }
64        }
65        #[cfg(not(unix))]
66        {
67            let _ = tokio::signal::ctrl_c().await;
68        }
69    }
70}
71
72/// Max time to sleep before re-reading the wall clock. Caps clock-step / DST
73/// drift to one chunk and keeps the heartbeat fresh.
74const MAX_SLEEP: Duration = Duration::from_secs(30);
75
76/// Execute the `schedule` subcommand.
77pub async fn run(args: ScheduleArgs) -> CliResult<()> {
78    let cwd = std::env::current_dir()?;
79    let env_path =
80        crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
81    crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
82    let path = match args.config {
83        Some(p) => p,
84        None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
85    };
86
87    let cfg = PipelineConfig::from_path_async(&path, args.profile.as_deref()).await?;
88    let spec = cfg.schedule.as_ref().ok_or_else(|| {
89        CliError::Config(
90            "no `schedule:` block in config — use `faucet run` for a one-shot run, or add a `schedule:` block"
91                .into(),
92        )
93    })?;
94    let compiled = CompiledSchedule::compile(spec)?;
95    let cron = spec.cron.clone();
96    let timezone = spec.timezone.clone();
97
98    crate::obs::install(&cfg)?;
99
100    let pipeline_name = cfg.name.clone().unwrap_or_else(|| {
101        path.file_stem()
102            .and_then(|s| s.to_str())
103            .unwrap_or("pipeline")
104            .to_owned()
105    });
106
107    let auth = build_auth_catalog(cfg.auth.as_ref())?;
108    // Build the shared OpenLineage emitter once; the `Arc` is cloned into each
109    // tick's `ExecuteOptions` so every run reuses the same transport/client.
110    #[cfg(feature = "lineage")]
111    let lineage = crate::lineage_glue::build_emitter(cfg.lineage.as_ref())
112        .map_err(|e| CliError::Config(format!("lineage: {e}")))?;
113    #[cfg(feature = "lineage")]
114    let lineage_cfg = cfg.lineage.clone();
115    let nodes = expand(&cfg)?; // validate once; cloned per tick
116    let execution = cfg.execution.clone();
117
118    if args.once {
119        return run_once(
120            &nodes,
121            &auth,
122            &execution,
123            &compiled,
124            &pipeline_name,
125            #[cfg(feature = "lineage")]
126            &lineage,
127            #[cfg(feature = "lineage")]
128            &lineage_cfg,
129        )
130        .await;
131    }
132
133    run_loop(
134        compiled,
135        nodes,
136        auth,
137        execution,
138        pipeline_name,
139        cron,
140        timezone,
141        #[cfg(feature = "lineage")]
142        lineage,
143        #[cfg(feature = "lineage")]
144        lineage_cfg,
145    )
146    .await
147}
148
149/// Build a fresh `ExecuteOptions` for one tick (connectors are rebuilt per run;
150/// the auth catalog is shared so cached tokens survive across ticks).
151fn make_opts(
152    pipeline_name: &str,
153    execution: &Option<crate::config::ExecutionSpec>,
154    auth: &AuthCatalog,
155    clock: chrono::DateTime<chrono::FixedOffset>,
156    #[cfg(feature = "lineage")] lineage: &Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
157    #[cfg(feature = "lineage")] lineage_cfg: &Option<faucet_lineage::LineageConfig>,
158) -> ExecuteOptions {
159    ExecuteOptions {
160        pipeline_name: pipeline_name.to_string(),
161        execution: execution.clone(),
162        dry_run: false,
163        limit: None,
164        state_path_override: None,
165        auth: auth.clone(),
166        clock,
167        cancel: None,
168        #[cfg(feature = "lineage")]
169        lineage: lineage.clone(),
170        #[cfg(feature = "lineage")]
171        lineage_cfg: lineage_cfg.clone(),
172    }
173}
174
175/// The per-run tracing span. Wraps the inner pipeline spans so a scheduled run
176/// is correlatable in distributed tracing. `scheduled_for` is the cron-intended
177/// instant; `tick` is when the run actually started.
178fn run_span(run_ordinal: u64, scheduled_for: DateTime<Utc>, tick: DateTime<Utc>) -> tracing::Span {
179    tracing::info_span!(
180        "faucet.schedule.run",
181        run_ordinal,
182        scheduled_for_unix_seconds = scheduled_for.timestamp(),
183        tick_unix_seconds = tick.timestamp(),
184    )
185}
186
187/// Spawn one pipeline run, wrapping it in the optional run timeout and the
188/// per-run span.
189fn spawn_run(
190    nodes: Vec<ExpandedNode>,
191    opts: ExecuteOptions,
192    timeout: Option<Duration>,
193    span: tracing::Span,
194) -> JoinHandle<CliResult<RunSummary>> {
195    tokio::spawn(
196        async move {
197            match timeout {
198                Some(d) => match tokio::time::timeout(d, run_expanded(nodes, opts)).await {
199                    Ok(r) => r,
200                    Err(_) => Err(CliError::Internal(format!(
201                        "scheduled run exceeded run_timeout_secs ({}s) and was aborted",
202                        d.as_secs()
203                    ))),
204                },
205                None => run_expanded(nodes, opts).await,
206            }
207        }
208        .instrument(span),
209    )
210}
211
212/// Classify a joined run task into a scheduler outcome + a log detail.
213fn classify(joined: Result<CliResult<RunSummary>, tokio::task::JoinError>) -> RunFinished {
214    let (outcome, detail) = match joined {
215        Ok(Ok(summary)) if summary.had_failures() => (
216            RunOutcome::Failure,
217            Some(format!("{} invocation(s) failed", summary.failure_count())),
218        ),
219        Ok(Ok(_)) => (RunOutcome::Success, None),
220        Ok(Err(e)) => (RunOutcome::Failure, Some(e.to_string())),
221        Err(je) => (
222            RunOutcome::Failure,
223            Some(format!("run task panicked: {je}")),
224        ),
225    };
226    RunFinished {
227        outcome,
228        duration: Duration::ZERO,
229        detail,
230    }
231}
232
233/// `--once`: run exactly one pipeline run now and map its result to an exit.
234#[allow(clippy::too_many_arguments)]
235async fn run_once(
236    nodes: &[ExpandedNode],
237    auth: &AuthCatalog,
238    execution: &Option<crate::config::ExecutionSpec>,
239    compiled: &CompiledSchedule,
240    pipeline_name: &str,
241    #[cfg(feature = "lineage")] lineage: &Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
242    #[cfg(feature = "lineage")] lineage_cfg: &Option<faucet_lineage::LineageConfig>,
243) -> CliResult<()> {
244    tracing::info!(pipeline = %pipeline_name, "schedule --once: running one pipeline now");
245    let now = chrono::Utc::now();
246    let opts = make_opts(
247        pipeline_name,
248        execution,
249        auth,
250        compiled.clock_at(now),
251        #[cfg(feature = "lineage")]
252        lineage,
253        #[cfg(feature = "lineage")]
254        lineage_cfg,
255    );
256    let span = run_span(1, now, now);
257    let fut = run_expanded(nodes.to_vec(), opts).instrument(span);
258    let summary = match compiled.run_timeout {
259        Some(d) => tokio::time::timeout(d, fut).await.map_err(|_| {
260            CliError::Internal(format!(
261                "--once run exceeded run_timeout_secs ({}s)",
262                d.as_secs()
263            ))
264        })??,
265        None => fut.await?,
266    };
267    if summary.had_failures() {
268        return Err(CliError::PipelineHadFailures {
269            count: summary.failure_count(),
270        });
271    }
272    Ok(())
273}
274
275/// The scheduling loop.
276#[allow(clippy::too_many_arguments)]
277async fn run_loop(
278    compiled: CompiledSchedule,
279    nodes: Vec<ExpandedNode>,
280    auth: AuthCatalog,
281    execution: Option<crate::config::ExecutionSpec>,
282    pipeline_name: String,
283    cron: String,
284    timezone: String,
285    #[cfg(feature = "lineage")] lineage: Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
286    #[cfg(feature = "lineage")] lineage_cfg: Option<faucet_lineage::LineageConfig>,
287) -> CliResult<()> {
288    let mut state = SchedulerState::new(&compiled);
289    let mut shutdown = Shutdown::new()?;
290    let mut running: Option<RunningRun> = None;
291    let mut pending_scheduled_for: Option<DateTime<Utc>> = None;
292    let mut run_ordinal: u64 = 0;
293
294    let mut next_due = if compiled.start_immediately {
295        Utc::now()
296    } else {
297        compiled
298            .next_after(Utc::now())
299            .ok_or_else(|| CliError::Config("schedule: no upcoming occurrence".into()))?
300    };
301
302    // Startup banner: cron, timezone, and the next few firing times so an
303    // operator can confirm at a glance the schedule is configured correctly.
304    let upcoming: Vec<String> = {
305        let mut t = Utc::now();
306        let mut v = Vec::with_capacity(3);
307        while v.len() < 3 {
308            match compiled.next_after(t) {
309                Some(n) => {
310                    v.push(n.to_rfc3339());
311                    t = n;
312                }
313                None => break,
314            }
315        }
316        v
317    };
318    tracing::info!(
319        pipeline = %pipeline_name,
320        cron = %cron,
321        timezone = %timezone,
322        next_occurrences = ?upcoming,
323        "scheduler started (Ctrl-C / SIGTERM to stop)"
324    );
325
326    // Register HELP text and pre-emit the two run-state gauges at 0 so both
327    // series exist in `/metrics` from t=0 — the `metrics` exporter only renders
328    // a series after its first emission, and these gauges are otherwise first
329    // touched mid/post-run, leaving a pre-first-run scrape blind to them
330    // (#146 R NIT).
331    m::describe();
332    m::in_flight(&pipeline_name, 0);
333    m::consecutive_failures(&pipeline_name, 0);
334
335    loop {
336        let now = Utc::now();
337
338        if now >= next_due {
339            match state.on_tick(running.is_some()) {
340                TickAction::Dispatch => {
341                    run_ordinal += 1;
342                    let opts = make_opts(
343                        &pipeline_name,
344                        &execution,
345                        &auth,
346                        compiled.clock_at(next_due),
347                        #[cfg(feature = "lineage")]
348                        &lineage,
349                        #[cfg(feature = "lineage")]
350                        &lineage_cfg,
351                    );
352                    let span = run_span(run_ordinal, next_due, now);
353                    let handle = spawn_run(nodes.clone(), opts, compiled.run_timeout, span);
354                    m::in_flight(&pipeline_name, 1);
355                    m::last_run_started(&pipeline_name, now);
356                    m::lateness(&pipeline_name, now - next_due);
357                    tracing::info!(pipeline = %pipeline_name, run_ordinal, scheduled_for = %next_due, "run started");
358                    running = Some(RunningRun {
359                        handle,
360                        started: Instant::now(),
361                    });
362                }
363                TickAction::Skip => {
364                    m::overlap(&pipeline_name, "skip");
365                    m::run_outcome(&pipeline_name, "skipped");
366                    tracing::warn!(pipeline = %pipeline_name, scheduled_for = %next_due, "tick skipped — previous run still in progress");
367                }
368                TickAction::Queue => {
369                    m::overlap(&pipeline_name, "queue");
370                    if pending_scheduled_for.is_none() {
371                        pending_scheduled_for = Some(next_due);
372                    }
373                    tracing::warn!(pipeline = %pipeline_name, scheduled_for = %next_due, "tick queued — will run after current run finishes");
374                }
375                TickAction::ForbidAbort => {
376                    m::overlap(&pipeline_name, "forbid");
377                    // A prior `Dispatch` set the in-flight gauge to 1; reset it
378                    // before we bail so `/metrics` doesn't read a stuck 1 after
379                    // the scheduler exits (#146 R LOW).
380                    m::in_flight(&pipeline_name, 0);
381                    return Err(CliError::ScheduleOverlapForbidden);
382                }
383            }
384            // Advance from the tick that just fired (`next_due`), not from the
385            // wall clock — so a sub-minute occurrence isn't skipped just because
386            // dispatch latency pushed `now` past it. A long backlog (suspension)
387            // is collapsed to a single catch-up inside `next_due_after_tick`.
388            next_due = match compiled.next_due_after_tick(next_due, Utc::now()) {
389                Some(t) => t,
390                None => {
391                    tracing::info!(pipeline = %pipeline_name, "no further scheduled occurrences; exiting");
392                    return Ok(());
393                }
394            };
395        }
396
397        let now2 = Utc::now();
398        m::heartbeat(&pipeline_name, now2);
399        m::next_tick(&pipeline_name, next_due);
400        let chunk = (next_due - now2)
401            .to_std()
402            .unwrap_or(Duration::ZERO)
403            .min(MAX_SLEEP);
404
405        tokio::select! {
406            biased;
407
408            _ = shutdown.recv() => {
409                tracing::info!(pipeline = %pipeline_name, "shutdown signal received; draining in-flight run");
410                graceful_shutdown(running.take(), compiled.shutdown_grace, &pipeline_name).await;
411                return Ok(());
412            }
413
414            finished = wait_for_run(&mut running) => {
415                let mut finished = finished;
416                if let Some(rr) = running.take() {
417                    finished.duration = rr.started.elapsed();
418                }
419                m::in_flight(&pipeline_name, 0);
420                let done_at = Utc::now();
421                m::last_run_completed(&pipeline_name, done_at);
422                m::last_run_duration(&pipeline_name, finished.duration);
423                m::run_outcome(&pipeline_name, match finished.outcome {
424                    RunOutcome::Success => "ok",
425                    RunOutcome::Failure => "err",
426                });
427                match finished.outcome {
428                    RunOutcome::Success => tracing::info!(
429                        pipeline = %pipeline_name, secs = finished.duration.as_secs_f64(), "run completed"
430                    ),
431                    RunOutcome::Failure => tracing::error!(
432                        pipeline = %pipeline_name, detail = finished.detail.as_deref().unwrap_or("unknown"),
433                        "run failed"
434                    ),
435                }
436
437                let after = state.on_run_finished(finished.outcome);
438                m::consecutive_failures(&pipeline_name, state.consecutive_failures());
439                match after {
440                    AfterRun::ExitOk => {
441                        tracing::info!(pipeline = %pipeline_name, "max_runs reached; exiting");
442                        return Ok(());
443                    }
444                    AfterRun::ExitFailure { consecutive } => {
445                        return Err(CliError::PipelineHadFailures { count: consecutive as usize });
446                    }
447                    AfterRun::Continue { dispatch_pending } => {
448                        if dispatch_pending {
449                            run_ordinal += 1;
450                            let sched_for = pending_scheduled_for.take().unwrap_or(done_at);
451                            let opts = make_opts(
452                                &pipeline_name,
453                                &execution,
454                                &auth,
455                                compiled.clock_at(sched_for),
456                                #[cfg(feature = "lineage")]
457                                &lineage,
458                                #[cfg(feature = "lineage")]
459                                &lineage_cfg,
460                            );
461                            let span = run_span(run_ordinal, sched_for, done_at);
462                            let handle = spawn_run(nodes.clone(), opts, compiled.run_timeout, span);
463                            m::in_flight(&pipeline_name, 1);
464                            m::last_run_started(&pipeline_name, done_at);
465                            m::lateness(&pipeline_name, done_at - sched_for);
466                            tracing::info!(pipeline = %pipeline_name, run_ordinal, scheduled_for = %sched_for, "queued run started");
467                            running = Some(RunningRun { handle, started: Instant::now() });
468                        }
469                    }
470                }
471            }
472
473            _ = tokio::time::sleep(chunk) => { /* re-loop: re-read wall clock */ }
474        }
475    }
476}
477
478/// Await the in-flight run (or never resolve when idle). Returns the classified
479/// outcome; the caller fills in `duration` from the `RunningRun`.
480async fn wait_for_run(running: &mut Option<RunningRun>) -> RunFinished {
481    match running {
482        Some(rr) => classify((&mut rr.handle).await),
483        None => std::future::pending().await,
484    }
485}
486
487/// On shutdown, await the in-flight run up to `grace`, then abort it.
488async fn graceful_shutdown(running: Option<RunningRun>, grace: Duration, pipeline_name: &str) {
489    if let Some(mut rr) = running {
490        match tokio::time::timeout(grace, &mut rr.handle).await {
491            Ok(_) => {
492                tracing::info!(pipeline = %pipeline_name, "in-flight run finished during shutdown grace")
493            }
494            Err(_) => {
495                rr.handle.abort();
496                tracing::warn!(
497                    pipeline = %pipeline_name,
498                    grace_secs = grace.as_secs(),
499                    "in-flight run exceeded shutdown grace; aborted (partial sink state possible; bookmark preserved for the next run)"
500                );
501            }
502        }
503        m::in_flight(pipeline_name, 0);
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use crate::schedule::spec::ScheduleSpec;
511
512    fn compiled(yaml: &str) -> CompiledSchedule {
513        let spec: ScheduleSpec = serde_yaml::from_str(yaml).unwrap();
514        CompiledSchedule::compile(&spec).unwrap()
515    }
516
517    fn summary(failures: usize, total: usize) -> RunSummary {
518        let mut invocations = Vec::new();
519        for i in 0..total {
520            invocations.push(crate::executor::InvocationOutcome {
521                row_id: format!("r{i}"),
522                parent_record_key: None,
523                records_written: if i < failures { 0 } else { 3 },
524                error: if i < failures {
525                    Some("boom".into())
526                } else {
527                    None
528                },
529            });
530        }
531        RunSummary { invocations }
532    }
533
534    #[test]
535    fn classify_success_when_no_failures() {
536        let joined = Ok(Ok(summary(0, 2)));
537        let f = classify(joined);
538        assert_eq!(f.outcome, RunOutcome::Success);
539        assert!(f.detail.is_none());
540    }
541
542    #[test]
543    fn classify_failure_when_some_invocations_failed() {
544        let joined = Ok(Ok(summary(2, 5)));
545        let f = classify(joined);
546        assert_eq!(f.outcome, RunOutcome::Failure);
547        assert_eq!(f.detail.as_deref(), Some("2 invocation(s) failed"));
548    }
549
550    #[test]
551    fn classify_failure_when_run_errored() {
552        let joined: Result<CliResult<RunSummary>, tokio::task::JoinError> =
553            Ok(Err(CliError::Internal("disk full".into())));
554        let f = classify(joined);
555        assert_eq!(f.outcome, RunOutcome::Failure);
556        assert!(f.detail.as_deref().unwrap().contains("disk full"));
557    }
558
559    #[tokio::test]
560    async fn classify_failure_when_task_panicked() {
561        // Spawn a task that panics, then join it to obtain a real JoinError.
562        let handle = tokio::spawn(async { panic!("kaboom") });
563        let joined: Result<CliResult<RunSummary>, tokio::task::JoinError> = handle.await.map(Ok);
564        let f = classify(joined);
565        assert_eq!(f.outcome, RunOutcome::Failure);
566        assert!(
567            f.detail.as_deref().unwrap().contains("panicked"),
568            "{:?}",
569            f.detail
570        );
571    }
572
573    #[test]
574    fn run_span_carries_ordinal_and_times() {
575        let scheduled = Utc::now();
576        let tick = scheduled + chrono::Duration::seconds(3);
577        let span = run_span(7, scheduled, tick);
578        // The span exists and is enterable; field values are recorded on
579        // creation. We assert it has the expected metadata name.
580        assert_eq!(span.metadata().unwrap().name(), "faucet.schedule.run");
581    }
582
583    #[tokio::test]
584    async fn wait_for_run_returns_classified_outcome() {
585        let handle = tokio::spawn(async { Ok(summary(0, 1)) });
586        let mut running = Some(RunningRun {
587            handle,
588            started: Instant::now(),
589        });
590        let finished = wait_for_run(&mut running).await;
591        assert_eq!(finished.outcome, RunOutcome::Success);
592    }
593
594    #[tokio::test]
595    async fn spawn_run_times_out_into_internal_error() {
596        // The run "never finishes" (a long sleep) but the 1s timeout aborts it
597        // and maps to an Internal error mentioning run_timeout_secs.
598        let dir = tempfile::tempdir().unwrap();
599        let input = dir.path().join("in.csv");
600        let output = dir.path().join("out.jsonl");
601        std::fs::write(&input, "name\nx\n").unwrap();
602        // Build nodes from a tiny real config so the spawned future is genuine.
603        let yaml = format!(
604            "version: 1\npipeline:\n  source: {{ type: csv, config: {{ path: {input} }} }}\n  sink: {{ type: jsonl, config: {{ path: {output} }} }}\n",
605            input = input.display(),
606            output = output.display(),
607        );
608        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
609        let nodes = expand(&cfg).unwrap();
610        let auth = AuthCatalog::new();
611        let opts = make_opts(
612            "to",
613            &None,
614            &auth,
615            Utc::now().fixed_offset(),
616            #[cfg(feature = "lineage")]
617            &None,
618            #[cfg(feature = "lineage")]
619            &None,
620        );
621        // A zero-ish timeout (1ns) virtually guarantees the timeout branch fires
622        // even though the pipeline is fast — the timeout races the spawn.
623        let handle = spawn_run(
624            nodes,
625            opts,
626            Some(Duration::from_nanos(1)),
627            run_span(1, Utc::now(), Utc::now()),
628        );
629        let joined = handle.await.unwrap();
630        // Either the run finished before the 1ns deadline (Ok) — unlikely — or
631        // it tripped the timeout into an Internal error. Accept both but assert
632        // the timeout message shape when it errors.
633        if let Err(CliError::Internal(msg)) = &joined {
634            assert!(msg.contains("run_timeout_secs"), "{msg}");
635        }
636    }
637
638    #[tokio::test]
639    async fn make_opts_disables_dry_run_limit_and_state_override() {
640        let auth = AuthCatalog::new();
641        let clock = Utc::now().fixed_offset();
642        let opts = make_opts(
643            "p",
644            &None,
645            &auth,
646            clock,
647            #[cfg(feature = "lineage")]
648            &None,
649            #[cfg(feature = "lineage")]
650            &None,
651        );
652        assert_eq!(opts.pipeline_name, "p");
653        assert!(!opts.dry_run);
654        assert!(opts.limit.is_none());
655        assert!(opts.state_path_override.is_none());
656        assert!(opts.cancel.is_none());
657        assert_eq!(opts.clock, clock);
658    }
659
660    #[tokio::test]
661    async fn graceful_shutdown_awaits_finished_run() {
662        // A run that finishes immediately is awaited within the grace window.
663        let c = compiled("cron: \"* * * * *\"\nshutdown_grace_secs: 5");
664        let handle = tokio::spawn(async { Ok(summary(0, 1)) });
665        let running = Some(RunningRun {
666            handle,
667            started: Instant::now(),
668        });
669        // Should return promptly without aborting (the run already completed).
670        graceful_shutdown(running, c.shutdown_grace, "p").await;
671    }
672
673    #[tokio::test]
674    async fn graceful_shutdown_aborts_run_exceeding_grace() {
675        // A run that never finishes is aborted once the (tiny) grace elapses.
676        let handle = tokio::spawn(async {
677            tokio::time::sleep(Duration::from_secs(3600)).await;
678            Ok(summary(0, 1))
679        });
680        let running = Some(RunningRun {
681            handle,
682            started: Instant::now(),
683        });
684        // 50ms grace → the abort branch fires; the call must still return.
685        graceful_shutdown(running, Duration::from_millis(50), "p").await;
686    }
687
688    #[tokio::test]
689    async fn graceful_shutdown_noop_when_idle() {
690        // No in-flight run → returns immediately.
691        graceful_shutdown(None, Duration::from_secs(1), "p").await;
692    }
693}