use crate::auth_catalog::{AuthCatalog, build_auth_catalog};
use crate::cli::ScheduleArgs;
use crate::config::PipelineConfig;
use crate::error::{CliError, CliResult};
use crate::executor::{ExecuteOptions, RunSummary, run_expanded};
use crate::expand::{ExpandedNode, expand};
use crate::schedule::compiled::CompiledSchedule;
use crate::schedule::metrics as m;
use crate::schedule::state::{AfterRun, RunOutcome, SchedulerState, TickAction};
use chrono::{DateTime, Utc};
use std::time::Duration;
use tokio::task::JoinHandle;
use tokio::time::Instant;
use tracing::Instrument;
struct RunningRun {
handle: JoinHandle<CliResult<RunSummary>>,
started: Instant,
}
struct RunFinished {
outcome: RunOutcome,
duration: Duration,
detail: Option<String>,
cooldown: Option<Duration>,
}
struct Shutdown {
#[cfg(unix)]
sigterm: tokio::signal::unix::Signal,
}
impl Shutdown {
fn new() -> CliResult<Self> {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let sigterm = signal(SignalKind::terminate()).map_err(|e| {
CliError::Internal(format!("failed to install SIGTERM handler: {e}"))
})?;
Ok(Self { sigterm })
}
#[cfg(not(unix))]
{
Ok(Self {})
}
}
async fn recv(&mut self) {
#[cfg(unix)]
{
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = self.sigterm.recv() => {}
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
}
}
}
const MAX_SLEEP: Duration = Duration::from_secs(30);
pub async fn run(args: ScheduleArgs) -> CliResult<()> {
let cwd = std::env::current_dir()?;
let env_path =
crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
let path = match args.config {
Some(p) => p,
None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
};
let cfg = PipelineConfig::from_path_async(&path, args.profile.as_deref()).await?;
let spec = cfg.schedule.as_ref().ok_or_else(|| {
CliError::Config(
"no `schedule:` block in config — use `faucet run` for a one-shot run, or add a `schedule:` block"
.into(),
)
})?;
let compiled = CompiledSchedule::compile(spec)?;
let cron = spec.cron.clone();
let timezone = spec.timezone.clone();
crate::obs::install(&cfg)?;
let pipeline_name = cfg.name.clone().unwrap_or_else(|| {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("pipeline")
.to_owned()
});
let auth = build_auth_catalog(cfg.auth.as_ref())?;
#[cfg(feature = "lineage")]
let lineage = crate::lineage_glue::build_emitter(cfg.lineage.as_ref())
.map_err(|e| CliError::Config(format!("lineage: {e}")))?;
#[cfg(feature = "lineage")]
let lineage_cfg = cfg.lineage.clone();
#[cfg(feature = "notify")]
let notifier = crate::notify::Notifier::from_specs(&cfg.notifications)?;
#[cfg(feature = "catalog")]
let catalog = match cfg.catalog.as_ref() {
Some(spec) => Some(crate::catalog::connect_from_spec(spec).await?),
None => None,
};
let nodes = expand(&cfg)?; let execution = cfg.execution.clone();
let resilience = match &cfg.resilience {
Some(spec) => Some(spec.to_policy()?),
None => None,
};
if args.once {
return run_once(
&nodes,
&auth,
&execution,
&compiled,
&pipeline_name,
&resilience,
&cfg.sla,
#[cfg(feature = "lineage")]
&lineage,
#[cfg(feature = "lineage")]
&lineage_cfg,
#[cfg(feature = "notify")]
¬ifier,
#[cfg(feature = "catalog")]
&catalog,
)
.await;
}
run_loop(
compiled,
nodes,
auth,
execution,
pipeline_name,
cron,
timezone,
resilience,
cfg.sla.clone(),
#[cfg(feature = "lineage")]
lineage,
#[cfg(feature = "lineage")]
lineage_cfg,
#[cfg(feature = "notify")]
notifier,
#[cfg(feature = "catalog")]
catalog,
)
.await
}
#[allow(clippy::too_many_arguments)]
fn make_opts(
pipeline_name: &str,
execution: &Option<crate::config::ExecutionSpec>,
auth: &AuthCatalog,
clock: chrono::DateTime<chrono::FixedOffset>,
resilience: &Option<faucet_core::ResiliencePolicy>,
sla: &Option<crate::sla::SlaSpec>,
#[cfg(feature = "lineage")] lineage: &Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
#[cfg(feature = "lineage")] lineage_cfg: &Option<faucet_lineage::LineageConfig>,
#[cfg(feature = "notify")] notifier: &Option<std::sync::Arc<crate::notify::Notifier>>,
#[cfg(feature = "catalog")] catalog: &Option<crate::catalog::CatalogHandle>,
) -> ExecuteOptions {
ExecuteOptions {
pipeline_name: pipeline_name.to_string(),
execution: execution.clone(),
dry_run: false,
limit: None,
state_path_override: None,
shard: None,
auth: auth.clone(),
clock,
cancel: None,
resilience: resilience.clone(),
sla: sla.clone(),
#[cfg(feature = "lineage")]
lineage: lineage.clone(),
#[cfg(feature = "lineage")]
lineage_cfg: lineage_cfg.clone(),
#[cfg(feature = "notify")]
notifier: notifier.clone(),
#[cfg(feature = "catalog")]
catalog: catalog.clone(),
}
}
fn run_span(run_ordinal: u64, scheduled_for: DateTime<Utc>, tick: DateTime<Utc>) -> tracing::Span {
tracing::info_span!(
"faucet.schedule.run",
run_ordinal,
scheduled_for_unix_seconds = scheduled_for.timestamp(),
tick_unix_seconds = tick.timestamp(),
)
}
fn spawn_run(
nodes: Vec<ExpandedNode>,
opts: ExecuteOptions,
timeout: Option<Duration>,
span: tracing::Span,
) -> JoinHandle<CliResult<RunSummary>> {
tokio::spawn(
async move {
match timeout {
Some(d) => match tokio::time::timeout(d, run_expanded(nodes, opts)).await {
Ok(r) => r,
Err(_) => Err(CliError::Internal(format!(
"scheduled run exceeded run_timeout_secs ({}s) and was aborted",
d.as_secs()
))),
},
None => run_expanded(nodes, opts).await,
}
}
.instrument(span),
)
}
const CIRCUIT_OPEN_PREFIX: &str = "Circuit open after";
fn classify(
joined: Result<CliResult<RunSummary>, tokio::task::JoinError>,
breaker_cooldown: Option<Duration>,
) -> RunFinished {
let circuit_open = match &joined {
Ok(Ok(summary)) => summary
.invocations
.iter()
.filter_map(|i| i.error.as_deref())
.any(|e| e.starts_with(CIRCUIT_OPEN_PREFIX)),
Ok(Err(e)) => e.to_string().contains(CIRCUIT_OPEN_PREFIX),
Err(_) => false,
};
let cooldown = if circuit_open {
let reconstructed: Result<(), faucet_core::FaucetError> =
Err(faucet_core::FaucetError::CircuitOpen {
failures: 0,
cooldown: breaker_cooldown.unwrap_or(Duration::ZERO),
});
crate::schedule::state::cooldown_delay(&reconstructed).filter(|d| !d.is_zero())
} else {
None
};
let (outcome, detail) = match joined {
Ok(Ok(summary)) if summary.had_failures() => (
RunOutcome::Failure,
Some(format!("{} invocation(s) failed", summary.failure_count())),
),
Ok(Ok(_)) => (RunOutcome::Success, None),
Ok(Err(e)) => (RunOutcome::Failure, Some(e.to_string())),
Err(je) => (
RunOutcome::Failure,
Some(format!("run task panicked: {je}")),
),
};
RunFinished {
outcome,
duration: Duration::ZERO,
detail,
cooldown,
}
}
#[allow(clippy::too_many_arguments)]
async fn run_once(
nodes: &[ExpandedNode],
auth: &AuthCatalog,
execution: &Option<crate::config::ExecutionSpec>,
compiled: &CompiledSchedule,
pipeline_name: &str,
resilience: &Option<faucet_core::ResiliencePolicy>,
sla: &Option<crate::sla::SlaSpec>,
#[cfg(feature = "lineage")] lineage: &Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
#[cfg(feature = "lineage")] lineage_cfg: &Option<faucet_lineage::LineageConfig>,
#[cfg(feature = "notify")] notifier: &Option<std::sync::Arc<crate::notify::Notifier>>,
#[cfg(feature = "catalog")] catalog: &Option<crate::catalog::CatalogHandle>,
) -> CliResult<()> {
tracing::info!(pipeline = %pipeline_name, "schedule --once: running one pipeline now");
let now = chrono::Utc::now();
let opts = make_opts(
pipeline_name,
execution,
auth,
compiled.clock_at(now),
resilience,
sla,
#[cfg(feature = "lineage")]
lineage,
#[cfg(feature = "lineage")]
lineage_cfg,
#[cfg(feature = "notify")]
notifier,
#[cfg(feature = "catalog")]
catalog,
);
let span = run_span(1, now, now);
let fut = run_expanded(nodes.to_vec(), opts).instrument(span);
let summary = match compiled.run_timeout {
Some(d) => tokio::time::timeout(d, fut).await.map_err(|_| {
CliError::Internal(format!(
"--once run exceeded run_timeout_secs ({}s)",
d.as_secs()
))
})??,
None => fut.await?,
};
if summary.had_failures() {
return Err(CliError::PipelineHadFailures {
count: summary.failure_count(),
});
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn run_loop(
compiled: CompiledSchedule,
nodes: Vec<ExpandedNode>,
auth: AuthCatalog,
execution: Option<crate::config::ExecutionSpec>,
pipeline_name: String,
cron: String,
timezone: String,
resilience: Option<faucet_core::ResiliencePolicy>,
sla: Option<crate::sla::SlaSpec>,
#[cfg(feature = "lineage")] lineage: Option<std::sync::Arc<faucet_lineage::LineageEmitter>>,
#[cfg(feature = "lineage")] lineage_cfg: Option<faucet_lineage::LineageConfig>,
#[cfg(feature = "notify")] notifier: Option<std::sync::Arc<crate::notify::Notifier>>,
#[cfg(feature = "catalog")] catalog: Option<crate::catalog::CatalogHandle>,
) -> CliResult<()> {
let mut state = SchedulerState::new(&compiled);
let breaker_cooldown = resilience
.as_ref()
.and_then(|r| r.circuit_breaker)
.map(|cb| cb.cooldown);
let mut shutdown = Shutdown::new()?;
let mut running: Option<RunningRun> = None;
let mut pending_scheduled_for: Option<DateTime<Utc>> = None;
let mut run_ordinal: u64 = 0;
let mut next_due = if compiled.start_immediately {
Utc::now()
} else {
compiled
.next_after(Utc::now())
.ok_or_else(|| CliError::Config("schedule: no upcoming occurrence".into()))?
};
let upcoming: Vec<String> = {
let mut t = Utc::now();
let mut v = Vec::with_capacity(3);
while v.len() < 3 {
match compiled.next_after(t) {
Some(n) => {
v.push(n.to_rfc3339());
t = n;
}
None => break,
}
}
v
};
tracing::info!(
pipeline = %pipeline_name,
cron = %cron,
timezone = %timezone,
next_occurrences = ?upcoming,
"scheduler started (Ctrl-C / SIGTERM to stop)"
);
m::describe();
m::in_flight(&pipeline_name, 0);
m::consecutive_failures(&pipeline_name, 0);
loop {
let now = Utc::now();
if now >= next_due {
match state.on_tick(running.is_some()) {
TickAction::Dispatch => {
run_ordinal += 1;
let opts = make_opts(
&pipeline_name,
&execution,
&auth,
compiled.clock_at(next_due),
&resilience,
&sla,
#[cfg(feature = "lineage")]
&lineage,
#[cfg(feature = "lineage")]
&lineage_cfg,
#[cfg(feature = "notify")]
¬ifier,
#[cfg(feature = "catalog")]
&catalog,
);
let span = run_span(run_ordinal, next_due, now);
let handle = spawn_run(nodes.clone(), opts, compiled.run_timeout, span);
m::in_flight(&pipeline_name, 1);
m::last_run_started(&pipeline_name, now);
m::lateness(&pipeline_name, now - next_due);
tracing::info!(pipeline = %pipeline_name, run_ordinal, scheduled_for = %next_due, "run started");
running = Some(RunningRun {
handle,
started: Instant::now(),
});
}
TickAction::Skip => {
m::overlap(&pipeline_name, "skip");
m::run_outcome(&pipeline_name, "skipped");
tracing::warn!(pipeline = %pipeline_name, scheduled_for = %next_due, "tick skipped — previous run still in progress");
}
TickAction::Queue => {
m::overlap(&pipeline_name, "queue");
if pending_scheduled_for.is_none() {
pending_scheduled_for = Some(next_due);
}
tracing::warn!(pipeline = %pipeline_name, scheduled_for = %next_due, "tick queued — will run after current run finishes");
}
TickAction::ForbidAbort => {
m::overlap(&pipeline_name, "forbid");
m::in_flight(&pipeline_name, 0);
return Err(CliError::ScheduleOverlapForbidden);
}
}
next_due = match compiled.next_due_after_tick(next_due, Utc::now()) {
Some(t) => t,
None => {
tracing::info!(pipeline = %pipeline_name, "no further scheduled occurrences; exiting");
return Ok(());
}
};
}
let now2 = Utc::now();
m::heartbeat(&pipeline_name, now2);
m::next_tick(&pipeline_name, next_due);
let chunk = (next_due - now2)
.to_std()
.unwrap_or(Duration::ZERO)
.min(MAX_SLEEP);
tokio::select! {
biased;
_ = shutdown.recv() => {
tracing::info!(pipeline = %pipeline_name, "shutdown signal received; draining in-flight run");
graceful_shutdown(running.take(), compiled.shutdown_grace, &pipeline_name).await;
faucet_core::shutdown_otel();
return Ok(());
}
finished = wait_for_run(&mut running, breaker_cooldown) => {
let mut finished = finished;
if let Some(rr) = running.take() {
finished.duration = rr.started.elapsed();
}
m::in_flight(&pipeline_name, 0);
let done_at = Utc::now();
if let Some(d) = finished.cooldown
&& let Ok(delta) = chrono::Duration::from_std(d)
{
let resume = done_at + delta;
if resume > next_due {
next_due = resume;
}
tracing::warn!(
pipeline = %pipeline_name,
cooldown_secs = d.as_secs(),
next_due = %next_due,
"circuit breaker opened; delaying re-entry by cooldown"
);
}
m::last_run_completed(&pipeline_name, done_at);
m::last_run_duration(&pipeline_name, finished.duration);
m::run_outcome(&pipeline_name, match finished.outcome {
RunOutcome::Success => "ok",
RunOutcome::Failure => "err",
});
match finished.outcome {
RunOutcome::Success => tracing::info!(
pipeline = %pipeline_name, secs = finished.duration.as_secs_f64(), "run completed"
),
RunOutcome::Failure => tracing::error!(
pipeline = %pipeline_name, detail = finished.detail.as_deref().unwrap_or("unknown"),
"run failed"
),
}
let after = state.on_run_finished(finished.outcome);
m::consecutive_failures(&pipeline_name, state.consecutive_failures());
match after {
AfterRun::ExitOk => {
tracing::info!(pipeline = %pipeline_name, "max_runs reached; exiting");
return Ok(());
}
AfterRun::ExitFailure { consecutive } => {
#[cfg(feature = "notify")]
if let Some(n) = ¬ifier {
n.emit(crate::notify::NotifyEvent::scheduler_stuck(
&pipeline_name,
format!(
"scheduler exiting after {consecutive} consecutive failures"
),
))
.await;
}
return Err(CliError::PipelineHadFailures { count: consecutive as usize });
}
AfterRun::Continue { dispatch_pending } => {
if dispatch_pending {
run_ordinal += 1;
let sched_for = pending_scheduled_for.take().unwrap_or(done_at);
let opts = make_opts(
&pipeline_name,
&execution,
&auth,
compiled.clock_at(sched_for),
&resilience,
&sla,
#[cfg(feature = "lineage")]
&lineage,
#[cfg(feature = "lineage")]
&lineage_cfg,
#[cfg(feature = "notify")]
¬ifier,
#[cfg(feature = "catalog")]
&catalog,
);
let span = run_span(run_ordinal, sched_for, done_at);
let handle = spawn_run(nodes.clone(), opts, compiled.run_timeout, span);
m::in_flight(&pipeline_name, 1);
m::last_run_started(&pipeline_name, done_at);
m::lateness(&pipeline_name, done_at - sched_for);
tracing::info!(pipeline = %pipeline_name, run_ordinal, scheduled_for = %sched_for, "queued run started");
running = Some(RunningRun { handle, started: Instant::now() });
}
}
}
}
_ = tokio::time::sleep(chunk) => { }
}
}
}
async fn wait_for_run(
running: &mut Option<RunningRun>,
breaker_cooldown: Option<Duration>,
) -> RunFinished {
match running {
Some(rr) => classify((&mut rr.handle).await, breaker_cooldown),
None => std::future::pending().await,
}
}
async fn graceful_shutdown(running: Option<RunningRun>, grace: Duration, pipeline_name: &str) {
if let Some(mut rr) = running {
match tokio::time::timeout(grace, &mut rr.handle).await {
Ok(_) => {
tracing::info!(pipeline = %pipeline_name, "in-flight run finished during shutdown grace")
}
Err(_) => {
rr.handle.abort();
tracing::warn!(
pipeline = %pipeline_name,
grace_secs = grace.as_secs(),
"in-flight run exceeded shutdown grace; aborted (partial sink state possible; bookmark preserved for the next run)"
);
}
}
m::in_flight(pipeline_name, 0);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schedule::spec::ScheduleSpec;
fn compiled(yaml: &str) -> CompiledSchedule {
let spec: ScheduleSpec = serde_yaml::from_str(yaml).unwrap();
CompiledSchedule::compile(&spec).unwrap()
}
fn summary(failures: usize, total: usize) -> RunSummary {
let mut invocations = Vec::new();
for i in 0..total {
invocations.push(crate::executor::InvocationOutcome {
row_id: format!("r{i}"),
parent_record_key: None,
records_written: if i < failures { 0 } else { 3 },
error: if i < failures {
Some("boom".into())
} else {
None
},
});
}
RunSummary { invocations }
}
#[test]
fn classify_success_when_no_failures() {
let joined = Ok(Ok(summary(0, 2)));
let f = classify(joined, None);
assert_eq!(f.outcome, RunOutcome::Success);
assert!(f.detail.is_none());
assert!(f.cooldown.is_none());
}
#[test]
fn classify_failure_when_some_invocations_failed() {
let joined = Ok(Ok(summary(2, 5)));
let f = classify(joined, None);
assert_eq!(f.outcome, RunOutcome::Failure);
assert_eq!(f.detail.as_deref(), Some("2 invocation(s) failed"));
assert!(f.cooldown.is_none());
}
#[test]
fn classify_failure_when_run_errored() {
let joined: Result<CliResult<RunSummary>, tokio::task::JoinError> =
Ok(Err(CliError::Internal("disk full".into())));
let f = classify(joined, None);
assert_eq!(f.outcome, RunOutcome::Failure);
assert!(f.detail.as_deref().unwrap().contains("disk full"));
}
#[tokio::test]
async fn classify_failure_when_task_panicked() {
let handle = tokio::spawn(async { panic!("kaboom") });
let joined: Result<CliResult<RunSummary>, tokio::task::JoinError> = handle.await.map(Ok);
let f = classify(joined, None);
assert_eq!(f.outcome, RunOutcome::Failure);
assert!(
f.detail.as_deref().unwrap().contains("panicked"),
"{:?}",
f.detail
);
}
#[test]
fn classify_recovers_cooldown_from_circuit_open_invocation() {
let circuit_open_msg = faucet_core::FaucetError::CircuitOpen {
failures: 3,
cooldown: Duration::from_secs(60),
}
.to_string();
let invocations = vec![crate::executor::InvocationOutcome {
row_id: "r0".into(),
parent_record_key: None,
records_written: 0,
error: Some(circuit_open_msg),
}];
let joined = Ok(Ok(RunSummary { invocations }));
let f = classify(joined, Some(Duration::from_secs(45)));
assert_eq!(f.outcome, RunOutcome::Failure);
assert_eq!(f.cooldown, Some(Duration::from_secs(45)));
}
#[test]
fn classify_circuit_open_without_configured_cooldown_yields_none() {
let circuit_open_msg = faucet_core::FaucetError::CircuitOpen {
failures: 1,
cooldown: Duration::from_secs(10),
}
.to_string();
let invocations = vec![crate::executor::InvocationOutcome {
row_id: "r0".into(),
parent_record_key: None,
records_written: 0,
error: Some(circuit_open_msg),
}];
let joined = Ok(Ok(RunSummary { invocations }));
let f = classify(joined, None);
assert_eq!(f.outcome, RunOutcome::Failure);
assert!(f.cooldown.is_none());
}
#[test]
fn classify_no_cooldown_for_ordinary_failure() {
let joined = Ok(Ok(summary(1, 2)));
let f = classify(joined, Some(Duration::from_secs(30)));
assert_eq!(f.outcome, RunOutcome::Failure);
assert!(f.cooldown.is_none());
}
#[test]
fn run_span_carries_ordinal_and_times() {
let scheduled = Utc::now();
let tick = scheduled + chrono::Duration::seconds(3);
let span = run_span(7, scheduled, tick);
assert_eq!(span.metadata().unwrap().name(), "faucet.schedule.run");
}
#[tokio::test]
async fn wait_for_run_returns_classified_outcome() {
let handle = tokio::spawn(async { Ok(summary(0, 1)) });
let mut running = Some(RunningRun {
handle,
started: Instant::now(),
});
let finished = wait_for_run(&mut running, None).await;
assert_eq!(finished.outcome, RunOutcome::Success);
}
#[tokio::test]
async fn spawn_run_times_out_into_internal_error() {
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("in.csv");
let output = dir.path().join("out.jsonl");
std::fs::write(&input, "name\nx\n").unwrap();
let yaml = format!(
"version: 1\npipeline:\n source: {{ type: csv, config: {{ path: {input} }} }}\n sink: {{ type: jsonl, config: {{ path: {output} }} }}\n",
input = input.display(),
output = output.display(),
);
let cfg = crate::config::parse_with_extension(&yaml, "yaml").unwrap();
let nodes = expand(&cfg).unwrap();
let auth = AuthCatalog::new();
let opts = make_opts(
"to",
&None,
&auth,
Utc::now().fixed_offset(),
&None,
&None,
#[cfg(feature = "lineage")]
&None,
#[cfg(feature = "lineage")]
&None,
#[cfg(feature = "notify")]
&None,
#[cfg(feature = "catalog")]
&None,
);
let handle = spawn_run(
nodes,
opts,
Some(Duration::from_nanos(1)),
run_span(1, Utc::now(), Utc::now()),
);
let joined = handle.await.unwrap();
if let Err(CliError::Internal(msg)) = &joined {
assert!(msg.contains("run_timeout_secs"), "{msg}");
}
}
#[tokio::test]
async fn make_opts_disables_dry_run_limit_and_state_override() {
let auth = AuthCatalog::new();
let clock = Utc::now().fixed_offset();
let opts = make_opts(
"p",
&None,
&auth,
clock,
&None,
&None,
#[cfg(feature = "lineage")]
&None,
#[cfg(feature = "lineage")]
&None,
#[cfg(feature = "notify")]
&None,
#[cfg(feature = "catalog")]
&None,
);
assert_eq!(opts.pipeline_name, "p");
assert!(!opts.dry_run);
assert!(opts.limit.is_none());
assert!(opts.state_path_override.is_none());
assert!(opts.cancel.is_none());
assert_eq!(opts.clock, clock);
}
#[tokio::test]
async fn graceful_shutdown_awaits_finished_run() {
let c = compiled("cron: \"* * * * *\"\nshutdown_grace_secs: 5");
let handle = tokio::spawn(async { Ok(summary(0, 1)) });
let running = Some(RunningRun {
handle,
started: Instant::now(),
});
graceful_shutdown(running, c.shutdown_grace, "p").await;
}
#[tokio::test]
async fn graceful_shutdown_aborts_run_exceeding_grace() {
let handle = tokio::spawn(async {
tokio::time::sleep(Duration::from_secs(3600)).await;
Ok(summary(0, 1))
});
let running = Some(RunningRun {
handle,
started: Instant::now(),
});
graceful_shutdown(running, Duration::from_millis(50), "p").await;
}
#[tokio::test]
async fn graceful_shutdown_noop_when_idle() {
graceful_shutdown(None, Duration::from_secs(1), "p").await;
}
}