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    let nodes = expand(&cfg)?; // validate once; cloned per tick
118    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/// Build a fresh `ExecuteOptions` for one tick (connectors are rebuilt per run;
158/// the auth catalog is shared so cached tokens survive across ticks).
159#[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
187/// The per-run tracing span. Wraps the inner pipeline spans so a scheduled run
188/// is correlatable in distributed tracing. `scheduled_for` is the cron-intended
189/// instant; `tick` is when the run actually started.
190fn 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
199/// Spawn one pipeline run, wrapping it in the optional run timeout and the
200/// per-run span.
201fn 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
224/// Display prefix of [`faucet_core::FaucetError::CircuitOpen`]. The per-invocation
225/// typed error is flattened to a string by the executor, so the scheduler matches
226/// on this stable prefix to detect a circuit-open run; the authoritative cooldown
227/// duration is recovered from the run's configured resilience policy (not parsed
228/// out of the message).
229const CIRCUIT_OPEN_PREFIX: &str = "Circuit open after";
230
231/// Classify a joined run task into a scheduler outcome + a log detail. When the
232/// run tripped the circuit breaker, `cooldown` is set to the policy's re-entry
233/// cooldown so the loop can delay the next tick.
234fn classify(
235    joined: Result<CliResult<RunSummary>, tokio::task::JoinError>,
236    breaker_cooldown: Option<Duration>,
237) -> RunFinished {
238    // Did any failure indicate a tripped circuit breaker?
239    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    // Recover the typed cooldown through the pure decision helper, using the
249    // configured cooldown as the authoritative duration.
250    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/// `--once`: run exactly one pipeline run now and map its result to an exit.
282#[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/// The scheduling loop.
326#[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    // The circuit-breaker re-entry cooldown, if a breaker is configured. Used to
341    // delay the next tick after a run trips the breaker.
342    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    // Startup banner: cron, timezone, and the next few firing times so an
360    // operator can confirm at a glance the schedule is configured correctly.
361    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    // Register HELP text and pre-emit the two run-state gauges at 0 so both
384    // series exist in `/metrics` from t=0 — the `metrics` exporter only renders
385    // a series after its first emission, and these gauges are otherwise first
386    // touched mid/post-run, leaving a pre-first-run scrape blind to them
387    // (#146 R NIT).
388    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                    // A prior `Dispatch` set the in-flight gauge to 1; reset it
436                    // before we bail so `/metrics` doesn't read a stuck 1 after
437                    // the scheduler exits (#146 R LOW).
438                    m::in_flight(&pipeline_name, 0);
439                    return Err(CliError::ScheduleOverlapForbidden);
440                }
441            }
442            // Advance from the tick that just fired (`next_due`), not from the
443            // wall clock — so a sub-minute occurrence isn't skipped just because
444            // dispatch latency pushed `now` past it. A long backlog (suspension)
445            // is collapsed to a single catch-up inside `next_due_after_tick`.
446            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                // Flush any buffered OTLP telemetry after the final run drains
470                // (no-op without the `otel` feature).
471                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                // Circuit-breaker re-entry cooldown: if the run tripped the
484                // breaker, push the next tick out so AT LEAST `cooldown` elapses
485                // before re-entry. This composes with the cron schedule — it
486                // only delays `next_due` when the cooldown would land later than
487                // the next scheduled occurrence. The actual wait happens in the
488                // existing cancellation-aware `select!` below, so SIGTERM still
489                // interrupts it.
490                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) => { /* re-loop: re-read wall clock */ }
558        }
559    }
560}
561
562/// Await the in-flight run (or never resolve when idle). Returns the classified
563/// outcome; the caller fills in `duration` from the `RunningRun`.
564async 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
574/// On shutdown, await the in-flight run up to `grace`, then abort it.
575async 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        // Spawn a task that panics, then join it to obtain a real JoinError.
651        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        // An invocation whose error is the flattened CircuitOpen Display string
665        // is detected; the cooldown is recovered from the configured policy.
666        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        // Defensive: a circuit-open run with no configured cooldown (zero)
686        // produces no delay rather than a spurious zero-length sleep.
687        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        // A plain failing invocation must not trip the cooldown path even when a
707        // breaker cooldown is configured.
708        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        // The span exists and is enterable; field values are recorded on
720        // creation. We assert it has the expected metadata name.
721        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        // The run "never finishes" (a long sleep) but the 1s timeout aborts it
738        // and maps to an Internal error mentioning run_timeout_secs.
739        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        // Build nodes from a tiny real config so the spawned future is genuine.
744        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        // A zero-ish timeout (1ns) virtually guarantees the timeout branch fires
764        // even though the pipeline is fast — the timeout races the spawn.
765        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        // Either the run finished before the 1ns deadline (Ok) — unlikely — or
773        // it tripped the timeout into an Internal error. Accept both but assert
774        // the timeout message shape when it errors.
775        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        // A run that finishes immediately is awaited within the grace window.
806        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        // Should return promptly without aborting (the run already completed).
813        graceful_shutdown(running, c.shutdown_grace, "p").await;
814    }
815
816    #[tokio::test]
817    async fn graceful_shutdown_aborts_run_exceeding_grace() {
818        // A run that never finishes is aborted once the (tiny) grace elapses.
819        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        // 50ms grace → the abort branch fires; the call must still return.
828        graceful_shutdown(running, Duration::from_millis(50), "p").await;
829    }
830
831    #[tokio::test]
832    async fn graceful_shutdown_noop_when_idle() {
833        // No in-flight run → returns immediately.
834        graceful_shutdown(None, Duration::from_secs(1), "p").await;
835    }
836}