Wait for events and timers
In this guide you build an order process that waits twice: first for a payment event from outside, then for a pickup timer. You send the event, see which answers are refused, and wake the threads whose timer or timeout is due.
While a thread waits, it is a checkpoint marked interrupted, with the time it wakes or times out. No process holds it. Waits that aren’t a person explains the model and which call answers which wait. This page shows the steps.
Before you start
Section titled “Before you start”- Install Strata for your language: see Installation.
- In C#, add
Microsoft.Extensions.TimeProvider.Testingfor the fake clock this guide uses. In Rust, addchrono. - Know how an interrupt boundary pauses a thread. See Human-in-the-loop.
Add the wait nodes
Section titled “Add the wait nodes”The order graph places the order, waits up to 24 hours for a payment.received event, packs, waits 4 hours for the carrier, then ships.
var orders = new StateGraph<Order>() .AddNode("Place", o => o) .AddWaitForEvent("AwaitPayment", "payment.received", timeout: TimeSpan.FromHours(24)) .AddNode("Pack", o => o) .AddTimer("AwaitPickup", TimeSpan.FromHours(4)) .AddNode("Ship", o => o with { Shipped = true }) .AddEdge("Place", "AwaitPayment") .AddEdge("AwaitPayment", "Pack") .AddEdge("Pack", "AwaitPickup") .AddEdge("AwaitPickup", "Ship") .Compile();
sealed record Order(string OrderId, bool Paid, string? PaymentRef, bool Shipped);const HOUR: Duration = Duration::from_secs(3600);
let orders = StateGraph::<Order>::new() .add_node("Place", |o: Order| o) .add_wait_for_event_with( "AwaitPayment", "payment.received", WaitOptions::new().with_timeout(24 * HOUR), ) .add_node("Pack", |o: Order| o) .add_timer("AwaitPickup", 4 * HOUR) .add_node("Ship", |o: Order| Order { shipped: true, ..o }) .add_edge("Place", "AwaitPayment") .add_edge("AwaitPayment", "Pack") .add_edge("Pack", "AwaitPickup") .add_edge("AwaitPickup", "Ship") .compile() .expect("the order graph is valid");A wait node does nothing itself. The run passes through it, then freezes, just as at an interrupt boundary. The edge out of the wait is chosen at the freeze, on the state as it was then, so the event’s payload can’t change where the thread goes next.
The builders:
AddWaitForEvent(name, eventType, timeout, onTimeout)in C#. In Rust,add_wait_for_event(name, event_type), oradd_wait_for_event_withandWaitOptions::new().with_timeout(..).with_on_timeout(..). Without a timeout, the wait is never due: only the event, or ending the thread, moves it.AddTimer(name, delay)/add_timer(name, delay). To wake at a time computed from the state, useAddTimer(name, state => wakeAt)/add_timer_until(name, |state| wake_at).onTimeoutnames the node a timed-out wait continues at. Leave it out, and a timeout ends the thread.
A delay or timeout of zero or less is refused, and so is onTimeout without a timeout. C# throws when you call the builder. Rust refuses at .compile().
Run until the first wait
Section titled “Run until the first wait”Run two orders. Each one freezes at AwaitPayment, and the result says what it waits for.
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2026-11-02T09:00:00Z", CultureInfo.InvariantCulture));var run = new RunOptions { TimeProvider = clock };var store = new InMemoryCheckpointStore(clock);
foreach (var id in new[] { "order-1", "order-2" }){ var result = await orders.RunAsync(id, new Order(id, false, null, false), store, run); if (result is GraphExecutionResult<Order>.Interrupted waiting) { Console.WriteLine($"{id}: {waiting.Wait!.Kind} {waiting.Wait.EventType} until {waiting.Wait.WakeAt}"); }}order-1: Event payment.received until 2026-11-03T09:00:00Zorder-2: Event payment.received until 2026-11-03T09:00:00Zlet store = Arc::new(InMemoryCheckpointStore::new());let start = Utc::now();for id in ["order-1", "order-2"] { let order = Order { order_id: id.into(), paid: false, payment_ref: None, shipped: false }; if let GraphExecutionResult::Interrupted { wait, .. } = orders.run(id, order, store.clone()).await { let wait = wait.unwrap(); println!("{id}: {:?} {} until {}", wait.kind, wait.event_type.unwrap_or_default(), wait.wake_at.unwrap_or_default()); }}order-1: Event payment.received until 2026-10-31T14:19:55Zorder-2: Event payment.received until 2026-10-31T14:19:55ZRust stamps wake_at from the wall clock, so your times differ.
The wake time is the run’s clock plus the timeout, rounded up to a whole second. In C# the clock is RunOptions.TimeProvider. In Rust it is Utc::now().
Send the event
Section titled “Send the event”When the payment provider calls your webhook, signal the thread with an EventSignal. Its overrides carry the payload. They replace top-level fields of the waiting state, as a person’s overrides do.
var signal = new EventSignal("payment.received") .WithOverride("Paid", true) .WithOverride("PaymentRef", "PAY-5531");var paid = await orders.SignalAsync("order-1", signal, store, run);if (paid is GraphExecutionResult<Order>.Interrupted next){ Console.WriteLine($"order-1: paid ({next.FrozenState.PaymentRef}), now {next.Wait!.Kind} until {next.Wait.WakeAt}");}order-1: paid (PAY-5531), now Timer until 2026-11-02T13:00:00Zlet signal = EventSignal::new("payment.received") .with_override("paid", true) .with_override("payment_ref", "PAY-5531");if let GraphExecutionResult::Interrupted { wait, frozen_state, .. } = orders.signal("order-1", signal.clone(), store.clone()).await{ let wait = wait.unwrap(); println!("order-1: paid ({}), now {:?} until {}", frozen_state.payment_ref.unwrap_or_default(), wait.kind, wait.wake_at.unwrap_or_default());}order-1: paid (PAY-5531), now Timer until 2026-10-30T18:19:55ZThe thread continues at Pack and freezes again at the pickup timer. A signal records no decision: the thread keeps the one it had.
What a signal can’t answer
Section titled “What a signal can’t answer”Each wait has one answer. Anything else comes back as Failed, and nothing is written. In Rust the result is Failed(StrataError::ExecutionAborted(message)).
var wrongType = await orders.SignalAsync("order-2", new EventSignal("refund.issued"), store, run);var approve = await orders.ResumeAsync("order-2", HumanSignal.Approve("clerk"), store, run);var notWaiting = await orders.SignalAsync("order-1", signal, store, run);let wrong_type = orders.signal("order-2", EventSignal::new("refund.issued"), store.clone()).await;let approve = orders.resume("order-2", HumanSignal::approve("clerk"), store.clone()).await;let not_waiting = orders.signal("order-1", signal, store.clone()).await;The three messages, the same in both languages:
thread 'order-2' is not waiting for event 'refund.issued': it is waiting for event 'payment.received' at [AwaitPayment]thread 'order-2' is waiting for event 'payment.received' at [AwaitPayment]: only a signal of that event answers itthread 'order-1' is not waiting for event 'payment.received': it is waiting for a timer at [AwaitPickup]- The wrong event type is refused.
- A person’s resume can’t answer an event wait or a timer.
- A thread that isn’t waiting for that event is refused. This includes a signal that arrives before the thread reaches its wait: Strata doesn’t keep it for later. Send it again once the thread waits. See Known limitations.
A signal to an ended or completed thread is refused too.
Wake due timers and timeouts
Section titled “Wake due timers and timeouts”Nothing fires on its own. WakeDueAsync (Rust: wake_due) finds the threads that are due and wakes them one at a time. Here the clock moves 5 hours, then 20 more.
clock.Advance(TimeSpan.FromHours(5));var due = await store.ListThreadsAsync(new ThreadQuery { DueBy = clock.GetUtcNow() });Console.WriteLine($"Due: {string.Join(", ", due.Threads.Select(t => t.ThreadId))}");await Wake();
clock.Advance(TimeSpan.FromHours(20));await Wake();
async Task Wake(){ var report = await orders.WakeDueAsync(store, new WakeOptions { Run = run }); foreach (var woken in report.Woken) { Console.WriteLine($"Woke {woken.ThreadId} ({woken.Reason}): {Outcome(woken.Result)}"); }}Due: order-1Woke order-1 (Timer): completed, shipped = TrueWoke order-2 (Timeout): cancelled (Ended) at AwaitPaymentlet now = start + chrono::Duration::hours(5);let due = store.list_threads(&ThreadQuery::default().due_by(now)).await.unwrap();let ids: Vec<_> = due.threads.iter().map(|t| t.thread_id.as_str()).collect();println!("Due: {}", ids.join(", "));
for now in [start + chrono::Duration::hours(5), start + chrono::Duration::hours(25)] { let report = orders.wake_due(store.clone(), now).await.unwrap(); for woken in &report.woken { println!("Woke {} ({:?}): {}", woken.thread_id, woken.reason, outcome(&woken.result)); }}Due: order-1Woke order-1 (Timer): completed, shipped = trueWoke order-2 (Timeout): cancelled (Ended) at AwaitPaymentOutcome / outcome is a small helper in the sample that formats the result:
static string Outcome(GraphExecutionResult<Order> result) => result switch{ GraphExecutionResult<Order>.Completed c => $"completed, shipped = {c.State.Shipped}", GraphExecutionResult<Order>.Cancelled c => $"cancelled ({c.Reason}) at {c.Node}", GraphExecutionResult<Order>.Interrupted i => $"interrupted at {i.SuspendedNode}", GraphExecutionResult<Order>.Failed f => $"failed: {f.Message}", _ => result.ToString(),};fn outcome(result: &GraphExecutionResult<Order>) -> String { match result { GraphExecutionResult::Completed(order) => format!("completed, shipped = {}", order.shipped), GraphExecutionResult::Cancelled { reason, node, .. } => format!("cancelled ({reason:?}) at {node}"), GraphExecutionResult::Interrupted { suspended_node, .. } => format!("interrupted at {suspended_node}"), GraphExecutionResult::Failed(error) => format!("failed: {error:?}"), }}The report has two lists:
Woken: each thread with itsReasonand theResultof its run.Timermeans a timer was due and the thread continued at the edge out of it.Timeoutmeans a wait’s timeout passed. The thread continued at itsonTimeoutroute, or, with none, was ended.Skipped: threads it listed but left alone, with why. A thread stopped being due, another process woke it first, or its wait isn’t one of this graph’s.
A timeout with no route ends the thread where it waits. The result is Cancelled with the reason Ended, and the checkpoint’s metadata has strata.end_reason = timed_out, which a ThreadQuery metadata filter can find. End a thread covers ending in detail.
A call wakes at most WakeOptions.Limit threads (100 by default). WakeOptions also filters by metadata and carries the RunOptions for every run it starts.
A thread that finishes after a wake reads resumed in the store, not completed, unless the graph checkpoints every node. See When checkpoints are written. Read the run’s own result from the report instead.
Run the wake on a schedule
Section titled “Run the wake on a schedule”In an application, call the wake on a cadence. The hosting packages do it for you: the wake driver, AddWakeDriver<TState>() in C# or Host::wake in Rust, calls it every 5 seconds by default. See The hosting drivers.
Several replicas can run the driver at once. A wake is saved only if the thread hasn’t changed, so of two processes that list the same thread, one wakes it and the other skips it.
When one store holds several graphs’ threads, give each graph an identity, so its wake lists only its own threads. See Graph identity.
Find due threads
Section titled “Find due threads”ThreadQuery.DueBy (Rust: ThreadQuery::default().due_by(now)) lists the threads that are waiting and whose timer or timeout is due by that time, as the sample above shows. From a terminal:
strata thread list --storage orders.db --duestrata thread list --storage orders.db --due-by 2026-11-02T13:00:00ZThe Waiting For column shows each thread’s wait. See strata thread list.
Wake one thread yourself
Section titled “Wake one thread yourself”WakeDueAsync doesn’t stream, and it uses one RunOptions for every thread. To wake a thread through your own resume, for example to stream its output, pass LevelSignals.Due (Rust: LevelSignals::due(), or LevelSignals::due_at(now)) as the signal:
var (chunks, completion) = orders.ResumeStreaming("order-1005", LevelSignals.Due, store, run);let (handle, mut chunks) = orders .clone() .resume_streaming("order-1005", LevelSignals::due_at(now), store.clone());The resume decides at its own load whether the thread is still due, and does what the wake would do. A thread that isn’t due yet is refused, with nothing written. For a thread waiting for a payment whose timeout is still days away, the message reads:
thread 'order-3' is not due until 2026-11-04T10:00:00Z: it is waiting for event 'payment.received' at [AwaitPayment]LevelSignals.Event(signal) (Rust: LevelSignals::event(signal)) answers an event wait the same way, so a signalled run can stream too.
Test with a fake clock
Section titled “Test with a fake clock”You don’t need to wait a day to test a 24-hour timeout.
- C#: pass a
FakeTimeProviderasRunOptions.TimeProvider, and move it withAdvanceorSetUtcNow. It sets each wait’s wake time, and “now” forWakeDueAsyncandLevelSignals.Due. Pass the sameRunOptionsinWakeOptions.Run. - Rust: a freeze always stamps
wake_atfromUtc::now(). Waking takes your time instead:wake_due(store, now),LevelSignals::due_at(now)andThreadQuery::due_by(now). Pass a time in the future, as the sample does withstart + 25 hours.
Limits
Section titled “Limits”- Wake times are whole seconds, rounded up, so a timer never fires early.
- A map’s child graph can’t wait.
Compile()refuses it witha map item can't freeze. Put the wait before or after the map node. - A signal that arrives before its wait is refused, not buffered.
- You can’t move or cancel a timer. You can only end the thread.
Related
Section titled “Related”- Waits that aren’t a person: which call answers which wait.
- Wakes and timers: how waits survive restarts.
- End a thread: end a waiting thread on request.
- Retry failed nodes: wait between attempts inside one run.
- Demo 19-waits-and-timers, in C# and Rust: the full order process on SQLite, with a fraud review that times out and a customer who cancels.