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