15. Out-of-order events: defer¶
External systems don’t always send events in the order your machine expects them. A payment
gateway may fire a webhook before your own backend has finished updating state. A mobile client
may push a confirmation before the server-side handshake completes. defer lets the machine
hold an event it isn’t ready to handle yet and re-deliver it automatically once it reaches
a state that can.
The problem: an early webhook¶
Consider a payment flow. After calling the gateway the machine waits for its own internal
acknowledgement (GatewayAck), then moves to WaitingWebhook where the confirmation
(PaymentConfirmed) is expected. In production, PaymentConfirmed occasionally arrives
before GatewayAck — network paths differ. Without defer, the early confirmation is
silently dropped and the machine gets stuck waiting forever.
event GatewayAck {}
event PaymentConfirmed {}
machine payment {
initial Charging
state Charging {}
state WaitingWebhook {}
final Done success {}
from Charging to WaitingWebhook on GatewayAck
from WaitingWebhook to Done on PaymentConfirmed
}
Fixing it with defer¶
Add defer PaymentConfirmed at the machine level. That tells the engine: while no state in
the current configuration has a transition for this event, hold it instead of dropping it.
event GatewayAck {}
event PaymentConfirmed {}
machine payment {
defer PaymentConfirmed
initial Charging
state Charging {}
state WaitingWebhook {}
final Done success {}
from Charging to WaitingWebhook on GatewayAck
from WaitingWebhook to Done on PaymentConfirmed
}
Declared at the machine level defer applies everywhere. Declared inside a state it applies
only in that state and its substates.
Running it¶
from harel import definition_from_dsl, DurableRunner, DictStore, Event
SOURCE = """
event GatewayAck {}
event PaymentConfirmed {}
machine payment {
defer PaymentConfirmed
initial Charging
state Charging {}
state WaitingWebhook {}
final Done success {}
from Charging to WaitingWebhook on GatewayAck
from WaitingWebhook to Done on PaymentConfirmed
}
"""
defn = definition_from_dsl(SOURCE, "payment")
runner = DurableRunner(DictStore(), {defn.id: defn})
exe = runner.create(defn.id)
print("start ->", exe.active_path) # Charging
# PaymentConfirmed arrives early — Charging has no transition for it
exe = runner.process(exe.id, Event(kind="PaymentConfirmed"))
print("after early hook ->", exe.active_path, "| deferred:", [e.kind for e in exe.deferred])
# GatewayAck arrives — machine moves to WaitingWebhook, engine re-delivers the held event
exe = runner.process(exe.id, Event(kind="GatewayAck"))
print("after ack ->", exe.active_path, "| outcome:", exe.outcome)
start -> Charging
after early hook -> Charging | deferred: ['PaymentConfirmed']
after ack -> Done | outcome: success
GatewayAck moves the machine to WaitingWebhook. The engine then drains the deferred queue:
PaymentConfirmed now has a matching transition, so it fires immediately — all within the same
process() call, without you sending the event again.
State-level defer¶
defer on a state only applies there and in its substates. Events that arrive outside that
scope are still dropped (or handled by on_activity if present). Use this when you only want to
hold an event for a specific phase of the machine’s life.
event Go {}
event EarlyResult {}
machine pipeline {
initial Preparing
state Preparing {
defer EarlyResult # hold EarlyResult only while here
}
state Processing {}
final Done success {}
from Preparing to Processing on Go
from Processing to Done on EarlyResult
}
An EarlyResult that arrives during Preparing is held and delivered as soon as Processing
becomes active (triggered by Go). An EarlyResult during any other state would be dropped
normally.
What defer does not cover¶
defer applies to domain events only. The engine’s own system events — Timeout, Cancel,
Finished, Reset, Start, SetState — are routed before the defer check and cannot be held.
Writing defer Timeout compiles, but has no effect at runtime.
Deferred events are held in a FIFO queue on the Execution and persisted with it, so they
survive crashes and worker restarts. If the machine terminates (reaches a final state) while
events are still deferred, those events are discarded.