1use 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
21struct RunningRun {
23 handle: JoinHandle<CliResult<RunSummary>>,
24 started: Instant,
25}
26
27struct RunFinished {
29 outcome: RunOutcome,
30 duration: Duration,
31 detail: Option<String>,
32}
33
34struct 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 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
72const MAX_SLEEP: Duration = Duration::from_secs(30);
75
76pub 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)?; 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
127fn 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
147fn 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
159fn 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
184fn 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
205async 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#[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 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 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 m::in_flight(&pipeline_name, 0);
335 return Err(CliError::ScheduleOverlapForbidden);
336 }
337 }
338 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) => { }
419 }
420 }
421}
422
423async 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
432async 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}