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 cooldown: Option<Duration>,
34}
35
36struct 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 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
74struct 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 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
110struct ReloadedBundle {
115 compiled: CompiledSchedule,
116 nodes: Vec<ExpandedNode>,
117 execution: Option<crate::config::ExecutionSpec>,
118 resilience: Option<faucet_core::ResiliencePolicy>,
119 sla: Option<crate::sla::SlaSpec>,
120 reconcile: Option<crate::reconcile::ReconcileSpec>,
121 cron: String,
122 timezone: String,
123}
124
125async fn reload_bundle(path: &std::path::Path, profile: Option<&str>) -> CliResult<ReloadedBundle> {
129 let cfg = PipelineConfig::from_path_async(path, profile).await?;
130 let spec = cfg.schedule.as_ref().ok_or_else(|| {
131 CliError::Config("reload: config no longer has a `schedule:` block".into())
132 })?;
133 let compiled = CompiledSchedule::compile(spec)?;
134 let cron = spec.cron.clone();
135 let timezone = spec.timezone.clone();
136 let nodes = expand(&cfg)?;
137 let resilience = match &cfg.resilience {
138 Some(spec) => Some(spec.to_policy()?),
139 None => None,
140 };
141 Ok(ReloadedBundle {
142 compiled,
143 nodes,
144 execution: cfg.execution.clone(),
145 resilience,
146 sla: cfg.sla.clone(),
147 reconcile: cfg.reconcile.clone(),
148 cron,
149 timezone,
150 })
151}
152
153const MAX_SLEEP: Duration = Duration::from_secs(30);
156
157pub async fn run(args: ScheduleArgs) -> CliResult<()> {
159 let cwd = std::env::current_dir()?;
160 let env_path =
161 crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
162 crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
163 let path = match args.config {
164 Some(p) => p,
165 None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
166 };
167
168 let cfg = PipelineConfig::from_path_async(&path, args.profile.as_deref()).await?;
169 let spec = cfg.schedule.as_ref().ok_or_else(|| {
170 CliError::Config(
171 "no `schedule:` block in config — use `faucet run` for a one-shot run, or add a `schedule:` block"
172 .into(),
173 )
174 })?;
175 let compiled = CompiledSchedule::compile(spec)?;
176 let cron = spec.cron.clone();
177 let timezone = spec.timezone.clone();
178
179 crate::obs::install(&cfg)?;
180
181 let pipeline_name = cfg.name.clone().unwrap_or_else(|| {
182 path.file_stem()
183 .and_then(|s| s.to_str())
184 .unwrap_or("pipeline")
185 .to_owned()
186 });
187
188 let auth = build_auth_catalog(cfg.auth.as_ref())?;
189 #[cfg(feature = "lineage")]
192 let lineage = crate::lineage_glue::build_emitter(cfg.lineage.as_ref())
193 .map_err(|e| CliError::Config(format!("lineage: {e}")))?;
194 #[cfg(feature = "lineage")]
195 let lineage_cfg = cfg.lineage.clone();
196 #[cfg(feature = "notify")]
199 let notifier = crate::notify::Notifier::from_specs(&cfg.notifications)?;
200 #[cfg(feature = "catalog")]
203 let catalog = match cfg.catalog.as_ref() {
204 Some(spec) => Some(crate::catalog::connect_from_spec(spec).await?),
205 None => None,
206 };
207 let nodes = expand(&cfg)?; let execution = cfg.execution.clone();
209 let resilience = match &cfg.resilience {
210 Some(spec) => Some(spec.to_policy()?),
211 None => None,
212 };
213
214 if args.once {
215 return run_once(
216 &nodes,
217 &auth,
218 &execution,
219 &compiled,
220 &pipeline_name,
221 &resilience,
222 &cfg.sla,
223 &cfg.reconcile,
224 #[cfg(feature = "lineage")]
225 &lineage,
226 #[cfg(feature = "lineage")]
227 &lineage_cfg,
228 #[cfg(feature = "notify")]
229 ¬ifier,
230 #[cfg(feature = "catalog")]
231 &catalog,
232 )
233 .await;
234 }
235
236 run_loop(
237 compiled,
238 nodes,
239 auth,
240 execution,
241 pipeline_name,
242 cron,
243 timezone,
244 resilience,
245 cfg.sla.clone(),
246 cfg.reconcile.clone(),
247 path,
248 args.profile,
249 #[cfg(feature = "lineage")]
250 lineage,
251 #[cfg(feature = "lineage")]
252 lineage_cfg,
253 #[cfg(feature = "notify")]
254 notifier,
255 #[cfg(feature = "catalog")]
256 catalog,
257 )
258 .await
259}
260
261#[allow(clippy::too_many_arguments)]
264fn make_opts(
265 pipeline_name: &str,
266 execution: &Option<crate::config::ExecutionSpec>,
267 auth: &AuthCatalog,
268 clock: chrono::DateTime<chrono::FixedOffset>,
269 resilience: &Option<faucet_core::ResiliencePolicy>,
270 sla: &Option<crate::sla::SlaSpec>,
271 reconcile: &Option<crate::reconcile::ReconcileSpec>,
272 #[cfg(feature = "lineage")] lineage: &Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
273 #[cfg(feature = "lineage")] lineage_cfg: &Option<faucet_lineage::LineageConfig>,
274 #[cfg(feature = "notify")] notifier: &Option<std::sync::Arc<crate::notify::Notifier>>,
275 #[cfg(feature = "catalog")] catalog: &Option<crate::catalog::CatalogHandle>,
276) -> ExecuteOptions {
277 ExecuteOptions {
278 pipeline_name: pipeline_name.to_string(),
279 run_id: None,
280 execution: execution.clone(),
281 dry_run: false,
282 limit: None,
283 state_path_override: None,
284 shard: None,
285 auth: auth.clone(),
286 clock,
287 cancel: None,
288 resilience: resilience.clone(),
289 sla: sla.clone(),
290 reconcile: reconcile.clone(),
291 #[cfg(feature = "lineage")]
292 lineage: lineage.clone(),
293 #[cfg(feature = "lineage")]
294 lineage_cfg: lineage_cfg.clone(),
295 #[cfg(feature = "notify")]
296 notifier: notifier.clone(),
297 #[cfg(feature = "catalog")]
298 catalog: catalog.clone(),
299 }
300}
301
302fn run_span(run_ordinal: u64, scheduled_for: DateTime<Utc>, tick: DateTime<Utc>) -> tracing::Span {
306 tracing::info_span!(
307 "faucet.schedule.run",
308 run_ordinal,
309 scheduled_for_unix_seconds = scheduled_for.timestamp(),
310 tick_unix_seconds = tick.timestamp(),
311 )
312}
313
314fn spawn_run(
317 nodes: Vec<ExpandedNode>,
318 opts: ExecuteOptions,
319 timeout: Option<Duration>,
320 span: tracing::Span,
321) -> JoinHandle<CliResult<RunSummary>> {
322 tokio::spawn(
323 async move {
324 match timeout {
325 Some(d) => match tokio::time::timeout(d, run_expanded(nodes, opts)).await {
326 Ok(r) => r,
327 Err(_) => Err(CliError::Internal(format!(
328 "scheduled run exceeded run_timeout_secs ({}s) and was aborted",
329 d.as_secs()
330 ))),
331 },
332 None => run_expanded(nodes, opts).await,
333 }
334 }
335 .instrument(span),
336 )
337}
338
339const CIRCUIT_OPEN_PREFIX: &str = "Circuit open after";
345
346fn classify(
350 joined: Result<CliResult<RunSummary>, tokio::task::JoinError>,
351 breaker_cooldown: Option<Duration>,
352) -> RunFinished {
353 let circuit_open = match &joined {
355 Ok(Ok(summary)) => summary
356 .invocations
357 .iter()
358 .filter_map(|i| i.error.as_deref())
359 .any(|e| e.starts_with(CIRCUIT_OPEN_PREFIX)),
360 Ok(Err(e)) => e.to_string().contains(CIRCUIT_OPEN_PREFIX),
361 Err(_) => false,
362 };
363 let cooldown = if circuit_open {
366 let reconstructed: Result<(), faucet_core::FaucetError> =
367 Err(faucet_core::FaucetError::CircuitOpen {
368 failures: 0,
369 cooldown: breaker_cooldown.unwrap_or(Duration::ZERO),
370 });
371 crate::schedule::state::cooldown_delay(&reconstructed).filter(|d| !d.is_zero())
372 } else {
373 None
374 };
375
376 let (outcome, detail) = match joined {
377 Ok(Ok(summary)) if summary.had_failures() => (
378 RunOutcome::Failure,
379 Some(format!("{} invocation(s) failed", summary.failure_count())),
380 ),
381 Ok(Ok(_)) => (RunOutcome::Success, None),
382 Ok(Err(e)) => (RunOutcome::Failure, Some(e.to_string())),
383 Err(je) => (
384 RunOutcome::Failure,
385 Some(format!("run task panicked: {je}")),
386 ),
387 };
388 RunFinished {
389 outcome,
390 duration: Duration::ZERO,
391 detail,
392 cooldown,
393 }
394}
395
396#[allow(clippy::too_many_arguments)]
398async fn run_once(
399 nodes: &[ExpandedNode],
400 auth: &AuthCatalog,
401 execution: &Option<crate::config::ExecutionSpec>,
402 compiled: &CompiledSchedule,
403 pipeline_name: &str,
404 resilience: &Option<faucet_core::ResiliencePolicy>,
405 sla: &Option<crate::sla::SlaSpec>,
406 reconcile: &Option<crate::reconcile::ReconcileSpec>,
407 #[cfg(feature = "lineage")] lineage: &Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
408 #[cfg(feature = "lineage")] lineage_cfg: &Option<faucet_lineage::LineageConfig>,
409 #[cfg(feature = "notify")] notifier: &Option<std::sync::Arc<crate::notify::Notifier>>,
410 #[cfg(feature = "catalog")] catalog: &Option<crate::catalog::CatalogHandle>,
411) -> CliResult<()> {
412 tracing::info!(pipeline = %pipeline_name, "schedule --once: running one pipeline now");
413 let now = chrono::Utc::now();
414 let opts = make_opts(
415 pipeline_name,
416 execution,
417 auth,
418 compiled.clock_at(now),
419 resilience,
420 sla,
421 reconcile,
422 #[cfg(feature = "lineage")]
423 lineage,
424 #[cfg(feature = "lineage")]
425 lineage_cfg,
426 #[cfg(feature = "notify")]
427 notifier,
428 #[cfg(feature = "catalog")]
429 catalog,
430 );
431 let span = run_span(1, now, now);
432 let fut = run_expanded(nodes.to_vec(), opts).instrument(span);
433 let summary = match compiled.run_timeout {
434 Some(d) => tokio::time::timeout(d, fut).await.map_err(|_| {
435 CliError::Internal(format!(
436 "--once run exceeded run_timeout_secs ({}s)",
437 d.as_secs()
438 ))
439 })??,
440 None => fut.await?,
441 };
442 if summary.had_failures() {
443 return Err(CliError::PipelineHadFailures {
444 count: summary.failure_count(),
445 });
446 }
447 #[cfg(feature = "catalog")]
450 crate::catalog::snapshot::record_if_ok(
451 catalog.as_ref(),
452 pipeline_name,
453 crate::catalog::snapshot::on_error_str(execution),
454 nodes,
455 true,
456 chrono::Utc::now(),
457 )
458 .await;
459 Ok(())
460}
461
462#[allow(clippy::too_many_arguments)]
464async fn run_loop(
465 mut compiled: CompiledSchedule,
466 mut nodes: Vec<ExpandedNode>,
467 auth: AuthCatalog,
468 mut execution: Option<crate::config::ExecutionSpec>,
469 pipeline_name: String,
470 mut cron: String,
471 mut timezone: String,
472 mut resilience: Option<faucet_core::ResiliencePolicy>,
473 mut sla: Option<crate::sla::SlaSpec>,
474 mut reconcile: Option<crate::reconcile::ReconcileSpec>,
475 path: std::path::PathBuf,
476 profile: Option<String>,
477 #[cfg(feature = "lineage")] lineage: Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
478 #[cfg(feature = "lineage")] lineage_cfg: Option<faucet_lineage::LineageConfig>,
479 #[cfg(feature = "notify")] notifier: Option<std::sync::Arc<crate::notify::Notifier>>,
480 #[cfg(feature = "catalog")] catalog: Option<crate::catalog::CatalogHandle>,
481) -> CliResult<()> {
482 let mut state = SchedulerState::new(&compiled);
483 let mut breaker_cooldown = resilience
486 .as_ref()
487 .and_then(|r| r.circuit_breaker)
488 .map(|cb| cb.cooldown);
489 let mut shutdown = Shutdown::new()?;
490 let mut reload = Reload::new()?;
491 let mut running: Option<RunningRun> = None;
492 let mut pending_scheduled_for: Option<DateTime<Utc>> = None;
493 let mut run_ordinal: u64 = 0;
494
495 let mut next_due = if compiled.start_immediately {
496 Utc::now()
497 } else {
498 compiled
499 .next_after(Utc::now())
500 .ok_or_else(|| CliError::Config("schedule: no upcoming occurrence".into()))?
501 };
502
503 let upcoming: Vec<String> = {
506 let mut t = Utc::now();
507 let mut v = Vec::with_capacity(3);
508 while v.len() < 3 {
509 match compiled.next_after(t) {
510 Some(n) => {
511 v.push(n.to_rfc3339());
512 t = n;
513 }
514 None => break,
515 }
516 }
517 v
518 };
519 tracing::info!(
520 pipeline = %pipeline_name,
521 cron = %cron,
522 timezone = %timezone,
523 next_occurrences = ?upcoming,
524 "scheduler started (Ctrl-C / SIGTERM to stop)"
525 );
526
527 m::describe();
533 m::in_flight(&pipeline_name, 0);
534 m::consecutive_failures(&pipeline_name, 0);
535
536 loop {
537 let now = Utc::now();
538
539 if now >= next_due {
540 match state.on_tick(running.is_some()) {
541 TickAction::Dispatch => {
542 run_ordinal += 1;
543 let opts = make_opts(
544 &pipeline_name,
545 &execution,
546 &auth,
547 compiled.clock_at(next_due),
548 &resilience,
549 &sla,
550 &reconcile,
551 #[cfg(feature = "lineage")]
552 &lineage,
553 #[cfg(feature = "lineage")]
554 &lineage_cfg,
555 #[cfg(feature = "notify")]
556 ¬ifier,
557 #[cfg(feature = "catalog")]
558 &catalog,
559 );
560 let span = run_span(run_ordinal, next_due, now);
561 let handle = spawn_run(nodes.clone(), opts, compiled.run_timeout, span);
562 m::in_flight(&pipeline_name, 1);
563 m::last_run_started(&pipeline_name, now);
564 m::lateness(&pipeline_name, now - next_due);
565 tracing::info!(pipeline = %pipeline_name, run_ordinal, scheduled_for = %next_due, "run started");
566 running = Some(RunningRun {
567 handle,
568 started: Instant::now(),
569 });
570 }
571 TickAction::Skip => {
572 m::overlap(&pipeline_name, "skip");
573 m::run_outcome(&pipeline_name, "skipped");
574 tracing::warn!(pipeline = %pipeline_name, scheduled_for = %next_due, "tick skipped — previous run still in progress");
575 }
576 TickAction::Queue => {
577 m::overlap(&pipeline_name, "queue");
578 if pending_scheduled_for.is_none() {
579 pending_scheduled_for = Some(next_due);
580 }
581 tracing::warn!(pipeline = %pipeline_name, scheduled_for = %next_due, "tick queued — will run after current run finishes");
582 }
583 TickAction::ForbidAbort => {
584 m::overlap(&pipeline_name, "forbid");
585 m::in_flight(&pipeline_name, 0);
589 return Err(CliError::ScheduleOverlapForbidden);
590 }
591 }
592 next_due = match compiled.next_due_after_tick(next_due, Utc::now()) {
597 Some(t) => t,
598 None => {
599 tracing::info!(pipeline = %pipeline_name, "no further scheduled occurrences; exiting");
600 return Ok(());
601 }
602 };
603 }
604
605 let now2 = Utc::now();
606 m::heartbeat(&pipeline_name, now2);
607 m::next_tick(&pipeline_name, next_due);
608 let chunk = (next_due - now2)
609 .to_std()
610 .unwrap_or(Duration::ZERO)
611 .min(MAX_SLEEP);
612
613 tokio::select! {
614 biased;
615
616 _ = shutdown.recv() => {
617 tracing::info!(pipeline = %pipeline_name, "shutdown signal received; draining in-flight run");
618 graceful_shutdown(running.take(), compiled.shutdown_grace, &pipeline_name).await;
619 faucet_core::shutdown_otel();
622 return Ok(());
623 }
624
625 finished = wait_for_run(&mut running, breaker_cooldown) => {
626 let mut finished = finished;
627 if let Some(rr) = running.take() {
628 finished.duration = rr.started.elapsed();
629 }
630 m::in_flight(&pipeline_name, 0);
631 let done_at = Utc::now();
632
633 if let Some(d) = finished.cooldown
641 && let Ok(delta) = chrono::Duration::from_std(d)
642 {
643 let resume = done_at + delta;
644 if resume > next_due {
645 next_due = resume;
646 }
647 tracing::warn!(
648 pipeline = %pipeline_name,
649 cooldown_secs = d.as_secs(),
650 next_due = %next_due,
651 "circuit breaker opened; delaying re-entry by cooldown"
652 );
653 }
654 m::last_run_completed(&pipeline_name, done_at);
655 m::last_run_duration(&pipeline_name, finished.duration);
656 m::run_outcome(&pipeline_name, match finished.outcome {
657 RunOutcome::Success => "ok",
658 RunOutcome::Failure => "err",
659 });
660 match finished.outcome {
661 RunOutcome::Success => tracing::info!(
662 pipeline = %pipeline_name, secs = finished.duration.as_secs_f64(), "run completed"
663 ),
664 RunOutcome::Failure => tracing::error!(
665 pipeline = %pipeline_name, detail = finished.detail.as_deref().unwrap_or("unknown"),
666 "run failed"
667 ),
668 }
669
670 let after = state.on_run_finished(finished.outcome);
671 m::consecutive_failures(&pipeline_name, state.consecutive_failures());
672 match after {
673 AfterRun::ExitOk => {
674 tracing::info!(pipeline = %pipeline_name, "max_runs reached; exiting");
675 return Ok(());
676 }
677 AfterRun::ExitFailure { consecutive } => {
678 #[cfg(feature = "notify")]
679 if let Some(n) = ¬ifier {
680 n.emit(crate::notify::NotifyEvent::scheduler_stuck(
681 &pipeline_name,
682 format!(
683 "scheduler exiting after {consecutive} consecutive failures"
684 ),
685 ))
686 .await;
687 }
688 return Err(CliError::PipelineHadFailures { count: consecutive as usize });
689 }
690 AfterRun::Continue { dispatch_pending } => {
691 if dispatch_pending {
692 run_ordinal += 1;
693 let sched_for = pending_scheduled_for.take().unwrap_or(done_at);
694 let opts = make_opts(
695 &pipeline_name,
696 &execution,
697 &auth,
698 compiled.clock_at(sched_for),
699 &resilience,
700 &sla,
701 &reconcile,
702 #[cfg(feature = "lineage")]
703 &lineage,
704 #[cfg(feature = "lineage")]
705 &lineage_cfg,
706 #[cfg(feature = "notify")]
707 ¬ifier,
708 #[cfg(feature = "catalog")]
709 &catalog,
710 );
711 let span = run_span(run_ordinal, sched_for, done_at);
712 let handle = spawn_run(nodes.clone(), opts, compiled.run_timeout, span);
713 m::in_flight(&pipeline_name, 1);
714 m::last_run_started(&pipeline_name, done_at);
715 m::lateness(&pipeline_name, done_at - sched_for);
716 tracing::info!(pipeline = %pipeline_name, run_ordinal, scheduled_for = %sched_for, "queued run started");
717 running = Some(RunningRun { handle, started: Instant::now() });
718 }
719 }
720 }
721 }
722
723 _ = reload.recv() => {
724 match reload_bundle(&path, profile.as_deref()).await {
729 Ok(b) => {
730 compiled = b.compiled;
731 nodes = b.nodes;
732 execution = b.execution;
733 resilience = b.resilience;
734 sla = b.sla;
735 reconcile = b.reconcile;
736 cron = b.cron;
737 timezone = b.timezone;
738 breaker_cooldown = resilience
739 .as_ref()
740 .and_then(|r| r.circuit_breaker)
741 .map(|cb| cb.cooldown);
742 next_due = if compiled.start_immediately {
743 Utc::now()
744 } else {
745 compiled.next_after(Utc::now()).unwrap_or(next_due)
746 };
747 m::reload(&pipeline_name, "ok");
748 tracing::info!(
749 pipeline = %pipeline_name, cron = %cron, timezone = %timezone,
750 next_due = %next_due, "config reloaded (SIGHUP)"
751 );
752 }
753 Err(e) => {
754 m::reload(&pipeline_name, "error");
755 tracing::error!(
756 pipeline = %pipeline_name, error = %e,
757 "config reload failed; keeping the previous config"
758 );
759 }
760 }
761 }
762
763 _ = tokio::time::sleep(chunk) => { }
764 }
765 }
766}
767
768async fn wait_for_run(
771 running: &mut Option<RunningRun>,
772 breaker_cooldown: Option<Duration>,
773) -> RunFinished {
774 match running {
775 Some(rr) => classify((&mut rr.handle).await, breaker_cooldown),
776 None => std::future::pending().await,
777 }
778}
779
780async fn graceful_shutdown(running: Option<RunningRun>, grace: Duration, pipeline_name: &str) {
782 if let Some(mut rr) = running {
783 match tokio::time::timeout(grace, &mut rr.handle).await {
784 Ok(_) => {
785 tracing::info!(pipeline = %pipeline_name, "in-flight run finished during shutdown grace")
786 }
787 Err(_) => {
788 rr.handle.abort();
789 tracing::warn!(
790 pipeline = %pipeline_name,
791 grace_secs = grace.as_secs(),
792 "in-flight run exceeded shutdown grace; aborted (partial sink state possible; bookmark preserved for the next run)"
793 );
794 }
795 }
796 m::in_flight(pipeline_name, 0);
797 }
798}
799
800#[cfg(test)]
801mod tests {
802 use super::*;
803 use crate::schedule::spec::ScheduleSpec;
804
805 fn compiled(yaml: &str) -> CompiledSchedule {
806 let spec: ScheduleSpec = serde_yaml::from_str(yaml).unwrap();
807 CompiledSchedule::compile(&spec).unwrap()
808 }
809
810 #[tokio::test]
814 async fn reload_bundle_validates_and_builds() {
815 let dir = tempfile::tempdir().unwrap();
816 let good = dir.path().join("good.yaml");
817 std::fs::write(
818 &good,
819 "version: 1\nname: sch\npipeline:\n source:\n type: csv\n config:\n path: in.csv\n sink:\n type: jsonl\n config:\n path: out.jsonl\nschedule:\n cron: \"*/5 * * * *\"\n timezone: UTC\n",
820 )
821 .unwrap();
822 let b = reload_bundle(&good, None)
823 .await
824 .expect("valid config reloads");
825 assert_eq!(b.cron, "*/5 * * * *");
826 assert_eq!(b.timezone, "UTC");
827 assert_eq!(b.nodes.len(), 1);
828
829 let bad = dir.path().join("bad.yaml");
831 std::fs::write(
832 &bad,
833 "version: 1\npipeline:\n source:\n type: csv\n config:\n path: in.csv\n sink:\n type: jsonl\n config:\n path: out.jsonl\n",
834 )
835 .unwrap();
836 assert!(reload_bundle(&bad, None).await.is_err());
837 }
838
839 fn summary(failures: usize, total: usize) -> RunSummary {
840 let mut invocations = Vec::new();
841 for i in 0..total {
842 invocations.push(crate::executor::InvocationOutcome {
843 row_id: format!("r{i}"),
844 parent_record_key: None,
845 records_written: if i < failures { 0 } else { 3 },
846 error: if i < failures {
847 Some("boom".into())
848 } else {
849 None
850 },
851 metrics: None,
852 });
853 }
854 RunSummary { invocations }
855 }
856
857 #[test]
858 fn classify_success_when_no_failures() {
859 let joined = Ok(Ok(summary(0, 2)));
860 let f = classify(joined, None);
861 assert_eq!(f.outcome, RunOutcome::Success);
862 assert!(f.detail.is_none());
863 assert!(f.cooldown.is_none());
864 }
865
866 #[test]
867 fn classify_failure_when_some_invocations_failed() {
868 let joined = Ok(Ok(summary(2, 5)));
869 let f = classify(joined, None);
870 assert_eq!(f.outcome, RunOutcome::Failure);
871 assert_eq!(f.detail.as_deref(), Some("2 invocation(s) failed"));
872 assert!(f.cooldown.is_none());
873 }
874
875 #[test]
876 fn classify_failure_when_run_errored() {
877 let joined: Result<CliResult<RunSummary>, tokio::task::JoinError> =
878 Ok(Err(CliError::Internal("disk full".into())));
879 let f = classify(joined, None);
880 assert_eq!(f.outcome, RunOutcome::Failure);
881 assert!(f.detail.as_deref().unwrap().contains("disk full"));
882 }
883
884 #[tokio::test]
885 async fn classify_failure_when_task_panicked() {
886 let handle = tokio::spawn(async { panic!("kaboom") });
888 let joined: Result<CliResult<RunSummary>, tokio::task::JoinError> = handle.await.map(Ok);
889 let f = classify(joined, None);
890 assert_eq!(f.outcome, RunOutcome::Failure);
891 assert!(
892 f.detail.as_deref().unwrap().contains("panicked"),
893 "{:?}",
894 f.detail
895 );
896 }
897
898 #[test]
899 fn classify_recovers_cooldown_from_circuit_open_invocation() {
900 let circuit_open_msg = faucet_core::FaucetError::CircuitOpen {
903 failures: 3,
904 cooldown: Duration::from_secs(60),
905 }
906 .to_string();
907 let invocations = vec![crate::executor::InvocationOutcome {
908 row_id: "r0".into(),
909 parent_record_key: None,
910 records_written: 0,
911 error: Some(circuit_open_msg),
912 metrics: None,
913 }];
914 let joined = Ok(Ok(RunSummary { invocations }));
915 let f = classify(joined, Some(Duration::from_secs(45)));
916 assert_eq!(f.outcome, RunOutcome::Failure);
917 assert_eq!(f.cooldown, Some(Duration::from_secs(45)));
918 }
919
920 #[test]
921 fn classify_circuit_open_without_configured_cooldown_yields_none() {
922 let circuit_open_msg = faucet_core::FaucetError::CircuitOpen {
925 failures: 1,
926 cooldown: Duration::from_secs(10),
927 }
928 .to_string();
929 let invocations = vec![crate::executor::InvocationOutcome {
930 row_id: "r0".into(),
931 parent_record_key: None,
932 records_written: 0,
933 error: Some(circuit_open_msg),
934 metrics: None,
935 }];
936 let joined = Ok(Ok(RunSummary { invocations }));
937 let f = classify(joined, None);
938 assert_eq!(f.outcome, RunOutcome::Failure);
939 assert!(f.cooldown.is_none());
940 }
941
942 #[test]
943 fn classify_no_cooldown_for_ordinary_failure() {
944 let joined = Ok(Ok(summary(1, 2)));
947 let f = classify(joined, Some(Duration::from_secs(30)));
948 assert_eq!(f.outcome, RunOutcome::Failure);
949 assert!(f.cooldown.is_none());
950 }
951
952 #[test]
953 fn run_span_carries_ordinal_and_times() {
954 let scheduled = Utc::now();
955 let tick = scheduled + chrono::Duration::seconds(3);
956 let span = run_span(7, scheduled, tick);
957 assert_eq!(span.metadata().unwrap().name(), "faucet.schedule.run");
960 }
961
962 #[tokio::test]
963 async fn wait_for_run_returns_classified_outcome() {
964 let handle = tokio::spawn(async { Ok(summary(0, 1)) });
965 let mut running = Some(RunningRun {
966 handle,
967 started: Instant::now(),
968 });
969 let finished = wait_for_run(&mut running, None).await;
970 assert_eq!(finished.outcome, RunOutcome::Success);
971 }
972
973 #[tokio::test]
974 async fn spawn_run_times_out_into_internal_error() {
975 let dir = tempfile::tempdir().unwrap();
978 let input = dir.path().join("in.csv");
979 let output = dir.path().join("out.jsonl");
980 std::fs::write(&input, "name\nx\n").unwrap();
981 let yaml = format!(
983 "version: 1\npipeline:\n source: {{ type: csv, config: {{ path: {input} }} }}\n sink: {{ type: jsonl, config: {{ path: {output} }} }}\n",
984 input = input.display(),
985 output = output.display(),
986 );
987 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
988 let nodes = expand(&cfg).unwrap();
989 let auth = AuthCatalog::new();
990 let opts = make_opts(
991 "to",
992 &None,
993 &auth,
994 Utc::now().fixed_offset(),
995 &None,
996 &None,
997 &None,
998 #[cfg(feature = "lineage")]
999 &None,
1000 #[cfg(feature = "lineage")]
1001 &None,
1002 #[cfg(feature = "notify")]
1003 &None,
1004 #[cfg(feature = "catalog")]
1005 &None,
1006 );
1007 let handle = spawn_run(
1010 nodes,
1011 opts,
1012 Some(Duration::from_nanos(1)),
1013 run_span(1, Utc::now(), Utc::now()),
1014 );
1015 let joined = handle.await.unwrap();
1016 if let Err(CliError::Internal(msg)) = &joined {
1020 assert!(msg.contains("run_timeout_secs"), "{msg}");
1021 }
1022 }
1023
1024 #[tokio::test]
1025 async fn make_opts_disables_dry_run_limit_and_state_override() {
1026 let auth = AuthCatalog::new();
1027 let clock = Utc::now().fixed_offset();
1028 let opts = make_opts(
1029 "p",
1030 &None,
1031 &auth,
1032 clock,
1033 &None,
1034 &None,
1035 &None,
1036 #[cfg(feature = "lineage")]
1037 &None,
1038 #[cfg(feature = "lineage")]
1039 &None,
1040 #[cfg(feature = "notify")]
1041 &None,
1042 #[cfg(feature = "catalog")]
1043 &None,
1044 );
1045 assert_eq!(opts.pipeline_name, "p");
1046 assert!(!opts.dry_run);
1047 assert!(opts.limit.is_none());
1048 assert!(opts.state_path_override.is_none());
1049 assert!(opts.cancel.is_none());
1050 assert_eq!(opts.clock, clock);
1051 }
1052
1053 #[tokio::test]
1054 async fn graceful_shutdown_awaits_finished_run() {
1055 let c = compiled("cron: \"* * * * *\"\nshutdown_grace_secs: 5");
1057 let handle = tokio::spawn(async { Ok(summary(0, 1)) });
1058 let running = Some(RunningRun {
1059 handle,
1060 started: Instant::now(),
1061 });
1062 graceful_shutdown(running, c.shutdown_grace, "p").await;
1064 }
1065
1066 #[tokio::test]
1067 async fn graceful_shutdown_aborts_run_exceeding_grace() {
1068 let handle = tokio::spawn(async {
1070 tokio::time::sleep(Duration::from_secs(3600)).await;
1071 Ok(summary(0, 1))
1072 });
1073 let running = Some(RunningRun {
1074 handle,
1075 started: Instant::now(),
1076 });
1077 graceful_shutdown(running, Duration::from_millis(50), "p").await;
1079 }
1080
1081 #[tokio::test]
1082 async fn graceful_shutdown_noop_when_idle() {
1083 graceful_shutdown(None, Duration::from_secs(1), "p").await;
1085 }
1086}