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, args.profile.as_deref()).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 #[cfg(feature = "lineage")]
111 let lineage = crate::lineage_glue::build_emitter(cfg.lineage.as_ref())
112 .map_err(|e| CliError::Config(format!("lineage: {e}")))?;
113 #[cfg(feature = "lineage")]
114 let lineage_cfg = cfg.lineage.clone();
115 let nodes = expand(&cfg)?; let execution = cfg.execution.clone();
117
118 if args.once {
119 return run_once(
120 &nodes,
121 &auth,
122 &execution,
123 &compiled,
124 &pipeline_name,
125 #[cfg(feature = "lineage")]
126 &lineage,
127 #[cfg(feature = "lineage")]
128 &lineage_cfg,
129 )
130 .await;
131 }
132
133 run_loop(
134 compiled,
135 nodes,
136 auth,
137 execution,
138 pipeline_name,
139 cron,
140 timezone,
141 #[cfg(feature = "lineage")]
142 lineage,
143 #[cfg(feature = "lineage")]
144 lineage_cfg,
145 )
146 .await
147}
148
149fn make_opts(
152 pipeline_name: &str,
153 execution: &Option<crate::config::ExecutionSpec>,
154 auth: &AuthCatalog,
155 clock: chrono::DateTime<chrono::FixedOffset>,
156 #[cfg(feature = "lineage")] lineage: &Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
157 #[cfg(feature = "lineage")] lineage_cfg: &Option<faucet_lineage::LineageConfig>,
158) -> ExecuteOptions {
159 ExecuteOptions {
160 pipeline_name: pipeline_name.to_string(),
161 execution: execution.clone(),
162 dry_run: false,
163 limit: None,
164 state_path_override: None,
165 auth: auth.clone(),
166 clock,
167 cancel: None,
168 #[cfg(feature = "lineage")]
169 lineage: lineage.clone(),
170 #[cfg(feature = "lineage")]
171 lineage_cfg: lineage_cfg.clone(),
172 }
173}
174
175fn run_span(run_ordinal: u64, scheduled_for: DateTime<Utc>, tick: DateTime<Utc>) -> tracing::Span {
179 tracing::info_span!(
180 "faucet.schedule.run",
181 run_ordinal,
182 scheduled_for_unix_seconds = scheduled_for.timestamp(),
183 tick_unix_seconds = tick.timestamp(),
184 )
185}
186
187fn spawn_run(
190 nodes: Vec<ExpandedNode>,
191 opts: ExecuteOptions,
192 timeout: Option<Duration>,
193 span: tracing::Span,
194) -> JoinHandle<CliResult<RunSummary>> {
195 tokio::spawn(
196 async move {
197 match timeout {
198 Some(d) => match tokio::time::timeout(d, run_expanded(nodes, opts)).await {
199 Ok(r) => r,
200 Err(_) => Err(CliError::Internal(format!(
201 "scheduled run exceeded run_timeout_secs ({}s) and was aborted",
202 d.as_secs()
203 ))),
204 },
205 None => run_expanded(nodes, opts).await,
206 }
207 }
208 .instrument(span),
209 )
210}
211
212fn classify(joined: Result<CliResult<RunSummary>, tokio::task::JoinError>) -> RunFinished {
214 let (outcome, detail) = match joined {
215 Ok(Ok(summary)) if summary.had_failures() => (
216 RunOutcome::Failure,
217 Some(format!("{} invocation(s) failed", summary.failure_count())),
218 ),
219 Ok(Ok(_)) => (RunOutcome::Success, None),
220 Ok(Err(e)) => (RunOutcome::Failure, Some(e.to_string())),
221 Err(je) => (
222 RunOutcome::Failure,
223 Some(format!("run task panicked: {je}")),
224 ),
225 };
226 RunFinished {
227 outcome,
228 duration: Duration::ZERO,
229 detail,
230 }
231}
232
233#[allow(clippy::too_many_arguments)]
235async fn run_once(
236 nodes: &[ExpandedNode],
237 auth: &AuthCatalog,
238 execution: &Option<crate::config::ExecutionSpec>,
239 compiled: &CompiledSchedule,
240 pipeline_name: &str,
241 #[cfg(feature = "lineage")] lineage: &Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
242 #[cfg(feature = "lineage")] lineage_cfg: &Option<faucet_lineage::LineageConfig>,
243) -> CliResult<()> {
244 tracing::info!(pipeline = %pipeline_name, "schedule --once: running one pipeline now");
245 let now = chrono::Utc::now();
246 let opts = make_opts(
247 pipeline_name,
248 execution,
249 auth,
250 compiled.clock_at(now),
251 #[cfg(feature = "lineage")]
252 lineage,
253 #[cfg(feature = "lineage")]
254 lineage_cfg,
255 );
256 let span = run_span(1, now, now);
257 let fut = run_expanded(nodes.to_vec(), opts).instrument(span);
258 let summary = match compiled.run_timeout {
259 Some(d) => tokio::time::timeout(d, fut).await.map_err(|_| {
260 CliError::Internal(format!(
261 "--once run exceeded run_timeout_secs ({}s)",
262 d.as_secs()
263 ))
264 })??,
265 None => fut.await?,
266 };
267 if summary.had_failures() {
268 return Err(CliError::PipelineHadFailures {
269 count: summary.failure_count(),
270 });
271 }
272 Ok(())
273}
274
275#[allow(clippy::too_many_arguments)]
277async fn run_loop(
278 compiled: CompiledSchedule,
279 nodes: Vec<ExpandedNode>,
280 auth: AuthCatalog,
281 execution: Option<crate::config::ExecutionSpec>,
282 pipeline_name: String,
283 cron: String,
284 timezone: String,
285 #[cfg(feature = "lineage")] lineage: Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
286 #[cfg(feature = "lineage")] lineage_cfg: Option<faucet_lineage::LineageConfig>,
287) -> CliResult<()> {
288 let mut state = SchedulerState::new(&compiled);
289 let mut shutdown = Shutdown::new()?;
290 let mut running: Option<RunningRun> = None;
291 let mut pending_scheduled_for: Option<DateTime<Utc>> = None;
292 let mut run_ordinal: u64 = 0;
293
294 let mut next_due = if compiled.start_immediately {
295 Utc::now()
296 } else {
297 compiled
298 .next_after(Utc::now())
299 .ok_or_else(|| CliError::Config("schedule: no upcoming occurrence".into()))?
300 };
301
302 let upcoming: Vec<String> = {
305 let mut t = Utc::now();
306 let mut v = Vec::with_capacity(3);
307 while v.len() < 3 {
308 match compiled.next_after(t) {
309 Some(n) => {
310 v.push(n.to_rfc3339());
311 t = n;
312 }
313 None => break,
314 }
315 }
316 v
317 };
318 tracing::info!(
319 pipeline = %pipeline_name,
320 cron = %cron,
321 timezone = %timezone,
322 next_occurrences = ?upcoming,
323 "scheduler started (Ctrl-C / SIGTERM to stop)"
324 );
325
326 m::describe();
332 m::in_flight(&pipeline_name, 0);
333 m::consecutive_failures(&pipeline_name, 0);
334
335 loop {
336 let now = Utc::now();
337
338 if now >= next_due {
339 match state.on_tick(running.is_some()) {
340 TickAction::Dispatch => {
341 run_ordinal += 1;
342 let opts = make_opts(
343 &pipeline_name,
344 &execution,
345 &auth,
346 compiled.clock_at(next_due),
347 #[cfg(feature = "lineage")]
348 &lineage,
349 #[cfg(feature = "lineage")]
350 &lineage_cfg,
351 );
352 let span = run_span(run_ordinal, next_due, now);
353 let handle = spawn_run(nodes.clone(), opts, compiled.run_timeout, span);
354 m::in_flight(&pipeline_name, 1);
355 m::last_run_started(&pipeline_name, now);
356 m::lateness(&pipeline_name, now - next_due);
357 tracing::info!(pipeline = %pipeline_name, run_ordinal, scheduled_for = %next_due, "run started");
358 running = Some(RunningRun {
359 handle,
360 started: Instant::now(),
361 });
362 }
363 TickAction::Skip => {
364 m::overlap(&pipeline_name, "skip");
365 m::run_outcome(&pipeline_name, "skipped");
366 tracing::warn!(pipeline = %pipeline_name, scheduled_for = %next_due, "tick skipped — previous run still in progress");
367 }
368 TickAction::Queue => {
369 m::overlap(&pipeline_name, "queue");
370 if pending_scheduled_for.is_none() {
371 pending_scheduled_for = Some(next_due);
372 }
373 tracing::warn!(pipeline = %pipeline_name, scheduled_for = %next_due, "tick queued — will run after current run finishes");
374 }
375 TickAction::ForbidAbort => {
376 m::overlap(&pipeline_name, "forbid");
377 m::in_flight(&pipeline_name, 0);
381 return Err(CliError::ScheduleOverlapForbidden);
382 }
383 }
384 next_due = match compiled.next_due_after_tick(next_due, Utc::now()) {
389 Some(t) => t,
390 None => {
391 tracing::info!(pipeline = %pipeline_name, "no further scheduled occurrences; exiting");
392 return Ok(());
393 }
394 };
395 }
396
397 let now2 = Utc::now();
398 m::heartbeat(&pipeline_name, now2);
399 m::next_tick(&pipeline_name, next_due);
400 let chunk = (next_due - now2)
401 .to_std()
402 .unwrap_or(Duration::ZERO)
403 .min(MAX_SLEEP);
404
405 tokio::select! {
406 biased;
407
408 _ = shutdown.recv() => {
409 tracing::info!(pipeline = %pipeline_name, "shutdown signal received; draining in-flight run");
410 graceful_shutdown(running.take(), compiled.shutdown_grace, &pipeline_name).await;
411 return Ok(());
412 }
413
414 finished = wait_for_run(&mut running) => {
415 let mut finished = finished;
416 if let Some(rr) = running.take() {
417 finished.duration = rr.started.elapsed();
418 }
419 m::in_flight(&pipeline_name, 0);
420 let done_at = Utc::now();
421 m::last_run_completed(&pipeline_name, done_at);
422 m::last_run_duration(&pipeline_name, finished.duration);
423 m::run_outcome(&pipeline_name, match finished.outcome {
424 RunOutcome::Success => "ok",
425 RunOutcome::Failure => "err",
426 });
427 match finished.outcome {
428 RunOutcome::Success => tracing::info!(
429 pipeline = %pipeline_name, secs = finished.duration.as_secs_f64(), "run completed"
430 ),
431 RunOutcome::Failure => tracing::error!(
432 pipeline = %pipeline_name, detail = finished.detail.as_deref().unwrap_or("unknown"),
433 "run failed"
434 ),
435 }
436
437 let after = state.on_run_finished(finished.outcome);
438 m::consecutive_failures(&pipeline_name, state.consecutive_failures());
439 match after {
440 AfterRun::ExitOk => {
441 tracing::info!(pipeline = %pipeline_name, "max_runs reached; exiting");
442 return Ok(());
443 }
444 AfterRun::ExitFailure { consecutive } => {
445 return Err(CliError::PipelineHadFailures { count: consecutive as usize });
446 }
447 AfterRun::Continue { dispatch_pending } => {
448 if dispatch_pending {
449 run_ordinal += 1;
450 let sched_for = pending_scheduled_for.take().unwrap_or(done_at);
451 let opts = make_opts(
452 &pipeline_name,
453 &execution,
454 &auth,
455 compiled.clock_at(sched_for),
456 #[cfg(feature = "lineage")]
457 &lineage,
458 #[cfg(feature = "lineage")]
459 &lineage_cfg,
460 );
461 let span = run_span(run_ordinal, sched_for, done_at);
462 let handle = spawn_run(nodes.clone(), opts, compiled.run_timeout, span);
463 m::in_flight(&pipeline_name, 1);
464 m::last_run_started(&pipeline_name, done_at);
465 m::lateness(&pipeline_name, done_at - sched_for);
466 tracing::info!(pipeline = %pipeline_name, run_ordinal, scheduled_for = %sched_for, "queued run started");
467 running = Some(RunningRun { handle, started: Instant::now() });
468 }
469 }
470 }
471 }
472
473 _ = tokio::time::sleep(chunk) => { }
474 }
475 }
476}
477
478async fn wait_for_run(running: &mut Option<RunningRun>) -> RunFinished {
481 match running {
482 Some(rr) => classify((&mut rr.handle).await),
483 None => std::future::pending().await,
484 }
485}
486
487async fn graceful_shutdown(running: Option<RunningRun>, grace: Duration, pipeline_name: &str) {
489 if let Some(mut rr) = running {
490 match tokio::time::timeout(grace, &mut rr.handle).await {
491 Ok(_) => {
492 tracing::info!(pipeline = %pipeline_name, "in-flight run finished during shutdown grace")
493 }
494 Err(_) => {
495 rr.handle.abort();
496 tracing::warn!(
497 pipeline = %pipeline_name,
498 grace_secs = grace.as_secs(),
499 "in-flight run exceeded shutdown grace; aborted (partial sink state possible; bookmark preserved for the next run)"
500 );
501 }
502 }
503 m::in_flight(pipeline_name, 0);
504 }
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use crate::schedule::spec::ScheduleSpec;
511
512 fn compiled(yaml: &str) -> CompiledSchedule {
513 let spec: ScheduleSpec = serde_yaml::from_str(yaml).unwrap();
514 CompiledSchedule::compile(&spec).unwrap()
515 }
516
517 fn summary(failures: usize, total: usize) -> RunSummary {
518 let mut invocations = Vec::new();
519 for i in 0..total {
520 invocations.push(crate::executor::InvocationOutcome {
521 row_id: format!("r{i}"),
522 parent_record_key: None,
523 records_written: if i < failures { 0 } else { 3 },
524 error: if i < failures {
525 Some("boom".into())
526 } else {
527 None
528 },
529 });
530 }
531 RunSummary { invocations }
532 }
533
534 #[test]
535 fn classify_success_when_no_failures() {
536 let joined = Ok(Ok(summary(0, 2)));
537 let f = classify(joined);
538 assert_eq!(f.outcome, RunOutcome::Success);
539 assert!(f.detail.is_none());
540 }
541
542 #[test]
543 fn classify_failure_when_some_invocations_failed() {
544 let joined = Ok(Ok(summary(2, 5)));
545 let f = classify(joined);
546 assert_eq!(f.outcome, RunOutcome::Failure);
547 assert_eq!(f.detail.as_deref(), Some("2 invocation(s) failed"));
548 }
549
550 #[test]
551 fn classify_failure_when_run_errored() {
552 let joined: Result<CliResult<RunSummary>, tokio::task::JoinError> =
553 Ok(Err(CliError::Internal("disk full".into())));
554 let f = classify(joined);
555 assert_eq!(f.outcome, RunOutcome::Failure);
556 assert!(f.detail.as_deref().unwrap().contains("disk full"));
557 }
558
559 #[tokio::test]
560 async fn classify_failure_when_task_panicked() {
561 let handle = tokio::spawn(async { panic!("kaboom") });
563 let joined: Result<CliResult<RunSummary>, tokio::task::JoinError> = handle.await.map(Ok);
564 let f = classify(joined);
565 assert_eq!(f.outcome, RunOutcome::Failure);
566 assert!(
567 f.detail.as_deref().unwrap().contains("panicked"),
568 "{:?}",
569 f.detail
570 );
571 }
572
573 #[test]
574 fn run_span_carries_ordinal_and_times() {
575 let scheduled = Utc::now();
576 let tick = scheduled + chrono::Duration::seconds(3);
577 let span = run_span(7, scheduled, tick);
578 assert_eq!(span.metadata().unwrap().name(), "faucet.schedule.run");
581 }
582
583 #[tokio::test]
584 async fn wait_for_run_returns_classified_outcome() {
585 let handle = tokio::spawn(async { Ok(summary(0, 1)) });
586 let mut running = Some(RunningRun {
587 handle,
588 started: Instant::now(),
589 });
590 let finished = wait_for_run(&mut running).await;
591 assert_eq!(finished.outcome, RunOutcome::Success);
592 }
593
594 #[tokio::test]
595 async fn spawn_run_times_out_into_internal_error() {
596 let dir = tempfile::tempdir().unwrap();
599 let input = dir.path().join("in.csv");
600 let output = dir.path().join("out.jsonl");
601 std::fs::write(&input, "name\nx\n").unwrap();
602 let yaml = format!(
604 "version: 1\npipeline:\n source: {{ type: csv, config: {{ path: {input} }} }}\n sink: {{ type: jsonl, config: {{ path: {output} }} }}\n",
605 input = input.display(),
606 output = output.display(),
607 );
608 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
609 let nodes = expand(&cfg).unwrap();
610 let auth = AuthCatalog::new();
611 let opts = make_opts(
612 "to",
613 &None,
614 &auth,
615 Utc::now().fixed_offset(),
616 #[cfg(feature = "lineage")]
617 &None,
618 #[cfg(feature = "lineage")]
619 &None,
620 );
621 let handle = spawn_run(
624 nodes,
625 opts,
626 Some(Duration::from_nanos(1)),
627 run_span(1, Utc::now(), Utc::now()),
628 );
629 let joined = handle.await.unwrap();
630 if let Err(CliError::Internal(msg)) = &joined {
634 assert!(msg.contains("run_timeout_secs"), "{msg}");
635 }
636 }
637
638 #[tokio::test]
639 async fn make_opts_disables_dry_run_limit_and_state_override() {
640 let auth = AuthCatalog::new();
641 let clock = Utc::now().fixed_offset();
642 let opts = make_opts(
643 "p",
644 &None,
645 &auth,
646 clock,
647 #[cfg(feature = "lineage")]
648 &None,
649 #[cfg(feature = "lineage")]
650 &None,
651 );
652 assert_eq!(opts.pipeline_name, "p");
653 assert!(!opts.dry_run);
654 assert!(opts.limit.is_none());
655 assert!(opts.state_path_override.is_none());
656 assert!(opts.cancel.is_none());
657 assert_eq!(opts.clock, clock);
658 }
659
660 #[tokio::test]
661 async fn graceful_shutdown_awaits_finished_run() {
662 let c = compiled("cron: \"* * * * *\"\nshutdown_grace_secs: 5");
664 let handle = tokio::spawn(async { Ok(summary(0, 1)) });
665 let running = Some(RunningRun {
666 handle,
667 started: Instant::now(),
668 });
669 graceful_shutdown(running, c.shutdown_grace, "p").await;
671 }
672
673 #[tokio::test]
674 async fn graceful_shutdown_aborts_run_exceeding_grace() {
675 let handle = tokio::spawn(async {
677 tokio::time::sleep(Duration::from_secs(3600)).await;
678 Ok(summary(0, 1))
679 });
680 let running = Some(RunningRun {
681 handle,
682 started: Instant::now(),
683 });
684 graceful_shutdown(running, Duration::from_millis(50), "p").await;
686 }
687
688 #[tokio::test]
689 async fn graceful_shutdown_noop_when_idle() {
690 graceful_shutdown(None, Duration::from_secs(1), "p").await;
692 }
693}