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    /// Circuit-breaker re-entry cooldown when the run tripped the breaker.
33    cooldown: Option<Duration>,
34}
35
36/// Cross-platform shutdown-signal source, registered once.
37struct 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    /// Resolve when SIGTERM (Unix) or Ctrl-C (any platform) is received.
59    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
74/// Max time to sleep before re-reading the wall clock. Caps clock-step / DST
75/// drift to one chunk and keeps the heartbeat fresh.
76const MAX_SLEEP: Duration = Duration::from_secs(30);
77
78/// Execute the `schedule` subcommand.
79pub 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    // Build the shared OpenLineage emitter once; the `Arc` is cloned into each
111    // tick's `ExecuteOptions` so every run reuses the same transport/client.
112    #[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    // Build the notifier once; the `Arc` is cloned into each tick's options and
118    // reused for the scheduler-level `scheduler_stuck` signal.
119    #[cfg(feature = "notify")]
120    let notifier = crate::notify::Notifier::from_specs(&cfg.notifications)?;
121    // Connect the catalog store once; the handle (a pooled connection) is
122    // cloned into each tick's options so every run accumulates into it.
123    #[cfg(feature = "catalog")]
124    let catalog = match cfg.catalog.as_ref() {
125        Some(spec) => Some(crate::catalog::connect_from_spec(spec).await?),
126        None => None,
127    };
128    let nodes = expand(&cfg)?; // validate once; cloned per tick
129    let execution = cfg.execution.clone();
130    let resilience = match &cfg.resilience {
131        Some(spec) => Some(spec.to_policy()?),
132        None => None,
133    };
134
135    if args.once {
136        return run_once(
137            &nodes,
138            &auth,
139            &execution,
140            &compiled,
141            &pipeline_name,
142            &resilience,
143            &cfg.sla,
144            #[cfg(feature = "lineage")]
145            &lineage,
146            #[cfg(feature = "lineage")]
147            &lineage_cfg,
148            #[cfg(feature = "notify")]
149            &notifier,
150            #[cfg(feature = "catalog")]
151            &catalog,
152        )
153        .await;
154    }
155
156    run_loop(
157        compiled,
158        nodes,
159        auth,
160        execution,
161        pipeline_name,
162        cron,
163        timezone,
164        resilience,
165        cfg.sla.clone(),
166        #[cfg(feature = "lineage")]
167        lineage,
168        #[cfg(feature = "lineage")]
169        lineage_cfg,
170        #[cfg(feature = "notify")]
171        notifier,
172        #[cfg(feature = "catalog")]
173        catalog,
174    )
175    .await
176}
177
178/// Build a fresh `ExecuteOptions` for one tick (connectors are rebuilt per run;
179/// the auth catalog is shared so cached tokens survive across ticks).
180#[allow(clippy::too_many_arguments)]
181fn make_opts(
182    pipeline_name: &str,
183    execution: &Option<crate::config::ExecutionSpec>,
184    auth: &AuthCatalog,
185    clock: chrono::DateTime<chrono::FixedOffset>,
186    resilience: &Option<faucet_core::ResiliencePolicy>,
187    sla: &Option<crate::sla::SlaSpec>,
188    #[cfg(feature = "lineage")] lineage: &Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
189    #[cfg(feature = "lineage")] lineage_cfg: &Option<faucet_lineage::LineageConfig>,
190    #[cfg(feature = "notify")] notifier: &Option<std::sync::Arc<crate::notify::Notifier>>,
191    #[cfg(feature = "catalog")] catalog: &Option<crate::catalog::CatalogHandle>,
192) -> ExecuteOptions {
193    ExecuteOptions {
194        pipeline_name: pipeline_name.to_string(),
195        execution: execution.clone(),
196        dry_run: false,
197        limit: None,
198        state_path_override: None,
199        shard: None,
200        auth: auth.clone(),
201        clock,
202        cancel: None,
203        resilience: resilience.clone(),
204        sla: sla.clone(),
205        #[cfg(feature = "lineage")]
206        lineage: lineage.clone(),
207        #[cfg(feature = "lineage")]
208        lineage_cfg: lineage_cfg.clone(),
209        #[cfg(feature = "notify")]
210        notifier: notifier.clone(),
211        #[cfg(feature = "catalog")]
212        catalog: catalog.clone(),
213    }
214}
215
216/// The per-run tracing span. Wraps the inner pipeline spans so a scheduled run
217/// is correlatable in distributed tracing. `scheduled_for` is the cron-intended
218/// instant; `tick` is when the run actually started.
219fn run_span(run_ordinal: u64, scheduled_for: DateTime<Utc>, tick: DateTime<Utc>) -> tracing::Span {
220    tracing::info_span!(
221        "faucet.schedule.run",
222        run_ordinal,
223        scheduled_for_unix_seconds = scheduled_for.timestamp(),
224        tick_unix_seconds = tick.timestamp(),
225    )
226}
227
228/// Spawn one pipeline run, wrapping it in the optional run timeout and the
229/// per-run span.
230fn spawn_run(
231    nodes: Vec<ExpandedNode>,
232    opts: ExecuteOptions,
233    timeout: Option<Duration>,
234    span: tracing::Span,
235) -> JoinHandle<CliResult<RunSummary>> {
236    tokio::spawn(
237        async move {
238            match timeout {
239                Some(d) => match tokio::time::timeout(d, run_expanded(nodes, opts)).await {
240                    Ok(r) => r,
241                    Err(_) => Err(CliError::Internal(format!(
242                        "scheduled run exceeded run_timeout_secs ({}s) and was aborted",
243                        d.as_secs()
244                    ))),
245                },
246                None => run_expanded(nodes, opts).await,
247            }
248        }
249        .instrument(span),
250    )
251}
252
253/// Display prefix of [`faucet_core::FaucetError::CircuitOpen`]. The per-invocation
254/// typed error is flattened to a string by the executor, so the scheduler matches
255/// on this stable prefix to detect a circuit-open run; the authoritative cooldown
256/// duration is recovered from the run's configured resilience policy (not parsed
257/// out of the message).
258const CIRCUIT_OPEN_PREFIX: &str = "Circuit open after";
259
260/// Classify a joined run task into a scheduler outcome + a log detail. When the
261/// run tripped the circuit breaker, `cooldown` is set to the policy's re-entry
262/// cooldown so the loop can delay the next tick.
263fn classify(
264    joined: Result<CliResult<RunSummary>, tokio::task::JoinError>,
265    breaker_cooldown: Option<Duration>,
266) -> RunFinished {
267    // Did any failure indicate a tripped circuit breaker?
268    let circuit_open = match &joined {
269        Ok(Ok(summary)) => summary
270            .invocations
271            .iter()
272            .filter_map(|i| i.error.as_deref())
273            .any(|e| e.starts_with(CIRCUIT_OPEN_PREFIX)),
274        Ok(Err(e)) => e.to_string().contains(CIRCUIT_OPEN_PREFIX),
275        Err(_) => false,
276    };
277    // Recover the typed cooldown through the pure decision helper, using the
278    // configured cooldown as the authoritative duration.
279    let cooldown = if circuit_open {
280        let reconstructed: Result<(), faucet_core::FaucetError> =
281            Err(faucet_core::FaucetError::CircuitOpen {
282                failures: 0,
283                cooldown: breaker_cooldown.unwrap_or(Duration::ZERO),
284            });
285        crate::schedule::state::cooldown_delay(&reconstructed).filter(|d| !d.is_zero())
286    } else {
287        None
288    };
289
290    let (outcome, detail) = match joined {
291        Ok(Ok(summary)) if summary.had_failures() => (
292            RunOutcome::Failure,
293            Some(format!("{} invocation(s) failed", summary.failure_count())),
294        ),
295        Ok(Ok(_)) => (RunOutcome::Success, None),
296        Ok(Err(e)) => (RunOutcome::Failure, Some(e.to_string())),
297        Err(je) => (
298            RunOutcome::Failure,
299            Some(format!("run task panicked: {je}")),
300        ),
301    };
302    RunFinished {
303        outcome,
304        duration: Duration::ZERO,
305        detail,
306        cooldown,
307    }
308}
309
310/// `--once`: run exactly one pipeline run now and map its result to an exit.
311#[allow(clippy::too_many_arguments)]
312async fn run_once(
313    nodes: &[ExpandedNode],
314    auth: &AuthCatalog,
315    execution: &Option<crate::config::ExecutionSpec>,
316    compiled: &CompiledSchedule,
317    pipeline_name: &str,
318    resilience: &Option<faucet_core::ResiliencePolicy>,
319    sla: &Option<crate::sla::SlaSpec>,
320    #[cfg(feature = "lineage")] lineage: &Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
321    #[cfg(feature = "lineage")] lineage_cfg: &Option<faucet_lineage::LineageConfig>,
322    #[cfg(feature = "notify")] notifier: &Option<std::sync::Arc<crate::notify::Notifier>>,
323    #[cfg(feature = "catalog")] catalog: &Option<crate::catalog::CatalogHandle>,
324) -> CliResult<()> {
325    tracing::info!(pipeline = %pipeline_name, "schedule --once: running one pipeline now");
326    let now = chrono::Utc::now();
327    let opts = make_opts(
328        pipeline_name,
329        execution,
330        auth,
331        compiled.clock_at(now),
332        resilience,
333        sla,
334        #[cfg(feature = "lineage")]
335        lineage,
336        #[cfg(feature = "lineage")]
337        lineage_cfg,
338        #[cfg(feature = "notify")]
339        notifier,
340        #[cfg(feature = "catalog")]
341        catalog,
342    );
343    let span = run_span(1, now, now);
344    let fut = run_expanded(nodes.to_vec(), opts).instrument(span);
345    let summary = match compiled.run_timeout {
346        Some(d) => tokio::time::timeout(d, fut).await.map_err(|_| {
347            CliError::Internal(format!(
348                "--once run exceeded run_timeout_secs ({}s)",
349                d.as_secs()
350            ))
351        })??,
352        None => fut.await?,
353    };
354    if summary.had_failures() {
355        return Err(CliError::PipelineHadFailures {
356            count: summary.failure_count(),
357        });
358    }
359    Ok(())
360}
361
362/// The scheduling loop.
363#[allow(clippy::too_many_arguments)]
364async fn run_loop(
365    compiled: CompiledSchedule,
366    nodes: Vec<ExpandedNode>,
367    auth: AuthCatalog,
368    execution: Option<crate::config::ExecutionSpec>,
369    pipeline_name: String,
370    cron: String,
371    timezone: String,
372    resilience: Option<faucet_core::ResiliencePolicy>,
373    sla: Option<crate::sla::SlaSpec>,
374    #[cfg(feature = "lineage")] lineage: Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
375    #[cfg(feature = "lineage")] lineage_cfg: Option<faucet_lineage::LineageConfig>,
376    #[cfg(feature = "notify")] notifier: Option<std::sync::Arc<crate::notify::Notifier>>,
377    #[cfg(feature = "catalog")] catalog: Option<crate::catalog::CatalogHandle>,
378) -> CliResult<()> {
379    let mut state = SchedulerState::new(&compiled);
380    // The circuit-breaker re-entry cooldown, if a breaker is configured. Used to
381    // delay the next tick after a run trips the breaker.
382    let breaker_cooldown = resilience
383        .as_ref()
384        .and_then(|r| r.circuit_breaker)
385        .map(|cb| cb.cooldown);
386    let mut shutdown = Shutdown::new()?;
387    let mut running: Option<RunningRun> = None;
388    let mut pending_scheduled_for: Option<DateTime<Utc>> = None;
389    let mut run_ordinal: u64 = 0;
390
391    let mut next_due = if compiled.start_immediately {
392        Utc::now()
393    } else {
394        compiled
395            .next_after(Utc::now())
396            .ok_or_else(|| CliError::Config("schedule: no upcoming occurrence".into()))?
397    };
398
399    // Startup banner: cron, timezone, and the next few firing times so an
400    // operator can confirm at a glance the schedule is configured correctly.
401    let upcoming: Vec<String> = {
402        let mut t = Utc::now();
403        let mut v = Vec::with_capacity(3);
404        while v.len() < 3 {
405            match compiled.next_after(t) {
406                Some(n) => {
407                    v.push(n.to_rfc3339());
408                    t = n;
409                }
410                None => break,
411            }
412        }
413        v
414    };
415    tracing::info!(
416        pipeline = %pipeline_name,
417        cron = %cron,
418        timezone = %timezone,
419        next_occurrences = ?upcoming,
420        "scheduler started (Ctrl-C / SIGTERM to stop)"
421    );
422
423    // Register HELP text and pre-emit the two run-state gauges at 0 so both
424    // series exist in `/metrics` from t=0 — the `metrics` exporter only renders
425    // a series after its first emission, and these gauges are otherwise first
426    // touched mid/post-run, leaving a pre-first-run scrape blind to them
427    // (#146 R NIT).
428    m::describe();
429    m::in_flight(&pipeline_name, 0);
430    m::consecutive_failures(&pipeline_name, 0);
431
432    loop {
433        let now = Utc::now();
434
435        if now >= next_due {
436            match state.on_tick(running.is_some()) {
437                TickAction::Dispatch => {
438                    run_ordinal += 1;
439                    let opts = make_opts(
440                        &pipeline_name,
441                        &execution,
442                        &auth,
443                        compiled.clock_at(next_due),
444                        &resilience,
445                        &sla,
446                        #[cfg(feature = "lineage")]
447                        &lineage,
448                        #[cfg(feature = "lineage")]
449                        &lineage_cfg,
450                        #[cfg(feature = "notify")]
451                        &notifier,
452                        #[cfg(feature = "catalog")]
453                        &catalog,
454                    );
455                    let span = run_span(run_ordinal, next_due, now);
456                    let handle = spawn_run(nodes.clone(), opts, compiled.run_timeout, span);
457                    m::in_flight(&pipeline_name, 1);
458                    m::last_run_started(&pipeline_name, now);
459                    m::lateness(&pipeline_name, now - next_due);
460                    tracing::info!(pipeline = %pipeline_name, run_ordinal, scheduled_for = %next_due, "run started");
461                    running = Some(RunningRun {
462                        handle,
463                        started: Instant::now(),
464                    });
465                }
466                TickAction::Skip => {
467                    m::overlap(&pipeline_name, "skip");
468                    m::run_outcome(&pipeline_name, "skipped");
469                    tracing::warn!(pipeline = %pipeline_name, scheduled_for = %next_due, "tick skipped — previous run still in progress");
470                }
471                TickAction::Queue => {
472                    m::overlap(&pipeline_name, "queue");
473                    if pending_scheduled_for.is_none() {
474                        pending_scheduled_for = Some(next_due);
475                    }
476                    tracing::warn!(pipeline = %pipeline_name, scheduled_for = %next_due, "tick queued — will run after current run finishes");
477                }
478                TickAction::ForbidAbort => {
479                    m::overlap(&pipeline_name, "forbid");
480                    // A prior `Dispatch` set the in-flight gauge to 1; reset it
481                    // before we bail so `/metrics` doesn't read a stuck 1 after
482                    // the scheduler exits (#146 R LOW).
483                    m::in_flight(&pipeline_name, 0);
484                    return Err(CliError::ScheduleOverlapForbidden);
485                }
486            }
487            // Advance from the tick that just fired (`next_due`), not from the
488            // wall clock — so a sub-minute occurrence isn't skipped just because
489            // dispatch latency pushed `now` past it. A long backlog (suspension)
490            // is collapsed to a single catch-up inside `next_due_after_tick`.
491            next_due = match compiled.next_due_after_tick(next_due, Utc::now()) {
492                Some(t) => t,
493                None => {
494                    tracing::info!(pipeline = %pipeline_name, "no further scheduled occurrences; exiting");
495                    return Ok(());
496                }
497            };
498        }
499
500        let now2 = Utc::now();
501        m::heartbeat(&pipeline_name, now2);
502        m::next_tick(&pipeline_name, next_due);
503        let chunk = (next_due - now2)
504            .to_std()
505            .unwrap_or(Duration::ZERO)
506            .min(MAX_SLEEP);
507
508        tokio::select! {
509            biased;
510
511            _ = shutdown.recv() => {
512                tracing::info!(pipeline = %pipeline_name, "shutdown signal received; draining in-flight run");
513                graceful_shutdown(running.take(), compiled.shutdown_grace, &pipeline_name).await;
514                // Flush any buffered OTLP telemetry after the final run drains
515                // (no-op without the `otel` feature).
516                faucet_core::shutdown_otel();
517                return Ok(());
518            }
519
520            finished = wait_for_run(&mut running, breaker_cooldown) => {
521                let mut finished = finished;
522                if let Some(rr) = running.take() {
523                    finished.duration = rr.started.elapsed();
524                }
525                m::in_flight(&pipeline_name, 0);
526                let done_at = Utc::now();
527
528                // Circuit-breaker re-entry cooldown: if the run tripped the
529                // breaker, push the next tick out so AT LEAST `cooldown` elapses
530                // before re-entry. This composes with the cron schedule — it
531                // only delays `next_due` when the cooldown would land later than
532                // the next scheduled occurrence. The actual wait happens in the
533                // existing cancellation-aware `select!` below, so SIGTERM still
534                // interrupts it.
535                if let Some(d) = finished.cooldown
536                    && let Ok(delta) = chrono::Duration::from_std(d)
537                {
538                    let resume = done_at + delta;
539                    if resume > next_due {
540                        next_due = resume;
541                    }
542                    tracing::warn!(
543                        pipeline = %pipeline_name,
544                        cooldown_secs = d.as_secs(),
545                        next_due = %next_due,
546                        "circuit breaker opened; delaying re-entry by cooldown"
547                    );
548                }
549                m::last_run_completed(&pipeline_name, done_at);
550                m::last_run_duration(&pipeline_name, finished.duration);
551                m::run_outcome(&pipeline_name, match finished.outcome {
552                    RunOutcome::Success => "ok",
553                    RunOutcome::Failure => "err",
554                });
555                match finished.outcome {
556                    RunOutcome::Success => tracing::info!(
557                        pipeline = %pipeline_name, secs = finished.duration.as_secs_f64(), "run completed"
558                    ),
559                    RunOutcome::Failure => tracing::error!(
560                        pipeline = %pipeline_name, detail = finished.detail.as_deref().unwrap_or("unknown"),
561                        "run failed"
562                    ),
563                }
564
565                let after = state.on_run_finished(finished.outcome);
566                m::consecutive_failures(&pipeline_name, state.consecutive_failures());
567                match after {
568                    AfterRun::ExitOk => {
569                        tracing::info!(pipeline = %pipeline_name, "max_runs reached; exiting");
570                        return Ok(());
571                    }
572                    AfterRun::ExitFailure { consecutive } => {
573                        #[cfg(feature = "notify")]
574                        if let Some(n) = &notifier {
575                            n.emit(crate::notify::NotifyEvent::scheduler_stuck(
576                                &pipeline_name,
577                                format!(
578                                    "scheduler exiting after {consecutive} consecutive failures"
579                                ),
580                            ))
581                            .await;
582                        }
583                        return Err(CliError::PipelineHadFailures { count: consecutive as usize });
584                    }
585                    AfterRun::Continue { dispatch_pending } => {
586                        if dispatch_pending {
587                            run_ordinal += 1;
588                            let sched_for = pending_scheduled_for.take().unwrap_or(done_at);
589                            let opts = make_opts(
590                                &pipeline_name,
591                                &execution,
592                                &auth,
593                                compiled.clock_at(sched_for),
594                                &resilience,
595                                &sla,
596                                #[cfg(feature = "lineage")]
597                                &lineage,
598                                #[cfg(feature = "lineage")]
599                                &lineage_cfg,
600                                #[cfg(feature = "notify")]
601                                &notifier,
602                                #[cfg(feature = "catalog")]
603                                &catalog,
604                            );
605                            let span = run_span(run_ordinal, sched_for, done_at);
606                            let handle = spawn_run(nodes.clone(), opts, compiled.run_timeout, span);
607                            m::in_flight(&pipeline_name, 1);
608                            m::last_run_started(&pipeline_name, done_at);
609                            m::lateness(&pipeline_name, done_at - sched_for);
610                            tracing::info!(pipeline = %pipeline_name, run_ordinal, scheduled_for = %sched_for, "queued run started");
611                            running = Some(RunningRun { handle, started: Instant::now() });
612                        }
613                    }
614                }
615            }
616
617            _ = tokio::time::sleep(chunk) => { /* re-loop: re-read wall clock */ }
618        }
619    }
620}
621
622/// Await the in-flight run (or never resolve when idle). Returns the classified
623/// outcome; the caller fills in `duration` from the `RunningRun`.
624async fn wait_for_run(
625    running: &mut Option<RunningRun>,
626    breaker_cooldown: Option<Duration>,
627) -> RunFinished {
628    match running {
629        Some(rr) => classify((&mut rr.handle).await, breaker_cooldown),
630        None => std::future::pending().await,
631    }
632}
633
634/// On shutdown, await the in-flight run up to `grace`, then abort it.
635async fn graceful_shutdown(running: Option<RunningRun>, grace: Duration, pipeline_name: &str) {
636    if let Some(mut rr) = running {
637        match tokio::time::timeout(grace, &mut rr.handle).await {
638            Ok(_) => {
639                tracing::info!(pipeline = %pipeline_name, "in-flight run finished during shutdown grace")
640            }
641            Err(_) => {
642                rr.handle.abort();
643                tracing::warn!(
644                    pipeline = %pipeline_name,
645                    grace_secs = grace.as_secs(),
646                    "in-flight run exceeded shutdown grace; aborted (partial sink state possible; bookmark preserved for the next run)"
647                );
648            }
649        }
650        m::in_flight(pipeline_name, 0);
651    }
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657    use crate::schedule::spec::ScheduleSpec;
658
659    fn compiled(yaml: &str) -> CompiledSchedule {
660        let spec: ScheduleSpec = serde_yaml::from_str(yaml).unwrap();
661        CompiledSchedule::compile(&spec).unwrap()
662    }
663
664    fn summary(failures: usize, total: usize) -> RunSummary {
665        let mut invocations = Vec::new();
666        for i in 0..total {
667            invocations.push(crate::executor::InvocationOutcome {
668                row_id: format!("r{i}"),
669                parent_record_key: None,
670                records_written: if i < failures { 0 } else { 3 },
671                error: if i < failures {
672                    Some("boom".into())
673                } else {
674                    None
675                },
676            });
677        }
678        RunSummary { invocations }
679    }
680
681    #[test]
682    fn classify_success_when_no_failures() {
683        let joined = Ok(Ok(summary(0, 2)));
684        let f = classify(joined, None);
685        assert_eq!(f.outcome, RunOutcome::Success);
686        assert!(f.detail.is_none());
687        assert!(f.cooldown.is_none());
688    }
689
690    #[test]
691    fn classify_failure_when_some_invocations_failed() {
692        let joined = Ok(Ok(summary(2, 5)));
693        let f = classify(joined, None);
694        assert_eq!(f.outcome, RunOutcome::Failure);
695        assert_eq!(f.detail.as_deref(), Some("2 invocation(s) failed"));
696        assert!(f.cooldown.is_none());
697    }
698
699    #[test]
700    fn classify_failure_when_run_errored() {
701        let joined: Result<CliResult<RunSummary>, tokio::task::JoinError> =
702            Ok(Err(CliError::Internal("disk full".into())));
703        let f = classify(joined, None);
704        assert_eq!(f.outcome, RunOutcome::Failure);
705        assert!(f.detail.as_deref().unwrap().contains("disk full"));
706    }
707
708    #[tokio::test]
709    async fn classify_failure_when_task_panicked() {
710        // Spawn a task that panics, then join it to obtain a real JoinError.
711        let handle = tokio::spawn(async { panic!("kaboom") });
712        let joined: Result<CliResult<RunSummary>, tokio::task::JoinError> = handle.await.map(Ok);
713        let f = classify(joined, None);
714        assert_eq!(f.outcome, RunOutcome::Failure);
715        assert!(
716            f.detail.as_deref().unwrap().contains("panicked"),
717            "{:?}",
718            f.detail
719        );
720    }
721
722    #[test]
723    fn classify_recovers_cooldown_from_circuit_open_invocation() {
724        // An invocation whose error is the flattened CircuitOpen Display string
725        // is detected; the cooldown is recovered from the configured policy.
726        let circuit_open_msg = faucet_core::FaucetError::CircuitOpen {
727            failures: 3,
728            cooldown: Duration::from_secs(60),
729        }
730        .to_string();
731        let invocations = vec![crate::executor::InvocationOutcome {
732            row_id: "r0".into(),
733            parent_record_key: None,
734            records_written: 0,
735            error: Some(circuit_open_msg),
736        }];
737        let joined = Ok(Ok(RunSummary { invocations }));
738        let f = classify(joined, Some(Duration::from_secs(45)));
739        assert_eq!(f.outcome, RunOutcome::Failure);
740        assert_eq!(f.cooldown, Some(Duration::from_secs(45)));
741    }
742
743    #[test]
744    fn classify_circuit_open_without_configured_cooldown_yields_none() {
745        // Defensive: a circuit-open run with no configured cooldown (zero)
746        // produces no delay rather than a spurious zero-length sleep.
747        let circuit_open_msg = faucet_core::FaucetError::CircuitOpen {
748            failures: 1,
749            cooldown: Duration::from_secs(10),
750        }
751        .to_string();
752        let invocations = vec![crate::executor::InvocationOutcome {
753            row_id: "r0".into(),
754            parent_record_key: None,
755            records_written: 0,
756            error: Some(circuit_open_msg),
757        }];
758        let joined = Ok(Ok(RunSummary { invocations }));
759        let f = classify(joined, None);
760        assert_eq!(f.outcome, RunOutcome::Failure);
761        assert!(f.cooldown.is_none());
762    }
763
764    #[test]
765    fn classify_no_cooldown_for_ordinary_failure() {
766        // A plain failing invocation must not trip the cooldown path even when a
767        // breaker cooldown is configured.
768        let joined = Ok(Ok(summary(1, 2)));
769        let f = classify(joined, Some(Duration::from_secs(30)));
770        assert_eq!(f.outcome, RunOutcome::Failure);
771        assert!(f.cooldown.is_none());
772    }
773
774    #[test]
775    fn run_span_carries_ordinal_and_times() {
776        let scheduled = Utc::now();
777        let tick = scheduled + chrono::Duration::seconds(3);
778        let span = run_span(7, scheduled, tick);
779        // The span exists and is enterable; field values are recorded on
780        // creation. We assert it has the expected metadata name.
781        assert_eq!(span.metadata().unwrap().name(), "faucet.schedule.run");
782    }
783
784    #[tokio::test]
785    async fn wait_for_run_returns_classified_outcome() {
786        let handle = tokio::spawn(async { Ok(summary(0, 1)) });
787        let mut running = Some(RunningRun {
788            handle,
789            started: Instant::now(),
790        });
791        let finished = wait_for_run(&mut running, None).await;
792        assert_eq!(finished.outcome, RunOutcome::Success);
793    }
794
795    #[tokio::test]
796    async fn spawn_run_times_out_into_internal_error() {
797        // The run "never finishes" (a long sleep) but the 1s timeout aborts it
798        // and maps to an Internal error mentioning run_timeout_secs.
799        let dir = tempfile::tempdir().unwrap();
800        let input = dir.path().join("in.csv");
801        let output = dir.path().join("out.jsonl");
802        std::fs::write(&input, "name\nx\n").unwrap();
803        // Build nodes from a tiny real config so the spawned future is genuine.
804        let yaml = format!(
805            "version: 1\npipeline:\n  source: {{ type: csv, config: {{ path: {input} }} }}\n  sink: {{ type: jsonl, config: {{ path: {output} }} }}\n",
806            input = input.display(),
807            output = output.display(),
808        );
809        let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
810        let nodes = expand(&cfg).unwrap();
811        let auth = AuthCatalog::new();
812        let opts = make_opts(
813            "to",
814            &None,
815            &auth,
816            Utc::now().fixed_offset(),
817            &None,
818            &None,
819            #[cfg(feature = "lineage")]
820            &None,
821            #[cfg(feature = "lineage")]
822            &None,
823            #[cfg(feature = "notify")]
824            &None,
825            #[cfg(feature = "catalog")]
826            &None,
827        );
828        // A zero-ish timeout (1ns) virtually guarantees the timeout branch fires
829        // even though the pipeline is fast — the timeout races the spawn.
830        let handle = spawn_run(
831            nodes,
832            opts,
833            Some(Duration::from_nanos(1)),
834            run_span(1, Utc::now(), Utc::now()),
835        );
836        let joined = handle.await.unwrap();
837        // Either the run finished before the 1ns deadline (Ok) — unlikely — or
838        // it tripped the timeout into an Internal error. Accept both but assert
839        // the timeout message shape when it errors.
840        if let Err(CliError::Internal(msg)) = &joined {
841            assert!(msg.contains("run_timeout_secs"), "{msg}");
842        }
843    }
844
845    #[tokio::test]
846    async fn make_opts_disables_dry_run_limit_and_state_override() {
847        let auth = AuthCatalog::new();
848        let clock = Utc::now().fixed_offset();
849        let opts = make_opts(
850            "p",
851            &None,
852            &auth,
853            clock,
854            &None,
855            &None,
856            #[cfg(feature = "lineage")]
857            &None,
858            #[cfg(feature = "lineage")]
859            &None,
860            #[cfg(feature = "notify")]
861            &None,
862            #[cfg(feature = "catalog")]
863            &None,
864        );
865        assert_eq!(opts.pipeline_name, "p");
866        assert!(!opts.dry_run);
867        assert!(opts.limit.is_none());
868        assert!(opts.state_path_override.is_none());
869        assert!(opts.cancel.is_none());
870        assert_eq!(opts.clock, clock);
871    }
872
873    #[tokio::test]
874    async fn graceful_shutdown_awaits_finished_run() {
875        // A run that finishes immediately is awaited within the grace window.
876        let c = compiled("cron: \"* * * * *\"\nshutdown_grace_secs: 5");
877        let handle = tokio::spawn(async { Ok(summary(0, 1)) });
878        let running = Some(RunningRun {
879            handle,
880            started: Instant::now(),
881        });
882        // Should return promptly without aborting (the run already completed).
883        graceful_shutdown(running, c.shutdown_grace, "p").await;
884    }
885
886    #[tokio::test]
887    async fn graceful_shutdown_aborts_run_exceeding_grace() {
888        // A run that never finishes is aborted once the (tiny) grace elapses.
889        let handle = tokio::spawn(async {
890            tokio::time::sleep(Duration::from_secs(3600)).await;
891            Ok(summary(0, 1))
892        });
893        let running = Some(RunningRun {
894            handle,
895            started: Instant::now(),
896        });
897        // 50ms grace → the abort branch fires; the call must still return.
898        graceful_shutdown(running, Duration::from_millis(50), "p").await;
899    }
900
901    #[tokio::test]
902    async fn graceful_shutdown_noop_when_idle() {
903        // No in-flight run → returns immediately.
904        graceful_shutdown(None, Duration::from_secs(1), "p").await;
905    }
906}