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