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