Build a workflow engine (Temporal / Airflow / Cadence style) (13 scenes)
Scene 08 · Signals and queries: the workflow as an actor
A running workflow is an addressable actor: a signal delivers external input durably and can change its path; a query reads its state without mutating it.
Previously

A signal can divert ORDER #1001 away from shipping — but 'divert' is easy to say and hard to do correctly. We've already charged the card and reserved the Widget. We can't ship, and we can't just stop and leave the customer paying for nothing. The completed steps have to be UNDONE, in the right order. How does a workflow safely back out of work it already committed?

Scene 08
Signals and queries: the workflow as an actor
Diagram
ORDER #1001 is paused before ShipPackage, drawn as an addressable actor with a mailbox. A 'signal' (cancelOrder) is fire-and-forget input: it lands in the history as an event and, on the workflow's next step, can change its path — here, diverting from shipping into refund/release. A 'query' (getOrderStatus) is a read-only peek at current state: it appends NO event and must never mutate or schedule anything, because it runs against replayed state. Mutations go through signals; reads go through queries.
ORDER #1001 — a running workflow is an addressable actorcancelOrder signal: not sentcustomer“cancel?”fire-and-forgetno signalgetOrderStatusread-onlyno event appendedreserved, about t…MAILORDER #1001workflow (replayed)paused before ShipPackageNEXT STEPShipPackageDEFAULT PATHShipPackage → EmailDIVERTED (SIGNAL)ReleaseInventory → Refund…EVENT HISTORY (append-only) — signals LAND here · queries append NOTHINGChargeCard ✓ $42replayedReserveInventory ✓replayedShipPackagependingA running workflow is an addressable actor: you can read it without leaving a trace, or send it input that changes …
paused before its next step — but still reachable
ORDER #1001 isn't finished — it's a LIVE workflow, paused on its next step (ShipPackage), waiting. That's the first surprise: a running workflow isn't a closed box you fire and forget. It's an addressable thing, sitting there with a mailbox, that you can still talk to. Watch the getOrderStatus arrow on the left peek IN: it reads the current state — "reserved, about to ship" — and a value comes back OUT. Now look at the event-history strip along the bottom: nothing new landed. The read left no trace. That trace-free, read-only peek into a running workflow's current state is called a **query**: it asks "what is the order's status right now?" and gets an answer without changing or recording anything. It runs against replayed state, so it must stay read-only.
Implementation
OrderWorkflow.run
the main path pauses on its mailbox before each step
1def run(order):
2 charge_card(order) # recorded as events
3 reserve_inventory(order)
4 # the actor parks here, reachable, until woken
5 if self.cancelled:
6 return run_compensation(order) # divert
7 ship_package(order)
8 send_email(order)
@signal_handler cancelOrder
fire-and-forget input recorded as a durable event
1# delivered to the running workflow's mailbox
2def cancelOrder():
3 # lands as SignalReceived in the event history
4 self.cancelled = True
5 # next step sees it and diverts the path
@query_handler getOrderStatus
synchronous read-only peek — appends no event
1# runs against REPLAYED state
2def getOrderStatus():
3 # reads current state, returns it, no event appended
4 return self.status # 'reserved, about to ship'
5 # FORBIDDEN here: mutate state or schedule work,
6 # replay would diverge -> non-determinism error
Not sure what to ask? Tap a question — the staff engineer answers in the chat panel.