use durare::{
DurableContext, DurableEngine, Error, InMemoryProvider, Result, StateProvider, StepOptions,
WorkflowOptions, STATUS_PENDING, STATUS_SUCCESS,
};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
#[tokio::test]
async fn step_runs_once_across_replays() -> Result<()> {
static CHARGES: AtomicUsize = AtomicUsize::new(0);
let provider = Arc::new(InMemoryProvider::new());
let mut engine = DurableEngine::new(provider).await?;
engine.register("charge", |ctx: DurableContext, _: ()| async move {
let amount = ctx
.step("charge_card", || async {
CHARGES.fetch_add(1, Ordering::SeqCst);
Ok::<_, Error>(4999_i64)
})
.await?;
Ok::<_, Error>(amount)
});
let a: i64 = engine
.start("charge", (), WorkflowOptions::with_id("wf-1"))
.await?
.result()
.await?;
let b: i64 = engine
.start("charge", (), WorkflowOptions::with_id("wf-1"))
.await?
.result()
.await?;
assert_eq!(a, 4999);
assert_eq!(b, 4999);
assert_eq!(
CHARGES.load(Ordering::SeqCst),
1,
"the charge side effect must execute exactly once across replays"
);
Ok(())
}
#[tokio::test]
async fn caught_step_failure_replays_without_rerunning() -> Result<()> {
static RUNS: AtomicUsize = AtomicUsize::new(0);
let provider = Arc::new(InMemoryProvider::new());
let mut engine = DurableEngine::new(provider).await?;
engine.register("flaky_caught", |ctx: DurableContext, _: ()| async move {
let r: Result<i64> = ctx
.step("maybe", || async {
let n = RUNS.fetch_add(1, Ordering::SeqCst);
if n == 0 {
Err(Error::app("transient"))
} else {
Ok(7)
}
})
.await;
Ok::<_, Error>(if r.is_ok() { "ok" } else { "caught-error" }.to_string())
});
let a: String = engine
.start("flaky_caught", (), WorkflowOptions::with_id("wf-step-err"))
.await?
.result()
.await?;
let b: String = engine
.start("flaky_caught", (), WorkflowOptions::with_id("wf-step-err"))
.await?
.result()
.await?;
assert_eq!(a, "caught-error");
assert_eq!(b, "caught-error", "replay observes the recorded step error");
assert_eq!(
RUNS.load(Ordering::SeqCst),
1,
"a checkpointed failed step is not re-run on replay"
);
let steps = engine.get_workflow_steps("wf-step-err").await?;
let maybe = steps
.iter()
.find(|s| s.name == "maybe")
.expect("step recorded");
assert_eq!(maybe.error.as_deref(), Some("transient"));
Ok(())
}
#[tokio::test]
async fn multi_step_results_are_stable() -> Result<()> {
let provider = Arc::new(InMemoryProvider::new());
let mut engine = DurableEngine::new(provider).await?;
engine.register("pipeline", |ctx: DurableContext, start: i64| async move {
let a = ctx
.step("double", || async { Ok::<_, Error>(start * 2) })
.await?;
let b = ctx
.step("plus_one", || async { Ok::<_, Error>(a + 1) })
.await?;
Ok::<_, Error>(b)
});
let out: i64 = engine
.start("pipeline", 10_i64, WorkflowOptions::with_id("wf-2"))
.await?
.result()
.await?;
assert_eq!(out, 21);
let out2: i64 = engine
.start("pipeline", 10_i64, WorkflowOptions::with_id("wf-2"))
.await?
.result()
.await?;
assert_eq!(out2, 21);
Ok(())
}
#[tokio::test]
async fn durable_sleep_is_not_repeated_on_replay() -> Result<()> {
static AFTER_SLEEP: AtomicUsize = AtomicUsize::new(0);
let nap = Duration::from_millis(500);
let provider = Arc::new(InMemoryProvider::new());
let mut engine = DurableEngine::new(provider).await?;
engine.register("napper", move |ctx: DurableContext, _: ()| async move {
ctx.sleep(nap).await?;
ctx.step("after_nap", || async {
AFTER_SLEEP.fetch_add(1, Ordering::SeqCst);
Ok::<_, Error>(1_i64)
})
.await?;
Ok::<_, Error>(())
});
let t0 = Instant::now();
engine
.start::<_, ()>("napper", (), WorkflowOptions::with_id("wf-nap"))
.await?
.result()
.await?;
let first = t0.elapsed();
assert!(
first >= Duration::from_millis(400),
"first run should really sleep (~500ms), took {first:?}"
);
let t1 = Instant::now();
engine
.start::<_, ()>("napper", (), WorkflowOptions::with_id("wf-nap"))
.await?
.result()
.await?;
let resubmit = t1.elapsed();
assert!(
resubmit < Duration::from_millis(200),
"a completed workflow must not re-sleep on resubmit, took {resubmit:?}"
);
assert_eq!(
AFTER_SLEEP.load(Ordering::SeqCst),
1,
"the step after the sleep runs once; the resubmit does not re-run the body"
);
Ok(())
}
#[tokio::test]
async fn durable_now_uuid_random_are_stable_across_replays() -> Result<()> {
let provider = Arc::new(InMemoryProvider::new());
let mut engine = DurableEngine::new(provider).await?;
engine.register("clocked", |ctx: DurableContext, _: ()| async move {
let now = ctx.now().await?.timestamp_micros();
let id = ctx.uuid().await?;
let r = ctx.random().await?;
Ok::<_, Error>((now, id, r))
});
let first = engine
.start::<(), (i64, String, f64)>("clocked", (), WorkflowOptions::with_id("wf-1"))
.await?
.result()
.await?;
let second = engine
.start::<(), (i64, String, f64)>("clocked", (), WorkflowOptions::with_id("wf-1"))
.await?
.result()
.await?;
assert_eq!(
first, second,
"now/uuid/random must be stable across replays"
);
assert!(
(0.0..1.0).contains(&first.2),
"random() is in [0, 1): {}",
first.2
);
Ok(())
}
#[tokio::test]
async fn durable_uuid_calls_in_one_workflow_are_distinct() -> Result<()> {
let provider = Arc::new(InMemoryProvider::new());
let mut engine = DurableEngine::new(provider).await?;
engine.register("two_ids", |ctx: DurableContext, _: ()| async move {
let a = ctx.uuid().await?;
let b = ctx.uuid().await?;
Ok::<_, Error>((a, b))
});
let (a, b) = engine
.start::<(), (String, String)>("two_ids", (), WorkflowOptions::with_id("wf"))
.await?
.result()
.await?;
assert_ne!(a, b, "two uuid() calls mint distinct ids");
assert!(!a.is_empty() && !b.is_empty());
Ok(())
}
#[tokio::test]
async fn workflow_body_panic_is_recoverable() -> Result<()> {
static ATTEMPTS: AtomicUsize = AtomicUsize::new(0);
let provider = Arc::new(InMemoryProvider::new());
let mut engine = DurableEngine::new(provider.clone()).await?;
engine.register("panicky", |_ctx: DurableContext, _: ()| async move {
if ATTEMPTS.fetch_add(1, Ordering::SeqCst) == 0 {
panic!("boom on the first attempt");
}
Ok::<_, Error>(())
});
let res = engine
.start::<(), ()>("panicky", (), WorkflowOptions::with_id("wf-panic"))
.await?
.result()
.await;
assert!(
res.is_err(),
"the owning caller observes the panic as an error"
);
assert_eq!(
provider
.get_workflow_status("wf-panic")
.await?
.unwrap()
.status,
STATUS_PENDING,
"a panicked workflow is left recoverable, not terminally failed"
);
assert!(
engine.recover().await? >= 1,
"recovery picks up the panicked run"
);
assert_eq!(
provider
.get_workflow_status("wf-panic")
.await?
.unwrap()
.status,
STATUS_SUCCESS,
"the recovered run completes"
);
assert_eq!(
ATTEMPTS.load(Ordering::SeqCst),
2,
"panicked once, then recovered"
);
Ok(())
}
#[tokio::test]
async fn step_panic_is_caught_and_retried() -> Result<()> {
static ATTEMPTS: AtomicUsize = AtomicUsize::new(0);
let mut engine = DurableEngine::new(Arc::new(InMemoryProvider::new())).await?;
engine.register("retry_panic", |ctx: DurableContext, _: ()| async move {
ctx.step_with(
StepOptions::new("flaky")
.max_retries(3)
.base_interval(Duration::from_millis(1)),
|| async {
if ATTEMPTS.fetch_add(1, Ordering::SeqCst) == 0 {
panic!("first attempt panics");
}
Ok::<_, Error>(42_i64)
},
)
.await
});
let out: i64 = engine
.start::<(), i64>("retry_panic", (), WorkflowOptions::with_id("wf-retry"))
.await?
.result()
.await?;
assert_eq!(out, 42);
assert_eq!(
ATTEMPTS.load(Ordering::SeqCst),
2,
"the step panicked once, then succeeded on retry"
);
Ok(())
}