Workflows can sleep. Not with tokio::time::sleep — that wouldn't survive a
restart — but with ctx.timer(), which records a TimerStarted event in
Postgres and suspends the workflow until the deadline elapses.
#[workflow]
async fn onboarding(ctx: &WorkflowContext, user_id: i64) -> HarvestResult<String> {
ctx.execute_activity_raw(
"send_welcome_email",
serde_json::json!({ "user_id": user_id }),
"default",
)
.await?;
// 30-second drip — durably suspended.
ctx.timer("post-welcome-drip", 30).await?;
let nudge = ctx
.execute_activity_raw(
"send_followup_email",
serde_json::json!({ "user_id": user_id }),
"default",
)
.await?;
Ok(nudge["status"].as_str().unwrap_or("sent").to_owned())
}
This is the durability demonstration to actually try in person:
- Start the workflow.
- While it's parked on the timer, hit
Ctrl+C. - Run
cargo runagain. - Watch the dashboard. The welcome activity is not re-executed — its result is replayed from the event log. The timer resumes wherever the 30-second budget left off, then the follow-up activity runs.
That replay-and-resume pattern is the whole point of the engine.
Cancellable and renewable timers
ctx.timer() (and its absolute-deadline sibling ctx.sleep_until()) are
fire-once: once armed they always fire. Some orchestrations need a timer
they can cancel (the work finished early, so an SLA timer must not fire) or
reset (a sliding-window / idle-session timeout that renews on every event).
For those, arm a durable timer with ctx.start_timer() and drive it through the
returned TimerHandle (issue #768):
#[workflow]
async fn fulfillment(ctx: &WorkflowContext, order: Order) -> HarvestResult<String> {
// Non-suspending: arm the SLA timer and keep running.
let mut sla = ctx.start_timer("fulfillment-sla", 3600);
for item in order.items {
ctx.execute_activity_raw("pick_item", serde_json::json!(item), "default").await?;
// Each item pushes the SLA deadline forward — reset cancels the old
// arming and starts a fresh one, so there is never an orphaned timer
// left to fire late. NOTE: this does NOT interrupt a long-running
// `pick_item`; an armed timer is never observed mid-activity. The
// deadline is only *checked* when the workflow reaches `await_fire()`
// below.
sla.reset(3600)?;
}
if order.shipped_early {
// Cancel so the SLA timer never fires.
sla.cancel()?;
Ok("shipped".into())
} else {
// Suspend until the SLA fires (or is cancelled elsewhere).
match sla.await_fire().await? {
TimerOutcome::Fired => Ok("sla_breached".into()),
TimerOutcome::Cancelled => Ok("cancelled".into()),
}
}
}
start_timerdoes not suspend — it records the arming (a singleTimerStartedevent) and returns a handle immediately, so the workflow keeps running. It does not yet make the timer fire-eligible (see below).cancel()records aTimerCancelledevent (and deletes the durable timer row if one has been created), so noTimerFiredis ever produced for a cancelled timer.reset(secs)= cancel + re-arm; intentionally O(1) history per reset (two events) with zero orphaned firings.await_fire()creates the durableharvest_timersrow (fires_at = now + durationat this instant) and suspends until the timer fires (TimerOutcome::Fired) or is cancelled (TimerOutcome::Cancelled).
The deadline is measured from await_fire(), not from start_timer(). A
cancellable timer becomes fire-eligible only when it is awaited — arming
records the event but inserts no durable row. An armed-but-unawaited timer is
therefore never observed while the workflow is parked on some other wait (an
activity, a child workflow, a signal): it cannot fire spuriously mid-activity,
and a reset() or await_fire() reached after that other wait is never
confronted with a stale fire (which would otherwise leave an unconsumed
TimerFired and break the run). In the loop above, the timer's fires_at is set
when the loop's final await_fire() runs, so the SLA is measured from there —
not from start_timer. This suits the intended idle-timeout / debounce / lease
patterns, which always await or reset the timer. If you need a deadline anchored
to start_timer time, capture ctx.system_now() at arm time and pass the
residual to ctx.sleep_until().
Bounding a signal wait with a deadline. An armed cancellable timer alone will not wake a workflow parked on a
wait_for_signal— the two are separate waits. To race a signal against a deadline in one call, usectx.receive_signal_timeout(below), which arms the deadline and the signal wait together as one race.
Fire-vs-cancel is decided by recorded-history order, not the wall clock. If a
timer genuinely races its own cancellation, whichever of TimerFired /
TimerCancelled is recorded first in history wins on every replay,
regardless of timing on the replaying worker. Like ctx.timer, fires are
anchored to the Postgres clock (fires_at = db_now + remaining), so absolute
honoring is subject to worker↔database clock skew — this is not a skew-proof
absolute-time guarantee.
For a two-branch "wait for a signal OR a deadline" race (an approval that
auto-rejects after 24h), reach for
ctx.receive_signal_timeout instead — it returns
Some(payload) when the signal arrives first and None when the deadline
fires. Composing a resettable start_timer handle with a signal wait in one
call is a natural follow-up; today, drive the reset from workflow logic as above
or use receive_signal_timeout for the pure two-branch shape.
See examples/cancellable_timer_sla.rs for a complete, tested example.