use crate::base::{CancelToken, Error, ErrorKind, Instant};
use crate::observe::status::RunStatus;
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct Limits<'r> {
pub(crate) deadline: Option<Instant>,
pub(crate) cancel: Option<&'r CancelToken>,
pub(crate) timers: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Stop {
Cancelled,
TimedOut,
}
impl Stop {
pub(crate) fn status(self) -> RunStatus {
match self {
Self::Cancelled => RunStatus::Cancelled,
Self::TimedOut => RunStatus::TimedOut,
}
}
pub(crate) fn during(self, function: &str) -> Error {
match self {
Self::Cancelled => Error::new(
ErrorKind::Cancelled,
format!("the run was cancelled during `{function}`"),
),
Self::TimedOut => Error::new(
ErrorKind::TimedOut,
format!("the run's deadline passed during `{function}`"),
),
}
}
}
impl Limits<'_> {
pub(crate) fn is_set(&self) -> bool {
self.deadline.is_some() || self.cancel.is_some()
}
pub(crate) fn stop(&self) -> Option<Stop> {
if self.cancel.is_some_and(CancelToken::is_cancelled) {
return Some(Stop::Cancelled);
}
match self.deadline {
Some(deadline) if now() >= deadline => Some(Stop::TimedOut),
_ => None,
}
}
pub(crate) fn check(&self) -> std::result::Result<(), Stop> {
self.stop().map_or(Ok(()), Err)
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) async fn race<F: Future>(
call: F,
limits: Limits<'_>,
) -> std::result::Result<F::Output, Stop> {
let timer = match limits.deadline {
Some(deadline) if limits.timers => Some(deadline),
Some(_) => {
timers::warn_once();
None
}
None => None,
};
if timer.is_none() && limits.cancel.is_none() {
return Ok(call.await);
}
let cancelled = async {
match limits.cancel {
Some(token) => token.cancelled().await,
None => std::future::pending().await,
}
};
let timer = async {
match timer {
Some(deadline) => tokio::time::sleep_until(deadline.into()).await,
None => std::future::pending().await,
}
};
tokio::select! {
biased;
output = call => Ok(output),
() = cancelled => Err(Stop::Cancelled),
() = timer => Err(Stop::TimedOut),
}
}
#[cfg(not(target_arch = "wasm32"))]
mod timers {
use std::cell::Cell;
thread_local! {
static LAST_RUNTIME: Cell<Option<(tokio::runtime::Id, bool)>> = const { Cell::new(None) };
}
pub(super) fn available() -> bool {
let Ok(handle) = tokio::runtime::Handle::try_current() else {
return false;
};
let id = handle.id();
if let Some((last_id, ok)) = LAST_RUNTIME.get()
&& last_id == id
{
return ok;
}
let ok = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
drop(tokio::time::sleep_until(tokio::time::Instant::now()));
}))
.is_ok();
LAST_RUNTIME.set(Some((id, ok)));
ok
}
pub(super) fn warn_once() {
static WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
WARNED.get_or_init(|| {
::log::warn!(
"a run deadline cannot use a timer: no Tokio runtime with \
`enable_time` is current. Limits are checked between ops and \
calls instead, and a call is not cut short mid-flight."
);
});
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn timers_available() -> bool {
timers::available()
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn timers_available() -> bool {
false
}
fn now() -> Instant {
super::record::now()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::run::RunOptions;
use crate::run::tests::clock_reads as reads;
use crate::{Engine, Message};
use serde_json::json;
use std::time::Duration;
#[test]
fn timer_availability_follows_the_current_runtime_on_the_same_thread() {
assert!(!timers_available());
let timed = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap();
let untimed = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
for (runtime, expected) in [(&timed, true), (&untimed, false), (&timed, true)] {
let _entered = runtime.enter();
assert_eq!(timers_available(), expected);
}
assert!(!timers_available());
}
#[test]
fn the_token_is_checked_before_the_deadline_and_neither_when_unset() {
let unset = Limits::default();
assert!(!unset.is_set());
let before = reads();
assert!(unset.check().is_ok());
assert_eq!(reads(), before, "no deadline, no clock");
let token = CancelToken::new();
let passed = Limits {
deadline: Some(now()),
cancel: Some(&token),
timers: false,
};
assert!(passed.is_set());
assert_eq!(passed.stop(), Some(Stop::TimedOut));
token.cancel();
assert_eq!(passed.check(), Err(Stop::Cancelled));
let e = Stop::Cancelled.during("f");
assert_eq!(
(e.code(), e.message()),
("CANCELLED", "the run was cancelled during `f`")
);
assert!(!e.is_retryable());
let far = Limits {
deadline: Some(now() + Duration::from_secs(3600)),
cancel: None,
timers: false,
};
assert!(far.is_set() && far.check().is_ok());
let past = Limits {
deadline: Some(now()),
cancel: None,
timers: false,
};
assert_eq!(past.check(), Err(Stop::TimedOut));
let e = Stop::TimedOut.during("f");
assert_eq!(
(e.code(), e.message()),
("TIMEOUT_ERROR", "the run's deadline passed during `f`")
);
assert!(e.is_retryable());
}
struct Answer(bool);
impl crate::Function for Answer {
type Config = ();
async fn call(&self, _: &mut crate::Ctx<'_>, _: &()) -> crate::Result<crate::Outcome> {
if self.0 {
Ok(crate::Outcome::Ok)
} else {
Err(Error::service("BOOM", "it failed"))
}
}
}
async fn reads_of(ok: bool, opts: &RunOptions<'_>) -> u32 {
let engine = Engine::builder()
.register("answer", Answer(ok))
.workflow_json(&json!({"id": "w", "tasks": [{"id": "a",
"on_failure": "continue", "function": {"name": "answer", "input": {}}}]}))
.build()
.unwrap();
let before = reads();
engine
.run_with(&mut Message::from_json(&json!({})), opts)
.await;
reads() - before
}
#[tokio::test]
async fn only_a_host_calls_err_checks_the_limits_after_it() {
let far = now() + Duration::from_secs(3600);
let opts = RunOptions::default().deadline(far);
let ok = reads_of(true, &opts).await;
assert!(ok >= 1, "the check before the call reads it");
assert_eq!(reads_of(false, &opts).await, ok + 1);
let token = CancelToken::new();
for opts in [RunOptions::default(), RunOptions::default().cancel(&token)] {
assert_eq!(reads_of(true, &opts).await, 0);
assert_eq!(reads_of(false, &opts).await, 0);
}
}
#[tokio::test]
async fn without_a_deadline_or_a_token_no_clock_is_read() {
let maps: Vec<_> = (0..70)
.map(|i| {
json!({"id": format!("m{i}"), "function": {"name": "map", "input": {
"mappings": [{"path": format!("data.m{i}"), "logic": i}]}}})
})
.collect();
let engine = Engine::builder()
.workflow_json(&json!({"id": "w", "loop": {"counter": "i", "max": 3}, "tasks": maps}))
.build()
.unwrap();
let before = reads();
engine.run(&mut Message::from_json(&json!({}))).await;
assert_eq!(reads(), before);
let token = CancelToken::new();
let opts = RunOptions::default().cancel(&token);
engine
.run_with(&mut Message::from_json(&json!({})), &opts)
.await;
assert_eq!(reads(), before);
let far = now() + Duration::from_secs(3600);
let before = reads();
let opts = RunOptions::default().deadline(far);
engine
.run_with(&mut Message::from_json(&json!({})), &opts)
.await;
assert!(reads() > before + 3, "{} reads", reads() - before);
}
#[test]
fn a_deadline_without_a_timer_falls_back_to_the_checks() {
let engine = Engine::builder()
.register("answer", Answer(true))
.workflow_json(&json!({"id": "w", "tasks": [{"id": "a",
"function": {"name": "answer", "input": {}}}]}))
.build()
.unwrap();
let opts = RunOptions::default().deadline(now() + Duration::from_secs(3600));
let run = futures::executor::block_on(
engine.run_with(&mut Message::from_json(&json!({})), &opts),
);
assert!(
matches!(run.status(), crate::run::RunStatus::Completed),
"{:?}",
run.status()
);
}
}