use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use serde::Serialize;
use serde::de::DeserializeOwned;
use tracing::Instrument;
use tokio_util::sync::CancellationToken;
use crate::checkpoint::{PendingStep, StepDurability, StepPlacement, revive};
use crate::context::{Ctx, StepStatus};
use crate::error::{DurableError, EngineOnly, Error, Result};
use crate::serialization::{decode, encode};
use crate::sysdb::types::{AwaitedOutcome, Outcome, StepTiming, Timestamp};
pub type ShouldRetry<E> = Arc<dyn Fn(&Error<E>) -> bool + Send + Sync>;
pub struct StepOptions<E = EngineOnly> {
pub max_attempts: u32,
pub interval: Duration,
pub backoff_rate: f64,
pub max_interval: Duration,
pub timeout: Option<Duration>,
pub preemptible: bool,
pub should_retry: Option<ShouldRetry<E>>,
}
impl<E> Default for StepOptions<E> {
fn default() -> Self {
Self {
max_attempts: 1,
interval: Duration::from_secs(1),
backoff_rate: 2.0,
max_interval: Duration::from_secs(3600),
timeout: None,
preemptible: false,
should_retry: None,
}
}
}
impl<E> std::fmt::Debug for StepOptions<E> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StepOptions")
.field("max_attempts", &self.max_attempts)
.field("interval", &self.interval)
.field("backoff_rate", &self.backoff_rate)
.field("max_interval", &self.max_interval)
.field("timeout", &self.timeout)
.field("preemptible", &self.preemptible)
.field("should_retry", &self.should_retry.is_some())
.finish()
}
}
impl<E> Clone for StepOptions<E> {
fn clone(&self) -> Self {
Self {
max_attempts: self.max_attempts,
interval: self.interval,
backoff_rate: self.backoff_rate,
max_interval: self.max_interval,
timeout: self.timeout,
preemptible: self.preemptible,
should_retry: self.should_retry.clone(),
}
}
}
impl<E> StepOptions<E> {
pub fn should_retry(
mut self,
predicate: impl Fn(&Error<E>) -> bool + Send + Sync + 'static,
) -> Self {
self.should_retry = Some(Arc::new(predicate));
self
}
fn backoff(&self, failures: u32) -> Duration {
let grown = self.interval.as_secs_f64() * self.backoff_rate.powi(failures as i32);
if !grown.is_finite() || grown < 0.0 || grown >= self.max_interval.as_secs_f64() {
return self.max_interval;
}
Duration::from_secs_f64(grown)
}
}
pub fn step<'a, T, E, F, Fut>(name: &str, body: F) -> PendingStep<'a, T, E>
where
T: Serialize + DeserializeOwned + Send + 'a,
E: DurableError + Send,
F: FnMut() -> Fut + Send + 'a,
Fut: Future<Output = Result<T, E>> + Send + 'a,
{
step_with(name, StepOptions::default(), body)
}
pub fn step_with<'a, T, E, F, Fut>(
name: &str,
options: StepOptions<E>,
body: F,
) -> PendingStep<'a, T, E>
where
T: Serialize + DeserializeOwned + Send + 'a,
E: DurableError + Send,
F: FnMut() -> Fut + Send + 'a,
Fut: Future<Output = Result<T, E>> + Send + 'a,
{
let placement = StepPlacement::here();
let name: Arc<str> = Arc::from(name);
PendingStep::new(
Arc::clone(&name),
placement.clone(),
run(placement, name, options, body),
)
}
async fn run<T, E, F, Fut>(
placement: StepPlacement,
name: Arc<str>,
options: StepOptions<E>,
mut body: F,
) -> Result<T, E>
where
T: Serialize + DeserializeOwned,
E: DurableError,
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, E>>,
{
let name = &*name;
let (ctx, step_id) = match Ctx::with_current(|here| placement.check_here(name, here))? {
StepDurability::Recorded { ctx, step_id } => (ctx, step_id),
StepDurability::Plain => {
tracing::debug!(
step_name = name,
built = %placement.whereabouts(),
"the step body runs plainly"
);
return body().await;
}
};
let executor = ctx.executor();
let workflow_id = ctx.workflow_id();
let started_at = Timestamp::now();
let recorded = executor
.sysdb()
.check_step(workflow_id, step_id, name)
.await
.map_err(Error::SystemDatabase)?;
if let Some(recorded) = recorded {
tracing::debug!(
step_id,
step_name = name,
"the step replays from its checkpoint; the body does not run"
);
return match recorded.error {
Some(error) => Err(revive(&error, name)),
None => decode(recorded.output.as_deref(), "step result"),
};
}
let attempts = options.max_attempts.max(1);
let mut failures: Vec<Error<E>> = Vec::new();
let outcome = loop {
let attempt = failures.len() as u32 + 1;
let status = StepStatus {
step_id,
current_attempt: attempt,
max_attempts: attempts,
};
let span = tracing::info_span!("step", step_id, step_name = name, attempt);
match supervise(ctx, name, status, &options, async { body().await }, span).await {
Ok(value) => break Ok(value),
Err(error) if error.control().is_some() => {
tracing::debug!(
step_id,
step_name = name,
attempt,
"a control signal ended the step; it is not retried and nothing is checkpointed"
);
return Err(error);
}
Err(error) if attempt >= attempts => {
if failures.is_empty() {
break Err(error);
}
failures.push(error);
break Err(Error::MaxStepRetriesExceeded {
step: name.to_owned(),
attempts,
errors: std::mem::take(&mut failures),
});
}
Err(error)
if options
.should_retry
.as_ref()
.is_some_and(|should_retry| !should_retry(&error)) =>
{
tracing::debug!(
step_id,
step_name = name,
attempt,
error = %error,
"the retry predicate declined this failure; the step is not retried"
);
if failures.is_empty() {
break Err(error);
}
failures.push(error);
break Err(Error::MaxStepRetriesExceeded {
step: name.to_owned(),
attempts: attempt,
errors: std::mem::take(&mut failures),
});
}
Err(error) => {
let backoff = options.backoff(attempt - 1);
tracing::warn!(
step_id,
step_name = name,
attempt,
attempts,
backoff_secs = backoff.as_secs_f64(),
error = %error,
"the step failed and will be retried"
);
failures.push(error);
tokio::time::sleep(backoff).await;
}
}
};
let timing = Some(StepTiming {
started_at,
completed_at: Timestamp::now(),
});
let serialization = Some(executor.serializer().name());
match &outcome {
Ok(value) => {
let output = encode(value, "step result")?;
executor
.sysdb()
.record_step(
workflow_id,
step_id,
name,
Outcome::Output(Some(&output)),
serialization,
timing,
)
.await
.map_err(Error::SystemDatabase)?;
tracing::debug!(
step_id,
step_name = name,
"the step ran; its output is recorded"
);
}
Err(error) => {
debug_assert!(error.control().is_none());
let encoded = encode(error, "step error")?;
executor
.sysdb()
.record_step(
workflow_id,
step_id,
name,
Outcome::Error(&encoded),
serialization,
timing,
)
.await
.map_err(Error::SystemDatabase)?;
tracing::debug!(
step_id,
step_name = name,
"the step failed; its error is recorded"
);
}
}
outcome
}
async fn supervise<T, E, Fut>(
ctx: &Ctx,
name: &str,
status: StepStatus,
options: &StepOptions<E>,
body: Fut,
span: tracing::Span,
) -> Result<T, E>
where
E: DurableError,
Fut: Future<Output = Result<T, E>>,
{
let token = CancellationToken::new();
let cancel_on_drop = token.clone().drop_guard();
let attempt = ctx
.in_step_scope(token.clone(), status, body)
.instrument(span);
let outcome = if options.timeout.is_none() && !options.preemptible {
attempt.await
} else {
supervised(ctx, name, options, attempt, token).await
};
cancel_on_drop.disarm();
outcome
}
async fn supervised<T, E>(
ctx: &Ctx,
name: &str,
options: &StepOptions<E>,
attempt: impl Future<Output = Result<T, E>>,
token: CancellationToken,
) -> Result<T, E>
where
E: DurableError,
{
let deadline = async {
match options.timeout {
Some(timeout) => tokio::time::sleep(timeout).await,
None => std::future::pending().await,
}
};
let cancelled = async {
match options.preemptible {
true => observe_cancellation(ctx).await,
false => std::future::pending().await,
}
};
tokio::select! {
biased;
outcome = attempt => outcome,
() = cancelled => {
token.cancel();
tracing::debug!(
step_name = name,
"the workflow was cancelled elsewhere; the step is preempted and records nothing"
);
Err(Error::WorkflowCancelled { workflow_id: ctx.workflow_id().to_owned() })
}
() = deadline => {
token.cancel();
let timeout = options.timeout.expect("the deadline arm only fires with a timeout set");
tracing::debug!(
step_name = name,
timeout_ms = timeout.as_millis(),
"the step attempt exceeded its timeout and was stopped"
);
Err(Error::StepTimeout { step: name.to_owned(), timeout })
}
}
}
async fn observe_cancellation(ctx: &Ctx) {
let executor = ctx.executor();
let interval = executor.outcome_poll_interval();
match executor
.sysdb()
.await_workflow_result(ctx.workflow_id(), interval, true)
.await
{
Ok(AwaitedOutcome::Cancelled) => (),
Ok(_) => std::future::pending().await,
Err(error) => {
tracing::warn!(
workflow_id = ctx.workflow_id(),
%error,
"could not read status for a preemptible step; it will not be preempted"
);
std::future::pending().await
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backoff_grows_geometrically_and_stops_at_the_cap() {
let options = StepOptions::<EngineOnly> {
interval: Duration::from_secs(1),
backoff_rate: 2.0,
max_interval: Duration::from_secs(10),
..Default::default()
};
assert_eq!(options.backoff(0), Duration::from_secs(1));
assert_eq!(options.backoff(1), Duration::from_secs(2));
assert_eq!(options.backoff(2), Duration::from_secs(4));
assert_eq!(options.backoff(3), Duration::from_secs(8));
assert_eq!(options.backoff(4), Duration::from_secs(10), "capped");
assert_eq!(options.backoff(40), Duration::from_secs(10), "still capped");
}
#[test]
fn a_nonsensical_backoff_rate_yields_the_cap() {
let cap = Duration::from_secs(30);
for rate in [-2.0, f64::NAN, f64::INFINITY, 1e10, f64::MAX] {
let options = StepOptions::<EngineOnly> {
interval: Duration::from_secs(1),
backoff_rate: rate,
max_interval: cap,
..Default::default()
};
assert_eq!(options.backoff(3), cap, "rate {rate}");
}
let overflows = StepOptions::<EngineOnly> {
interval: Duration::from_secs(1),
backoff_rate: 1e10,
max_interval: cap,
..Default::default()
};
assert_eq!(overflows.backoff(2), cap);
}
#[test]
fn the_defaults_match_python_typescript_and_java() {
let options = StepOptions::<EngineOnly>::default();
assert_eq!(options.max_attempts, 1, "a plain step does not retry");
assert_eq!(options.interval, Duration::from_secs(1));
assert_eq!(options.backoff_rate, 2.0);
assert_eq!(options.max_interval, Duration::from_secs(3600));
}
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use crate::sysdb::types::{NewWorkflow, StepRecord, Submission};
use crate::{Config, DBOS};
async fn workflow(id: &str) -> (DBOS, dbos_test_support::TestDatabase) {
let db = dbos_test_support::test_database().await;
let dbos = DBOS::new(Config {
migrate: false,
app_version: Some("1.0.0".to_owned()),
..Config::new("step-test", db.url())
});
dbos.launch().await.expect("launch failed");
dbos.executor("test")
.expect("launched")
.sysdb()
.init_workflow(&NewWorkflow::new(id), None, Submission::Fresh, None)
.await
.expect("could not create the workflow row");
(dbos, db)
}
fn ctx(dbos: &DBOS, id: &str) -> Ctx {
Ctx::new(dbos.executor("test").expect("launched"), id, None)
}
async fn steps(dbos: &DBOS, id: &str) -> Vec<StepRecord> {
dbos.executor("test")
.expect("launched")
.sysdb()
.list_workflow_steps(id, true, None, None, None)
.await
.expect("read failed")
}
fn ids(steps: &[StepRecord]) -> Vec<(i32, &str)> {
steps
.iter()
.map(|step| (step.step_id, step.step_name.as_str()))
.collect()
}
#[tokio::test]
async fn a_replayed_step_returns_its_recorded_result_without_entering_the_body() {
let (dbos, _db) = workflow("wf-replay").await;
let entered = AtomicU32::new(0);
let body = || async {
entered.fetch_add(1, Ordering::SeqCst);
Ok::<_, crate::Error>(7u32)
};
let first = Ctx::scope(ctx(&dbos, "wf-replay"), async {
step("compute", body).await
})
.await;
assert_eq!(first.unwrap(), 7);
assert_eq!(entered.load(Ordering::SeqCst), 1);
let again = Ctx::scope(ctx(&dbos, "wf-replay"), async {
step("compute", body).await
})
.await;
assert_eq!(again.unwrap(), 7, "the recorded result");
assert_eq!(
entered.load(Ordering::SeqCst),
1,
"the body must not run a second time: that is the whole of durable execution"
);
dbos.shutdown().await;
}
#[derive(Debug, thiserror::Error, serde::Serialize, serde::Deserialize, PartialEq)]
#[error("the card was declined after {attempts} attempts")]
struct CardDeclined {
attempts: u32,
}
#[tokio::test]
async fn a_replayed_step_that_failed_reports_what_it_recorded() {
let (dbos, _db) = workflow("wf-failed-step").await;
let failing = || async { Err::<(), _>(CardDeclined { attempts: 3 }.into()) };
let first = Ctx::scope(ctx(&dbos, "wf-failed-step"), async {
step("boom", failing).await
})
.await;
let first = first.unwrap_err();
let Error::Application(error) = &first else {
panic!("expected an application error, got {first}")
};
assert_eq!(error, &CardDeclined { attempts: 3 });
let again = Ctx::scope(ctx(&dbos, "wf-failed-step"), async {
step("boom", failing).await
})
.await;
let again = again.unwrap_err();
let Error::Application(replayed) = &again else {
panic!("expected an application error, got {again}")
};
assert_eq!(replayed, error, "the failure survives the round trip whole");
assert_eq!(again.to_string(), first.to_string());
dbos.shutdown().await;
}
#[tokio::test]
async fn a_database_failure_is_not_the_steps_result() {
use crate::sysdb::{BackendError, BackendErrorKind};
let (dbos, _db) = workflow("wf-blip").await;
let failed = Ctx::scope(ctx(&dbos, "wf-blip"), async {
step("charge", || async {
Err::<(), crate::Error>(Error::SystemDatabase(crate::sysdb::Error::Backend(
BackendError {
message: "connection reset by peer".to_owned(),
sqlstate: None,
kind: BackendErrorKind::Connection,
},
)))
})
.await
})
.await
.unwrap_err();
assert!(matches!(failed, Error::SystemDatabase(_)), "{failed}");
let steps = steps(&dbos, "wf-blip").await;
assert!(
steps.is_empty(),
"a database failure must not be checkpointed as the step's outcome: {steps:?}"
);
dbos.shutdown().await;
}
#[tokio::test]
async fn a_step_recorded_in_a_foreign_shape_degrades_to_its_message() {
let (dbos, _db) = workflow("wf-foreign").await;
dbos.executor("test")
.expect("launched")
.sysdb()
.record_step(
"wf-foreign",
0,
"charge",
Outcome::Error(r#"{"pythonModule":"app.errors","type":"CardDeclined"}"#),
Some("pickle"),
None,
)
.await
.expect("could not record the step");
let replayed = Ctx::scope(ctx(&dbos, "wf-foreign"), async {
step("charge", || async {
Err::<(), _>(CardDeclined { attempts: 1 }.into())
})
.await
})
.await
.unwrap_err();
match &replayed {
Error::StepFailed { step, message } => {
assert_eq!(step, "charge");
assert!(message.contains("CardDeclined"), "{message}");
}
other => panic!("expected a degraded step failure, got {other:?}"),
}
dbos.shutdown().await;
}
#[tokio::test]
async fn a_replayed_step_gives_back_the_same_variant_it_recorded() {
let (dbos, _db) = workflow("wf-variant").await;
let failing = || async {
Err::<(), crate::Error>(Error::NotRegistered {
key: "checkout/Checkout/eu".to_owned(),
})
};
let first = Ctx::scope(ctx(&dbos, "wf-variant"), async {
step("boom", failing).await
})
.await;
let first = first.unwrap_err();
let again = Ctx::scope(ctx(&dbos, "wf-variant"), async {
step("boom", failing).await
})
.await;
let again = again.unwrap_err();
match &again {
Error::NotRegistered { key } => assert_eq!(key, "checkout/Checkout/eu"),
other => panic!("the variant did not survive the round trip: {other:?}"),
}
assert_eq!(again.to_string(), first.to_string());
dbos.shutdown().await;
}
#[tokio::test]
async fn a_step_whose_name_changed_is_reported_rather_than_silently_matched() {
let (dbos, _db) = workflow("wf-renamed").await;
let first = Ctx::scope(ctx(&dbos, "wf-renamed"), async {
step("old_name", || async { Ok::<_, crate::Error>(1u32) }).await
})
.await;
assert_eq!(first.unwrap(), 1);
let renamed = Ctx::scope(ctx(&dbos, "wf-renamed"), async {
step("new_name", || async { Ok::<_, crate::Error>(1u32) }).await
})
.await;
assert!(
renamed.is_err(),
"a changed step name must not match a recorded result"
);
dbos.shutdown().await;
}
#[tokio::test]
async fn steps_are_recorded_in_order_under_the_ids_they_allocated() {
let (dbos, _db) = workflow("wf-order").await;
Ctx::scope(ctx(&dbos, "wf-order"), async {
step("one", || async { Ok::<_, crate::Error>(1u32) })
.await
.unwrap();
step("two", || async { Ok::<_, crate::Error>(2u32) })
.await
.unwrap();
step("three", || async { Ok::<_, crate::Error>(3u32) })
.await
.unwrap();
})
.await;
let steps = steps(&dbos, "wf-order").await;
assert_eq!(ids(&steps), [(0, "one"), (1, "two"), (2, "three")]);
assert_eq!(steps[2].output.as_deref(), Some("3"));
dbos.shutdown().await;
}
#[tokio::test]
async fn a_nested_step_is_a_plain_call_and_allocates_no_id() {
let (dbos, _db) = workflow("wf-nested").await;
Ctx::scope(ctx(&dbos, "wf-nested"), async {
step("outer", || async {
let inner = step("inner", || async { Ok::<_, crate::Error>(1u32) })
.await
.unwrap();
Ok::<_, crate::Error>(inner + 1)
})
.await
.unwrap();
step("after", || async { Ok::<_, crate::Error>(9u32) })
.await
.unwrap();
})
.await;
assert_eq!(
ids(&steps(&dbos, "wf-nested").await),
[(0, "outer"), (1, "after")],
"`inner` is not a checkpoint"
);
dbos.shutdown().await;
}
#[tokio::test]
async fn a_step_polled_once_is_refused_when_it_moves() {
let (dbos, _db) = workflow("wf-relocated").await;
let c = ctx(&dbos, "wf-relocated");
let outcome = Ctx::scope(c.clone(), async {
let mut built = step("moved", || async {
std::future::pending::<()>().await;
Ok::<_, crate::Error>(1u32)
});
let started = std::pin::Pin::new(&mut built)
.poll(&mut std::task::Context::from_waker(std::task::Waker::noop()));
assert!(
started.is_pending(),
"the first poll should suspend, leaving the run started"
);
c.in_step_scope(CancellationToken::new(), StepStatus::first(0), built)
.await
})
.await;
assert!(
matches!(outcome, Err(Error::StepBuiltElsewhere { .. })),
"a started step is still refused where it moves: {outcome:?}"
);
assert!(
steps(&dbos, "wf-relocated").await.is_empty(),
"and it records nothing under the workflow it was built in"
);
dbos.shutdown().await;
}
#[tokio::test]
async fn a_body_that_builds_its_step_builds_it_inside_the_attempt() {
let (dbos, _db) = workflow("wf-built-in-body").await;
let outer = Ctx::scope(ctx(&dbos, "wf-built-in-body"), async {
let outer = step("outer", || {
step("inner", || async { Ok::<_, crate::Error>(1u32) })
})
.await;
step("after", || async { Ok::<_, crate::Error>(9u32) })
.await
.unwrap();
outer
})
.await;
assert_eq!(outer.unwrap(), 1);
assert_eq!(
ids(&steps(&dbos, "wf-built-in-body").await),
[(0, "outer"), (1, "after")],
"`inner` is a plain call, so it takes no id and `after` keeps the next one"
);
dbos.shutdown().await;
}
#[tokio::test]
async fn a_step_built_while_a_sibling_runs_is_still_checkpointed() {
let (dbos, _db) = workflow("wf-concurrent").await;
let inside = Arc::new(tokio::sync::Notify::new());
let release = Arc::new(tokio::sync::Notify::new());
Ctx::scope(ctx(&dbos, "wf-concurrent"), async {
let held = step("held", {
let inside = Arc::clone(&inside);
let release = Arc::clone(&release);
move || {
let inside = Arc::clone(&inside);
let release = Arc::clone(&release);
async move {
inside.notify_one();
release.notified().await;
Ok::<_, crate::Error>(1u32)
}
}
});
let beside = async {
inside.notified().await;
let value = step("beside", || async { Ok::<_, crate::Error>(2u32) }).await;
release.notify_one();
value
};
let (held, beside) = tokio::join!(held, beside);
assert_eq!(held.unwrap(), 1);
assert_eq!(beside.unwrap(), 2);
})
.await;
assert_eq!(
ids(&steps(&dbos, "wf-concurrent").await),
[(0, "held"), (1, "beside")],
"`beside` is the workflow's own step, not something inside `held`"
);
dbos.shutdown().await;
}
#[tokio::test]
async fn a_built_step_carries_its_name_and_id() {
let (dbos, _db) = workflow("wf-identity").await;
Ctx::scope(ctx(&dbos, "wf-identity"), async {
let first = step("first", || async { Ok::<_, crate::Error>(1u32) });
let second = step("second", || async { Ok::<_, crate::Error>(2u32) });
assert_eq!((first.name(), first.step_id()), ("first", Some(0)));
assert_eq!((second.name(), second.step_id()), ("second", Some(1)));
assert_eq!(second.await.unwrap(), 2);
assert_eq!(first.await.unwrap(), 1);
})
.await;
dbos.shutdown().await;
}
#[tokio::test]
async fn steps_driven_out_of_build_order_keep_the_ids_they_were_built_with() {
let (dbos, _db) = workflow("wf-out-of-order").await;
Ctx::scope(ctx(&dbos, "wf-out-of-order"), async {
let a = step("a", || async { Ok::<_, crate::Error>(1u32) });
let b = step("b", || async { Ok::<_, crate::Error>(2u32) });
let c = step("c", || async { Ok::<_, crate::Error>(3u32) });
let (c, b, a) = tokio::join!(c, b, a);
assert_eq!((a.unwrap(), b.unwrap(), c.unwrap()), (1, 2, 3));
})
.await;
assert_eq!(
ids(&steps(&dbos, "wf-out-of-order").await),
[(0, "a"), (1, "b"), (2, "c")],
"the ids follow source order, which is what a replay builds again"
);
dbos.shutdown().await;
}
#[tokio::test]
async fn a_step_built_outside_and_polled_inside_is_refused() {
let (dbos, _db) = workflow("wf-outside-in").await;
let smuggled = step("early", || async { Ok::<_, crate::Error>(1u32) });
assert_eq!(
smuggled.step_id(),
None,
"there was no counter to draw from"
);
match Ctx::scope(ctx(&dbos, "wf-outside-in"), smuggled).await {
Err(Error::StepBuiltElsewhere {
step,
built,
polled,
}) => {
assert_eq!((step.as_str(), &*built), ("early", "outside a workflow"));
assert_eq!(&*polled, "in workflow wf-outside-in");
}
other => panic!("expected a built-elsewhere refusal, got {other:?}"),
}
dbos.shutdown().await;
}
#[tokio::test]
async fn a_step_built_in_another_workflow_is_refused() {
let (dbos, _db) = workflow("wf-donor").await;
dbos.executor("test")
.expect("launched")
.sysdb()
.init_workflow(&NewWorkflow::new("wf-thief"), None, Submission::Fresh, None)
.await
.expect("could not create the second workflow row");
#[allow(clippy::async_yields_async)]
let smuggled = Ctx::scope(ctx(&dbos, "wf-donor"), async {
step("borrowed", || async { Ok::<_, crate::Error>(7u32) })
})
.await;
assert_eq!(smuggled.step_id(), Some(0));
match Ctx::scope(ctx(&dbos, "wf-thief"), smuggled).await {
Err(Error::StepBuiltElsewhere {
step,
built,
polled,
}) => {
assert_eq!(
(step.as_str(), &*built),
("borrowed", "in workflow wf-donor")
);
assert_eq!(&*polled, "in workflow wf-thief");
}
other => panic!("expected a built-elsewhere refusal, got {other:?}"),
}
dbos.shutdown().await;
}
#[tokio::test]
async fn a_step_built_inside_a_step_and_awaited_outside_it_is_refused() {
let (dbos, _db) = workflow("wf-carried-out").await;
let (out, smuggled) = tokio::sync::oneshot::channel();
let refused = Ctx::scope(ctx(&dbos, "wf-carried-out"), async {
let mut out = Some(out);
step("outer", move || {
let out = out.take();
async move {
if let Some(out) = out {
let _ = out.send(step("inner", || async { Ok::<_, crate::Error>(1u32) }));
}
Ok::<_, crate::Error>(0u32)
}
})
.await
.expect("the outer step failed");
smuggled.await.expect("the body sent one out").await
})
.await;
match refused {
Err(Error::StepBuiltElsewhere {
step,
built,
polled,
}) => {
assert_eq!(step, "inner");
assert_eq!(&*built, "inside a step of workflow wf-carried-out");
assert_eq!(&*polled, "in workflow wf-carried-out");
}
other => panic!("expected a built-elsewhere refusal, got {other:?}"),
}
dbos.shutdown().await;
}
#[tokio::test]
async fn a_step_carried_into_a_step_body_is_refused() {
let (dbos, _db) = workflow("wf-carried-in").await;
let refused = Ctx::scope(ctx(&dbos, "wf-carried-in"), async {
let claimed = step("claimed", || async { Ok::<_, crate::Error>(1u32) });
assert_eq!(claimed.step_id(), Some(0), "claimed at a step boundary");
let mut carried = Some(claimed);
step("outer", move || {
let taken = carried.take();
async move {
match taken {
Some(inner) => inner.await,
None => Ok(0u32),
}
}
})
.await
})
.await;
match refused {
Err(Error::StepBuiltElsewhere {
step,
built,
polled,
}) => {
assert_eq!(step, "claimed");
assert_eq!(&*built, "in workflow wf-carried-in");
assert_eq!(&*polled, "inside a step of workflow wf-carried-in");
}
other => panic!("expected a built-elsewhere refusal, got {other:?}"),
}
dbos.shutdown().await;
}
#[tokio::test]
async fn a_step_outside_a_workflow_runs_plainly() {
let entered = Arc::new(AtomicU32::new(0));
let value = step("standalone", || {
let entered = Arc::clone(&entered);
async move {
entered.fetch_add(1, Ordering::SeqCst);
Ok::<_, crate::Error>(3u32)
}
})
.await
.expect("a step with no workflow around it just runs");
assert_eq!(value, 3);
assert_eq!(entered.load(Ordering::SeqCst), 1);
assert!(Ctx::current().is_none());
}
}