Skip to content
These docs cover Strata 0.9.0-preview.1.It is a preview, and the API can still change before 1.0.

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.

  • Install Strata for your language: see Installation.
  • In C#, add Microsoft.Extensions.TimeProvider.Testing for the fake clock this guide uses. In Rust, add chrono.
  • Know how an interrupt boundary pauses a thread. See Human-in-the-loop.

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);

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), or add_wait_for_event_with and WaitOptions::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, use AddTimer(name, state => wakeAt) / add_timer_until(name, |state| wake_at).
  • onTimeout names 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 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:00Z
order-2: Event payment.received until 2026-11-03T09:00:00Z

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().

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:00Z

The thread continues at Pack and freezes again at the pickup timer. A signal records no decision: the thread keeps the one it had.

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);

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 it
thread '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.

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-1
Woke order-1 (Timer): completed, shipped = True
Woke order-2 (Timeout): cancelled (Ended) at AwaitPayment

Outcome / 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(),
};

The report has two lists:

  • Woken: each thread with its Reason and the Result of its run. Timer means a timer was due and the thread continued at the edge out of it. Timeout means a wait’s timeout passed. The thread continued at its onTimeout route, 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.

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.

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:

Terminal window
strata thread list --storage orders.db --due
strata thread list --storage orders.db --due-by 2026-11-02T13:00:00Z

The Waiting For column shows each thread’s wait. See strata thread list.

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);

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.

You don’t need to wait a day to test a 24-hour timeout.

  • C#: pass a FakeTimeProvider as RunOptions.TimeProvider, and move it with Advance or SetUtcNow. It sets each wait’s wake time, and “now” for WakeDueAsync and LevelSignals.Due. Pass the same RunOptions in WakeOptions.Run.
  • Rust: a freeze always stamps wake_at from Utc::now(). Waking takes your time instead: wake_due(store, now), LevelSignals::due_at(now) and ThreadQuery::due_by(now). Pass a time in the future, as the sample does with start + 25 hours.
  • Wake times are whole seconds, rounded up, so a timer never fires early.
  • A map’s child graph can’t wait. Compile() refuses it with a 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.