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
74const MAX_SLEEP: Duration = Duration::from_secs(30);
77
78pub async fn run(args: ScheduleArgs) -> CliResult<()> {
80 let cwd = std::env::current_dir()?;
81 let env_path =
82 crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
83 crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
84 let path = match args.config {
85 Some(p) => p,
86 None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
87 };
88
89 let cfg = PipelineConfig::from_path_async(&path, args.profile.as_deref()).await?;
90 let spec = cfg.schedule.as_ref().ok_or_else(|| {
91 CliError::Config(
92 "no `schedule:` block in config — use `faucet run` for a one-shot run, or add a `schedule:` block"
93 .into(),
94 )
95 })?;
96 let compiled = CompiledSchedule::compile(spec)?;
97 let cron = spec.cron.clone();
98 let timezone = spec.timezone.clone();
99
100 crate::obs::install(&cfg)?;
101
102 let pipeline_name = cfg.name.clone().unwrap_or_else(|| {
103 path.file_stem()
104 .and_then(|s| s.to_str())
105 .unwrap_or("pipeline")
106 .to_owned()
107 });
108
109 let auth = build_auth_catalog(cfg.auth.as_ref())?;
110 #[cfg(feature = "lineage")]
113 let lineage = crate::lineage_glue::build_emitter(cfg.lineage.as_ref())
114 .map_err(|e| CliError::Config(format!("lineage: {e}")))?;
115 #[cfg(feature = "lineage")]
116 let lineage_cfg = cfg.lineage.clone();
117 #[cfg(feature = "notify")]
120 let notifier = crate::notify::Notifier::from_specs(&cfg.notifications)?;
121 #[cfg(feature = "catalog")]
124 let catalog = match cfg.catalog.as_ref() {
125 Some(spec) => Some(crate::catalog::connect_from_spec(spec).await?),
126 None => None,
127 };
128 let nodes = expand(&cfg)?; let execution = cfg.execution.clone();
130 let resilience = match &cfg.resilience {
131 Some(spec) => Some(spec.to_policy()?),
132 None => None,
133 };
134
135 if args.once {
136 return run_once(
137 &nodes,
138 &auth,
139 &execution,
140 &compiled,
141 &pipeline_name,
142 &resilience,
143 &cfg.sla,
144 #[cfg(feature = "lineage")]
145 &lineage,
146 #[cfg(feature = "lineage")]
147 &lineage_cfg,
148 #[cfg(feature = "notify")]
149 ¬ifier,
150 #[cfg(feature = "catalog")]
151 &catalog,
152 )
153 .await;
154 }
155
156 run_loop(
157 compiled,
158 nodes,
159 auth,
160 execution,
161 pipeline_name,
162 cron,
163 timezone,
164 resilience,
165 cfg.sla.clone(),
166 #[cfg(feature = "lineage")]
167 lineage,
168 #[cfg(feature = "lineage")]
169 lineage_cfg,
170 #[cfg(feature = "notify")]
171 notifier,
172 #[cfg(feature = "catalog")]
173 catalog,
174 )
175 .await
176}
177
178#[allow(clippy::too_many_arguments)]
181fn make_opts(
182 pipeline_name: &str,
183 execution: &Option<crate::config::ExecutionSpec>,
184 auth: &AuthCatalog,
185 clock: chrono::DateTime<chrono::FixedOffset>,
186 resilience: &Option<faucet_core::ResiliencePolicy>,
187 sla: &Option<crate::sla::SlaSpec>,
188 #[cfg(feature = "lineage")] lineage: &Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
189 #[cfg(feature = "lineage")] lineage_cfg: &Option<faucet_lineage::LineageConfig>,
190 #[cfg(feature = "notify")] notifier: &Option<std::sync::Arc<crate::notify::Notifier>>,
191 #[cfg(feature = "catalog")] catalog: &Option<crate::catalog::CatalogHandle>,
192) -> ExecuteOptions {
193 ExecuteOptions {
194 pipeline_name: pipeline_name.to_string(),
195 execution: execution.clone(),
196 dry_run: false,
197 limit: None,
198 state_path_override: None,
199 shard: None,
200 auth: auth.clone(),
201 clock,
202 cancel: None,
203 resilience: resilience.clone(),
204 sla: sla.clone(),
205 #[cfg(feature = "lineage")]
206 lineage: lineage.clone(),
207 #[cfg(feature = "lineage")]
208 lineage_cfg: lineage_cfg.clone(),
209 #[cfg(feature = "notify")]
210 notifier: notifier.clone(),
211 #[cfg(feature = "catalog")]
212 catalog: catalog.clone(),
213 }
214}
215
216fn run_span(run_ordinal: u64, scheduled_for: DateTime<Utc>, tick: DateTime<Utc>) -> tracing::Span {
220 tracing::info_span!(
221 "faucet.schedule.run",
222 run_ordinal,
223 scheduled_for_unix_seconds = scheduled_for.timestamp(),
224 tick_unix_seconds = tick.timestamp(),
225 )
226}
227
228fn spawn_run(
231 nodes: Vec<ExpandedNode>,
232 opts: ExecuteOptions,
233 timeout: Option<Duration>,
234 span: tracing::Span,
235) -> JoinHandle<CliResult<RunSummary>> {
236 tokio::spawn(
237 async move {
238 match timeout {
239 Some(d) => match tokio::time::timeout(d, run_expanded(nodes, opts)).await {
240 Ok(r) => r,
241 Err(_) => Err(CliError::Internal(format!(
242 "scheduled run exceeded run_timeout_secs ({}s) and was aborted",
243 d.as_secs()
244 ))),
245 },
246 None => run_expanded(nodes, opts).await,
247 }
248 }
249 .instrument(span),
250 )
251}
252
253const CIRCUIT_OPEN_PREFIX: &str = "Circuit open after";
259
260fn classify(
264 joined: Result<CliResult<RunSummary>, tokio::task::JoinError>,
265 breaker_cooldown: Option<Duration>,
266) -> RunFinished {
267 let circuit_open = match &joined {
269 Ok(Ok(summary)) => summary
270 .invocations
271 .iter()
272 .filter_map(|i| i.error.as_deref())
273 .any(|e| e.starts_with(CIRCUIT_OPEN_PREFIX)),
274 Ok(Err(e)) => e.to_string().contains(CIRCUIT_OPEN_PREFIX),
275 Err(_) => false,
276 };
277 let cooldown = if circuit_open {
280 let reconstructed: Result<(), faucet_core::FaucetError> =
281 Err(faucet_core::FaucetError::CircuitOpen {
282 failures: 0,
283 cooldown: breaker_cooldown.unwrap_or(Duration::ZERO),
284 });
285 crate::schedule::state::cooldown_delay(&reconstructed).filter(|d| !d.is_zero())
286 } else {
287 None
288 };
289
290 let (outcome, detail) = match joined {
291 Ok(Ok(summary)) if summary.had_failures() => (
292 RunOutcome::Failure,
293 Some(format!("{} invocation(s) failed", summary.failure_count())),
294 ),
295 Ok(Ok(_)) => (RunOutcome::Success, None),
296 Ok(Err(e)) => (RunOutcome::Failure, Some(e.to_string())),
297 Err(je) => (
298 RunOutcome::Failure,
299 Some(format!("run task panicked: {je}")),
300 ),
301 };
302 RunFinished {
303 outcome,
304 duration: Duration::ZERO,
305 detail,
306 cooldown,
307 }
308}
309
310#[allow(clippy::too_many_arguments)]
312async fn run_once(
313 nodes: &[ExpandedNode],
314 auth: &AuthCatalog,
315 execution: &Option<crate::config::ExecutionSpec>,
316 compiled: &CompiledSchedule,
317 pipeline_name: &str,
318 resilience: &Option<faucet_core::ResiliencePolicy>,
319 sla: &Option<crate::sla::SlaSpec>,
320 #[cfg(feature = "lineage")] lineage: &Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
321 #[cfg(feature = "lineage")] lineage_cfg: &Option<faucet_lineage::LineageConfig>,
322 #[cfg(feature = "notify")] notifier: &Option<std::sync::Arc<crate::notify::Notifier>>,
323 #[cfg(feature = "catalog")] catalog: &Option<crate::catalog::CatalogHandle>,
324) -> CliResult<()> {
325 tracing::info!(pipeline = %pipeline_name, "schedule --once: running one pipeline now");
326 let now = chrono::Utc::now();
327 let opts = make_opts(
328 pipeline_name,
329 execution,
330 auth,
331 compiled.clock_at(now),
332 resilience,
333 sla,
334 #[cfg(feature = "lineage")]
335 lineage,
336 #[cfg(feature = "lineage")]
337 lineage_cfg,
338 #[cfg(feature = "notify")]
339 notifier,
340 #[cfg(feature = "catalog")]
341 catalog,
342 );
343 let span = run_span(1, now, now);
344 let fut = run_expanded(nodes.to_vec(), opts).instrument(span);
345 let summary = match compiled.run_timeout {
346 Some(d) => tokio::time::timeout(d, fut).await.map_err(|_| {
347 CliError::Internal(format!(
348 "--once run exceeded run_timeout_secs ({}s)",
349 d.as_secs()
350 ))
351 })??,
352 None => fut.await?,
353 };
354 if summary.had_failures() {
355 return Err(CliError::PipelineHadFailures {
356 count: summary.failure_count(),
357 });
358 }
359 Ok(())
360}
361
362#[allow(clippy::too_many_arguments)]
364async fn run_loop(
365 compiled: CompiledSchedule,
366 nodes: Vec<ExpandedNode>,
367 auth: AuthCatalog,
368 execution: Option<crate::config::ExecutionSpec>,
369 pipeline_name: String,
370 cron: String,
371 timezone: String,
372 resilience: Option<faucet_core::ResiliencePolicy>,
373 sla: Option<crate::sla::SlaSpec>,
374 #[cfg(feature = "lineage")] lineage: Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
375 #[cfg(feature = "lineage")] lineage_cfg: Option<faucet_lineage::LineageConfig>,
376 #[cfg(feature = "notify")] notifier: Option<std::sync::Arc<crate::notify::Notifier>>,
377 #[cfg(feature = "catalog")] catalog: Option<crate::catalog::CatalogHandle>,
378) -> CliResult<()> {
379 let mut state = SchedulerState::new(&compiled);
380 let breaker_cooldown = resilience
383 .as_ref()
384 .and_then(|r| r.circuit_breaker)
385 .map(|cb| cb.cooldown);
386 let mut shutdown = Shutdown::new()?;
387 let mut running: Option<RunningRun> = None;
388 let mut pending_scheduled_for: Option<DateTime<Utc>> = None;
389 let mut run_ordinal: u64 = 0;
390
391 let mut next_due = if compiled.start_immediately {
392 Utc::now()
393 } else {
394 compiled
395 .next_after(Utc::now())
396 .ok_or_else(|| CliError::Config("schedule: no upcoming occurrence".into()))?
397 };
398
399 let upcoming: Vec<String> = {
402 let mut t = Utc::now();
403 let mut v = Vec::with_capacity(3);
404 while v.len() < 3 {
405 match compiled.next_after(t) {
406 Some(n) => {
407 v.push(n.to_rfc3339());
408 t = n;
409 }
410 None => break,
411 }
412 }
413 v
414 };
415 tracing::info!(
416 pipeline = %pipeline_name,
417 cron = %cron,
418 timezone = %timezone,
419 next_occurrences = ?upcoming,
420 "scheduler started (Ctrl-C / SIGTERM to stop)"
421 );
422
423 m::describe();
429 m::in_flight(&pipeline_name, 0);
430 m::consecutive_failures(&pipeline_name, 0);
431
432 loop {
433 let now = Utc::now();
434
435 if now >= next_due {
436 match state.on_tick(running.is_some()) {
437 TickAction::Dispatch => {
438 run_ordinal += 1;
439 let opts = make_opts(
440 &pipeline_name,
441 &execution,
442 &auth,
443 compiled.clock_at(next_due),
444 &resilience,
445 &sla,
446 #[cfg(feature = "lineage")]
447 &lineage,
448 #[cfg(feature = "lineage")]
449 &lineage_cfg,
450 #[cfg(feature = "notify")]
451 ¬ifier,
452 #[cfg(feature = "catalog")]
453 &catalog,
454 );
455 let span = run_span(run_ordinal, next_due, now);
456 let handle = spawn_run(nodes.clone(), opts, compiled.run_timeout, span);
457 m::in_flight(&pipeline_name, 1);
458 m::last_run_started(&pipeline_name, now);
459 m::lateness(&pipeline_name, now - next_due);
460 tracing::info!(pipeline = %pipeline_name, run_ordinal, scheduled_for = %next_due, "run started");
461 running = Some(RunningRun {
462 handle,
463 started: Instant::now(),
464 });
465 }
466 TickAction::Skip => {
467 m::overlap(&pipeline_name, "skip");
468 m::run_outcome(&pipeline_name, "skipped");
469 tracing::warn!(pipeline = %pipeline_name, scheduled_for = %next_due, "tick skipped — previous run still in progress");
470 }
471 TickAction::Queue => {
472 m::overlap(&pipeline_name, "queue");
473 if pending_scheduled_for.is_none() {
474 pending_scheduled_for = Some(next_due);
475 }
476 tracing::warn!(pipeline = %pipeline_name, scheduled_for = %next_due, "tick queued — will run after current run finishes");
477 }
478 TickAction::ForbidAbort => {
479 m::overlap(&pipeline_name, "forbid");
480 m::in_flight(&pipeline_name, 0);
484 return Err(CliError::ScheduleOverlapForbidden);
485 }
486 }
487 next_due = match compiled.next_due_after_tick(next_due, Utc::now()) {
492 Some(t) => t,
493 None => {
494 tracing::info!(pipeline = %pipeline_name, "no further scheduled occurrences; exiting");
495 return Ok(());
496 }
497 };
498 }
499
500 let now2 = Utc::now();
501 m::heartbeat(&pipeline_name, now2);
502 m::next_tick(&pipeline_name, next_due);
503 let chunk = (next_due - now2)
504 .to_std()
505 .unwrap_or(Duration::ZERO)
506 .min(MAX_SLEEP);
507
508 tokio::select! {
509 biased;
510
511 _ = shutdown.recv() => {
512 tracing::info!(pipeline = %pipeline_name, "shutdown signal received; draining in-flight run");
513 graceful_shutdown(running.take(), compiled.shutdown_grace, &pipeline_name).await;
514 faucet_core::shutdown_otel();
517 return Ok(());
518 }
519
520 finished = wait_for_run(&mut running, breaker_cooldown) => {
521 let mut finished = finished;
522 if let Some(rr) = running.take() {
523 finished.duration = rr.started.elapsed();
524 }
525 m::in_flight(&pipeline_name, 0);
526 let done_at = Utc::now();
527
528 if let Some(d) = finished.cooldown
536 && let Ok(delta) = chrono::Duration::from_std(d)
537 {
538 let resume = done_at + delta;
539 if resume > next_due {
540 next_due = resume;
541 }
542 tracing::warn!(
543 pipeline = %pipeline_name,
544 cooldown_secs = d.as_secs(),
545 next_due = %next_due,
546 "circuit breaker opened; delaying re-entry by cooldown"
547 );
548 }
549 m::last_run_completed(&pipeline_name, done_at);
550 m::last_run_duration(&pipeline_name, finished.duration);
551 m::run_outcome(&pipeline_name, match finished.outcome {
552 RunOutcome::Success => "ok",
553 RunOutcome::Failure => "err",
554 });
555 match finished.outcome {
556 RunOutcome::Success => tracing::info!(
557 pipeline = %pipeline_name, secs = finished.duration.as_secs_f64(), "run completed"
558 ),
559 RunOutcome::Failure => tracing::error!(
560 pipeline = %pipeline_name, detail = finished.detail.as_deref().unwrap_or("unknown"),
561 "run failed"
562 ),
563 }
564
565 let after = state.on_run_finished(finished.outcome);
566 m::consecutive_failures(&pipeline_name, state.consecutive_failures());
567 match after {
568 AfterRun::ExitOk => {
569 tracing::info!(pipeline = %pipeline_name, "max_runs reached; exiting");
570 return Ok(());
571 }
572 AfterRun::ExitFailure { consecutive } => {
573 #[cfg(feature = "notify")]
574 if let Some(n) = ¬ifier {
575 n.emit(crate::notify::NotifyEvent::scheduler_stuck(
576 &pipeline_name,
577 format!(
578 "scheduler exiting after {consecutive} consecutive failures"
579 ),
580 ))
581 .await;
582 }
583 return Err(CliError::PipelineHadFailures { count: consecutive as usize });
584 }
585 AfterRun::Continue { dispatch_pending } => {
586 if dispatch_pending {
587 run_ordinal += 1;
588 let sched_for = pending_scheduled_for.take().unwrap_or(done_at);
589 let opts = make_opts(
590 &pipeline_name,
591 &execution,
592 &auth,
593 compiled.clock_at(sched_for),
594 &resilience,
595 &sla,
596 #[cfg(feature = "lineage")]
597 &lineage,
598 #[cfg(feature = "lineage")]
599 &lineage_cfg,
600 #[cfg(feature = "notify")]
601 ¬ifier,
602 #[cfg(feature = "catalog")]
603 &catalog,
604 );
605 let span = run_span(run_ordinal, sched_for, done_at);
606 let handle = spawn_run(nodes.clone(), opts, compiled.run_timeout, span);
607 m::in_flight(&pipeline_name, 1);
608 m::last_run_started(&pipeline_name, done_at);
609 m::lateness(&pipeline_name, done_at - sched_for);
610 tracing::info!(pipeline = %pipeline_name, run_ordinal, scheduled_for = %sched_for, "queued run started");
611 running = Some(RunningRun { handle, started: Instant::now() });
612 }
613 }
614 }
615 }
616
617 _ = tokio::time::sleep(chunk) => { }
618 }
619 }
620}
621
622async fn wait_for_run(
625 running: &mut Option<RunningRun>,
626 breaker_cooldown: Option<Duration>,
627) -> RunFinished {
628 match running {
629 Some(rr) => classify((&mut rr.handle).await, breaker_cooldown),
630 None => std::future::pending().await,
631 }
632}
633
634async fn graceful_shutdown(running: Option<RunningRun>, grace: Duration, pipeline_name: &str) {
636 if let Some(mut rr) = running {
637 match tokio::time::timeout(grace, &mut rr.handle).await {
638 Ok(_) => {
639 tracing::info!(pipeline = %pipeline_name, "in-flight run finished during shutdown grace")
640 }
641 Err(_) => {
642 rr.handle.abort();
643 tracing::warn!(
644 pipeline = %pipeline_name,
645 grace_secs = grace.as_secs(),
646 "in-flight run exceeded shutdown grace; aborted (partial sink state possible; bookmark preserved for the next run)"
647 );
648 }
649 }
650 m::in_flight(pipeline_name, 0);
651 }
652}
653
654#[cfg(test)]
655mod tests {
656 use super::*;
657 use crate::schedule::spec::ScheduleSpec;
658
659 fn compiled(yaml: &str) -> CompiledSchedule {
660 let spec: ScheduleSpec = serde_yaml::from_str(yaml).unwrap();
661 CompiledSchedule::compile(&spec).unwrap()
662 }
663
664 fn summary(failures: usize, total: usize) -> RunSummary {
665 let mut invocations = Vec::new();
666 for i in 0..total {
667 invocations.push(crate::executor::InvocationOutcome {
668 row_id: format!("r{i}"),
669 parent_record_key: None,
670 records_written: if i < failures { 0 } else { 3 },
671 error: if i < failures {
672 Some("boom".into())
673 } else {
674 None
675 },
676 });
677 }
678 RunSummary { invocations }
679 }
680
681 #[test]
682 fn classify_success_when_no_failures() {
683 let joined = Ok(Ok(summary(0, 2)));
684 let f = classify(joined, None);
685 assert_eq!(f.outcome, RunOutcome::Success);
686 assert!(f.detail.is_none());
687 assert!(f.cooldown.is_none());
688 }
689
690 #[test]
691 fn classify_failure_when_some_invocations_failed() {
692 let joined = Ok(Ok(summary(2, 5)));
693 let f = classify(joined, None);
694 assert_eq!(f.outcome, RunOutcome::Failure);
695 assert_eq!(f.detail.as_deref(), Some("2 invocation(s) failed"));
696 assert!(f.cooldown.is_none());
697 }
698
699 #[test]
700 fn classify_failure_when_run_errored() {
701 let joined: Result<CliResult<RunSummary>, tokio::task::JoinError> =
702 Ok(Err(CliError::Internal("disk full".into())));
703 let f = classify(joined, None);
704 assert_eq!(f.outcome, RunOutcome::Failure);
705 assert!(f.detail.as_deref().unwrap().contains("disk full"));
706 }
707
708 #[tokio::test]
709 async fn classify_failure_when_task_panicked() {
710 let handle = tokio::spawn(async { panic!("kaboom") });
712 let joined: Result<CliResult<RunSummary>, tokio::task::JoinError> = handle.await.map(Ok);
713 let f = classify(joined, None);
714 assert_eq!(f.outcome, RunOutcome::Failure);
715 assert!(
716 f.detail.as_deref().unwrap().contains("panicked"),
717 "{:?}",
718 f.detail
719 );
720 }
721
722 #[test]
723 fn classify_recovers_cooldown_from_circuit_open_invocation() {
724 let circuit_open_msg = faucet_core::FaucetError::CircuitOpen {
727 failures: 3,
728 cooldown: Duration::from_secs(60),
729 }
730 .to_string();
731 let invocations = vec![crate::executor::InvocationOutcome {
732 row_id: "r0".into(),
733 parent_record_key: None,
734 records_written: 0,
735 error: Some(circuit_open_msg),
736 }];
737 let joined = Ok(Ok(RunSummary { invocations }));
738 let f = classify(joined, Some(Duration::from_secs(45)));
739 assert_eq!(f.outcome, RunOutcome::Failure);
740 assert_eq!(f.cooldown, Some(Duration::from_secs(45)));
741 }
742
743 #[test]
744 fn classify_circuit_open_without_configured_cooldown_yields_none() {
745 let circuit_open_msg = faucet_core::FaucetError::CircuitOpen {
748 failures: 1,
749 cooldown: Duration::from_secs(10),
750 }
751 .to_string();
752 let invocations = vec![crate::executor::InvocationOutcome {
753 row_id: "r0".into(),
754 parent_record_key: None,
755 records_written: 0,
756 error: Some(circuit_open_msg),
757 }];
758 let joined = Ok(Ok(RunSummary { invocations }));
759 let f = classify(joined, None);
760 assert_eq!(f.outcome, RunOutcome::Failure);
761 assert!(f.cooldown.is_none());
762 }
763
764 #[test]
765 fn classify_no_cooldown_for_ordinary_failure() {
766 let joined = Ok(Ok(summary(1, 2)));
769 let f = classify(joined, Some(Duration::from_secs(30)));
770 assert_eq!(f.outcome, RunOutcome::Failure);
771 assert!(f.cooldown.is_none());
772 }
773
774 #[test]
775 fn run_span_carries_ordinal_and_times() {
776 let scheduled = Utc::now();
777 let tick = scheduled + chrono::Duration::seconds(3);
778 let span = run_span(7, scheduled, tick);
779 assert_eq!(span.metadata().unwrap().name(), "faucet.schedule.run");
782 }
783
784 #[tokio::test]
785 async fn wait_for_run_returns_classified_outcome() {
786 let handle = tokio::spawn(async { Ok(summary(0, 1)) });
787 let mut running = Some(RunningRun {
788 handle,
789 started: Instant::now(),
790 });
791 let finished = wait_for_run(&mut running, None).await;
792 assert_eq!(finished.outcome, RunOutcome::Success);
793 }
794
795 #[tokio::test]
796 async fn spawn_run_times_out_into_internal_error() {
797 let dir = tempfile::tempdir().unwrap();
800 let input = dir.path().join("in.csv");
801 let output = dir.path().join("out.jsonl");
802 std::fs::write(&input, "name\nx\n").unwrap();
803 let yaml = format!(
805 "version: 1\npipeline:\n source: {{ type: csv, config: {{ path: {input} }} }}\n sink: {{ type: jsonl, config: {{ path: {output} }} }}\n",
806 input = input.display(),
807 output = output.display(),
808 );
809 let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
810 let nodes = expand(&cfg).unwrap();
811 let auth = AuthCatalog::new();
812 let opts = make_opts(
813 "to",
814 &None,
815 &auth,
816 Utc::now().fixed_offset(),
817 &None,
818 &None,
819 #[cfg(feature = "lineage")]
820 &None,
821 #[cfg(feature = "lineage")]
822 &None,
823 #[cfg(feature = "notify")]
824 &None,
825 #[cfg(feature = "catalog")]
826 &None,
827 );
828 let handle = spawn_run(
831 nodes,
832 opts,
833 Some(Duration::from_nanos(1)),
834 run_span(1, Utc::now(), Utc::now()),
835 );
836 let joined = handle.await.unwrap();
837 if let Err(CliError::Internal(msg)) = &joined {
841 assert!(msg.contains("run_timeout_secs"), "{msg}");
842 }
843 }
844
845 #[tokio::test]
846 async fn make_opts_disables_dry_run_limit_and_state_override() {
847 let auth = AuthCatalog::new();
848 let clock = Utc::now().fixed_offset();
849 let opts = make_opts(
850 "p",
851 &None,
852 &auth,
853 clock,
854 &None,
855 &None,
856 #[cfg(feature = "lineage")]
857 &None,
858 #[cfg(feature = "lineage")]
859 &None,
860 #[cfg(feature = "notify")]
861 &None,
862 #[cfg(feature = "catalog")]
863 &None,
864 );
865 assert_eq!(opts.pipeline_name, "p");
866 assert!(!opts.dry_run);
867 assert!(opts.limit.is_none());
868 assert!(opts.state_path_override.is_none());
869 assert!(opts.cancel.is_none());
870 assert_eq!(opts.clock, clock);
871 }
872
873 #[tokio::test]
874 async fn graceful_shutdown_awaits_finished_run() {
875 let c = compiled("cron: \"* * * * *\"\nshutdown_grace_secs: 5");
877 let handle = tokio::spawn(async { Ok(summary(0, 1)) });
878 let running = Some(RunningRun {
879 handle,
880 started: Instant::now(),
881 });
882 graceful_shutdown(running, c.shutdown_grace, "p").await;
884 }
885
886 #[tokio::test]
887 async fn graceful_shutdown_aborts_run_exceeding_grace() {
888 let handle = tokio::spawn(async {
890 tokio::time::sleep(Duration::from_secs(3600)).await;
891 Ok(summary(0, 1))
892 });
893 let running = Some(RunningRun {
894 handle,
895 started: Instant::now(),
896 });
897 graceful_shutdown(running, Duration::from_millis(50), "p").await;
899 }
900
901 #[tokio::test]
902 async fn graceful_shutdown_noop_when_idle() {
903 graceful_shutdown(None, Duration::from_secs(1), "p").await;
905 }
906}