Skip to main content

faucet_cli/commands/
schedule.rs

1//! `faucet schedule` — run a pipeline on a cron schedule in one long-running
2//! process. Reuses `expand` + `executor::run_expanded` per tick; keeps no state
3//! of its own (resumability rides the pipeline's per-page bookmark). See
4//! `docs/superpowers/specs/2026-05-30-faucet-schedule-design.md`.
5
6use crate::auth_catalog::{AuthCatalog, build_auth_catalog};
7use crate::cli::ScheduleArgs;
8use crate::config::PipelineConfig;
9use crate::error::{CliError, CliResult};
10use crate::executor::{ExecuteOptions, RunSummary, run_expanded};
11use crate::expand::{ExpandedNode, expand};
12use crate::schedule::compiled::CompiledSchedule;
13use crate::schedule::metrics as m;
14use crate::schedule::state::{AfterRun, RunOutcome, SchedulerState, TickAction};
15use chrono::{DateTime, Utc};
16use std::time::Duration;
17use tokio::task::JoinHandle;
18use tokio::time::Instant;
19use tracing::Instrument;
20
21/// An in-flight run.
22struct RunningRun {
23    handle: JoinHandle<CliResult<RunSummary>>,
24    started: Instant,
25}
26
27/// The data the loop needs after a run finishes.
28struct RunFinished {
29    outcome: RunOutcome,
30    duration: Duration,
31    detail: Option<String>,
32}
33
34/// Cross-platform shutdown-signal source, registered once.
35struct Shutdown {
36    #[cfg(unix)]
37    sigterm: tokio::signal::unix::Signal,
38}
39
40impl Shutdown {
41    fn new() -> CliResult<Self> {
42        #[cfg(unix)]
43        {
44            use tokio::signal::unix::{SignalKind, signal};
45            let sigterm = signal(SignalKind::terminate()).map_err(|e| {
46                CliError::Internal(format!("failed to install SIGTERM handler: {e}"))
47            })?;
48            Ok(Self { sigterm })
49        }
50        #[cfg(not(unix))]
51        {
52            Ok(Self {})
53        }
54    }
55
56    /// Resolve when SIGTERM (Unix) or Ctrl-C (any platform) is received.
57    async fn recv(&mut self) {
58        #[cfg(unix)]
59        {
60            tokio::select! {
61                _ = tokio::signal::ctrl_c() => {}
62                _ = self.sigterm.recv() => {}
63            }
64        }
65        #[cfg(not(unix))]
66        {
67            let _ = tokio::signal::ctrl_c().await;
68        }
69    }
70}
71
72/// Max time to sleep before re-reading the wall clock. Caps clock-step / DST
73/// drift to one chunk and keeps the heartbeat fresh.
74const MAX_SLEEP: Duration = Duration::from_secs(30);
75
76/// Execute the `schedule` subcommand.
77pub async fn run(args: ScheduleArgs) -> CliResult<()> {
78    let cwd = std::env::current_dir()?;
79    let env_path =
80        crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
81    crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
82    let path = match args.config {
83        Some(p) => p,
84        None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
85    };
86
87    let cfg = PipelineConfig::from_path_async(&path).await?;
88    let spec = cfg.schedule.as_ref().ok_or_else(|| {
89        CliError::Config(
90            "no `schedule:` block in config — use `faucet run` for a one-shot run, or add a `schedule:` block"
91                .into(),
92        )
93    })?;
94    let compiled = CompiledSchedule::compile(spec)?;
95    let cron = spec.cron.clone();
96    let timezone = spec.timezone.clone();
97
98    crate::obs::install(&cfg)?;
99
100    let pipeline_name = cfg.name.clone().unwrap_or_else(|| {
101        path.file_stem()
102            .and_then(|s| s.to_str())
103            .unwrap_or("pipeline")
104            .to_owned()
105    });
106
107    let auth = build_auth_catalog(cfg.auth.as_ref())?;
108    let nodes = expand(&cfg)?; // validate once; cloned per tick
109    let execution = cfg.execution.clone();
110
111    if args.once {
112        return run_once(&nodes, &auth, &execution, &compiled, &pipeline_name).await;
113    }
114
115    run_loop(
116        compiled,
117        nodes,
118        auth,
119        execution,
120        pipeline_name,
121        cron,
122        timezone,
123    )
124    .await
125}
126
127/// Build a fresh `ExecuteOptions` for one tick (connectors are rebuilt per run;
128/// the auth catalog is shared so cached tokens survive across ticks).
129fn make_opts(
130    pipeline_name: &str,
131    execution: &Option<crate::config::ExecutionSpec>,
132    auth: &AuthCatalog,
133    clock: chrono::DateTime<chrono::FixedOffset>,
134) -> ExecuteOptions {
135    ExecuteOptions {
136        pipeline_name: pipeline_name.to_string(),
137        execution: execution.clone(),
138        dry_run: false,
139        limit: None,
140        state_path_override: None,
141        auth: auth.clone(),
142        clock,
143        cancel: None,
144    }
145}
146
147/// The per-run tracing span. Wraps the inner pipeline spans so a scheduled run
148/// is correlatable in distributed tracing. `scheduled_for` is the cron-intended
149/// instant; `tick` is when the run actually started.
150fn run_span(run_ordinal: u64, scheduled_for: DateTime<Utc>, tick: DateTime<Utc>) -> tracing::Span {
151    tracing::info_span!(
152        "faucet.schedule.run",
153        run_ordinal,
154        scheduled_for_unix_seconds = scheduled_for.timestamp(),
155        tick_unix_seconds = tick.timestamp(),
156    )
157}
158
159/// Spawn one pipeline run, wrapping it in the optional run timeout and the
160/// per-run span.
161fn spawn_run(
162    nodes: Vec<ExpandedNode>,
163    opts: ExecuteOptions,
164    timeout: Option<Duration>,
165    span: tracing::Span,
166) -> JoinHandle<CliResult<RunSummary>> {
167    tokio::spawn(
168        async move {
169            match timeout {
170                Some(d) => match tokio::time::timeout(d, run_expanded(nodes, opts)).await {
171                    Ok(r) => r,
172                    Err(_) => Err(CliError::Internal(format!(
173                        "scheduled run exceeded run_timeout_secs ({}s) and was aborted",
174                        d.as_secs()
175                    ))),
176                },
177                None => run_expanded(nodes, opts).await,
178            }
179        }
180        .instrument(span),
181    )
182}
183
184/// Classify a joined run task into a scheduler outcome + a log detail.
185fn classify(joined: Result<CliResult<RunSummary>, tokio::task::JoinError>) -> RunFinished {
186    let (outcome, detail) = match joined {
187        Ok(Ok(summary)) if summary.had_failures() => (
188            RunOutcome::Failure,
189            Some(format!("{} invocation(s) failed", summary.failure_count())),
190        ),
191        Ok(Ok(_)) => (RunOutcome::Success, None),
192        Ok(Err(e)) => (RunOutcome::Failure, Some(e.to_string())),
193        Err(je) => (
194            RunOutcome::Failure,
195            Some(format!("run task panicked: {je}")),
196        ),
197    };
198    RunFinished {
199        outcome,
200        duration: Duration::ZERO,
201        detail,
202    }
203}
204
205/// `--once`: run exactly one pipeline run now and map its result to an exit.
206async fn run_once(
207    nodes: &[ExpandedNode],
208    auth: &AuthCatalog,
209    execution: &Option<crate::config::ExecutionSpec>,
210    compiled: &CompiledSchedule,
211    pipeline_name: &str,
212) -> CliResult<()> {
213    tracing::info!(pipeline = %pipeline_name, "schedule --once: running one pipeline now");
214    let now = chrono::Utc::now();
215    let opts = make_opts(pipeline_name, execution, auth, compiled.clock_at(now));
216    let span = run_span(1, now, now);
217    let fut = run_expanded(nodes.to_vec(), opts).instrument(span);
218    let summary = match compiled.run_timeout {
219        Some(d) => tokio::time::timeout(d, fut).await.map_err(|_| {
220            CliError::Internal(format!(
221                "--once run exceeded run_timeout_secs ({}s)",
222                d.as_secs()
223            ))
224        })??,
225        None => fut.await?,
226    };
227    if summary.had_failures() {
228        return Err(CliError::PipelineHadFailures {
229            count: summary.failure_count(),
230        });
231    }
232    Ok(())
233}
234
235/// The scheduling loop.
236#[allow(clippy::too_many_arguments)]
237async fn run_loop(
238    compiled: CompiledSchedule,
239    nodes: Vec<ExpandedNode>,
240    auth: AuthCatalog,
241    execution: Option<crate::config::ExecutionSpec>,
242    pipeline_name: String,
243    cron: String,
244    timezone: String,
245) -> CliResult<()> {
246    let mut state = SchedulerState::new(&compiled);
247    let mut shutdown = Shutdown::new()?;
248    let mut running: Option<RunningRun> = None;
249    let mut pending_scheduled_for: Option<DateTime<Utc>> = None;
250    let mut run_ordinal: u64 = 0;
251
252    let mut next_due = if compiled.start_immediately {
253        Utc::now()
254    } else {
255        compiled
256            .next_after(Utc::now())
257            .ok_or_else(|| CliError::Config("schedule: no upcoming occurrence".into()))?
258    };
259
260    // Startup banner: cron, timezone, and the next few firing times so an
261    // operator can confirm at a glance the schedule is configured correctly.
262    let upcoming: Vec<String> = {
263        let mut t = Utc::now();
264        let mut v = Vec::with_capacity(3);
265        while v.len() < 3 {
266            match compiled.next_after(t) {
267                Some(n) => {
268                    v.push(n.to_rfc3339());
269                    t = n;
270                }
271                None => break,
272            }
273        }
274        v
275    };
276    tracing::info!(
277        pipeline = %pipeline_name,
278        cron = %cron,
279        timezone = %timezone,
280        next_occurrences = ?upcoming,
281        "scheduler started (Ctrl-C / SIGTERM to stop)"
282    );
283
284    // Register HELP text and pre-emit the two run-state gauges at 0 so both
285    // series exist in `/metrics` from t=0 — the `metrics` exporter only renders
286    // a series after its first emission, and these gauges are otherwise first
287    // touched mid/post-run, leaving a pre-first-run scrape blind to them
288    // (#146 R NIT).
289    m::describe();
290    m::in_flight(&pipeline_name, 0);
291    m::consecutive_failures(&pipeline_name, 0);
292
293    loop {
294        let now = Utc::now();
295
296        if now >= next_due {
297            match state.on_tick(running.is_some()) {
298                TickAction::Dispatch => {
299                    run_ordinal += 1;
300                    let opts = make_opts(
301                        &pipeline_name,
302                        &execution,
303                        &auth,
304                        compiled.clock_at(next_due),
305                    );
306                    let span = run_span(run_ordinal, next_due, now);
307                    let handle = spawn_run(nodes.clone(), opts, compiled.run_timeout, span);
308                    m::in_flight(&pipeline_name, 1);
309                    m::last_run_started(&pipeline_name, now);
310                    m::lateness(&pipeline_name, now - next_due);
311                    tracing::info!(pipeline = %pipeline_name, run_ordinal, scheduled_for = %next_due, "run started");
312                    running = Some(RunningRun {
313                        handle,
314                        started: Instant::now(),
315                    });
316                }
317                TickAction::Skip => {
318                    m::overlap(&pipeline_name, "skip");
319                    m::run_outcome(&pipeline_name, "skipped");
320                    tracing::warn!(pipeline = %pipeline_name, scheduled_for = %next_due, "tick skipped — previous run still in progress");
321                }
322                TickAction::Queue => {
323                    m::overlap(&pipeline_name, "queue");
324                    if pending_scheduled_for.is_none() {
325                        pending_scheduled_for = Some(next_due);
326                    }
327                    tracing::warn!(pipeline = %pipeline_name, scheduled_for = %next_due, "tick queued — will run after current run finishes");
328                }
329                TickAction::ForbidAbort => {
330                    m::overlap(&pipeline_name, "forbid");
331                    // A prior `Dispatch` set the in-flight gauge to 1; reset it
332                    // before we bail so `/metrics` doesn't read a stuck 1 after
333                    // the scheduler exits (#146 R LOW).
334                    m::in_flight(&pipeline_name, 0);
335                    return Err(CliError::ScheduleOverlapForbidden);
336                }
337            }
338            // Advance from the tick that just fired (`next_due`), not from the
339            // wall clock — so a sub-minute occurrence isn't skipped just because
340            // dispatch latency pushed `now` past it. A long backlog (suspension)
341            // is collapsed to a single catch-up inside `next_due_after_tick`.
342            next_due = match compiled.next_due_after_tick(next_due, Utc::now()) {
343                Some(t) => t,
344                None => {
345                    tracing::info!(pipeline = %pipeline_name, "no further scheduled occurrences; exiting");
346                    return Ok(());
347                }
348            };
349        }
350
351        let now2 = Utc::now();
352        m::heartbeat(&pipeline_name, now2);
353        m::next_tick(&pipeline_name, next_due);
354        let chunk = (next_due - now2)
355            .to_std()
356            .unwrap_or(Duration::ZERO)
357            .min(MAX_SLEEP);
358
359        tokio::select! {
360            biased;
361
362            _ = shutdown.recv() => {
363                tracing::info!(pipeline = %pipeline_name, "shutdown signal received; draining in-flight run");
364                graceful_shutdown(running.take(), compiled.shutdown_grace, &pipeline_name).await;
365                return Ok(());
366            }
367
368            finished = wait_for_run(&mut running) => {
369                let mut finished = finished;
370                if let Some(rr) = running.take() {
371                    finished.duration = rr.started.elapsed();
372                }
373                m::in_flight(&pipeline_name, 0);
374                let done_at = Utc::now();
375                m::last_run_completed(&pipeline_name, done_at);
376                m::last_run_duration(&pipeline_name, finished.duration);
377                m::run_outcome(&pipeline_name, match finished.outcome {
378                    RunOutcome::Success => "ok",
379                    RunOutcome::Failure => "err",
380                });
381                match finished.outcome {
382                    RunOutcome::Success => tracing::info!(
383                        pipeline = %pipeline_name, secs = finished.duration.as_secs_f64(), "run completed"
384                    ),
385                    RunOutcome::Failure => tracing::error!(
386                        pipeline = %pipeline_name, detail = finished.detail.as_deref().unwrap_or("unknown"),
387                        "run failed"
388                    ),
389                }
390
391                let after = state.on_run_finished(finished.outcome);
392                m::consecutive_failures(&pipeline_name, state.consecutive_failures());
393                match after {
394                    AfterRun::ExitOk => {
395                        tracing::info!(pipeline = %pipeline_name, "max_runs reached; exiting");
396                        return Ok(());
397                    }
398                    AfterRun::ExitFailure { consecutive } => {
399                        return Err(CliError::PipelineHadFailures { count: consecutive as usize });
400                    }
401                    AfterRun::Continue { dispatch_pending } => {
402                        if dispatch_pending {
403                            run_ordinal += 1;
404                            let sched_for = pending_scheduled_for.take().unwrap_or(done_at);
405                            let opts = make_opts(&pipeline_name, &execution, &auth, compiled.clock_at(sched_for));
406                            let span = run_span(run_ordinal, sched_for, done_at);
407                            let handle = spawn_run(nodes.clone(), opts, compiled.run_timeout, span);
408                            m::in_flight(&pipeline_name, 1);
409                            m::last_run_started(&pipeline_name, done_at);
410                            m::lateness(&pipeline_name, done_at - sched_for);
411                            tracing::info!(pipeline = %pipeline_name, run_ordinal, scheduled_for = %sched_for, "queued run started");
412                            running = Some(RunningRun { handle, started: Instant::now() });
413                        }
414                    }
415                }
416            }
417
418            _ = tokio::time::sleep(chunk) => { /* re-loop: re-read wall clock */ }
419        }
420    }
421}
422
423/// Await the in-flight run (or never resolve when idle). Returns the classified
424/// outcome; the caller fills in `duration` from the `RunningRun`.
425async fn wait_for_run(running: &mut Option<RunningRun>) -> RunFinished {
426    match running {
427        Some(rr) => classify((&mut rr.handle).await),
428        None => std::future::pending().await,
429    }
430}
431
432/// On shutdown, await the in-flight run up to `grace`, then abort it.
433async fn graceful_shutdown(running: Option<RunningRun>, grace: Duration, pipeline_name: &str) {
434    if let Some(mut rr) = running {
435        match tokio::time::timeout(grace, &mut rr.handle).await {
436            Ok(_) => {
437                tracing::info!(pipeline = %pipeline_name, "in-flight run finished during shutdown grace")
438            }
439            Err(_) => {
440                rr.handle.abort();
441                tracing::warn!(
442                    pipeline = %pipeline_name,
443                    grace_secs = grace.as_secs(),
444                    "in-flight run exceeded shutdown grace; aborted (partial sink state possible; bookmark preserved for the next run)"
445                );
446            }
447        }
448        m::in_flight(pipeline_name, 0);
449    }
450}