8. Doing things at once: orthogonal regions¶
Until now the machine has been in exactly one state at a time. But some things genuinely happen in parallel. When an order is placed, you might run a fraud screen and reserve stock at the same time, and only proceed once both are done. That is an orthogonal state (an AND-state): several regions, each its own sub-machine, running concurrently.
An AND-state with two regions¶
event FraudCleared {}
event StockReserved {}
machine checkout {
initial Verifying
orthogonal Verifying {
state Fraud {
initial Screening
state Screening {}
final Cleared success {}
from Screening to Cleared on FraudCleared
}
state Stock {
initial Checking
state Checking {}
final Reserved success {}
from Checking to Reserved on StockReserved
}
}
final Approved success {}
from Verifying to Approved
}
stateDiagram-v2
[*] --> Verifying
state Verifying {
[*] --> Screening
Screening --> Cleared : FraudCleared
--
[*] --> Checking
Checking --> Reserved : StockReserved
}
Verifying --> Approved
Approved --> [*]
orthogonal Verifying { … } declares the AND-state. Each child — Fraud, Stock — is a
region: a full sub-machine with its own initial and its own terminal. The two run
independently.
Fork, broadcast, join¶
from harel import definition_from_dsl, DurableRunner, DictStore, Event
SOURCE = """
event FraudCleared {}
event StockReserved {}
machine checkout {
initial Verifying
orthogonal Verifying {
state Fraud {
initial Screening
state Screening {}
final Cleared success {}
from Screening to Cleared on FraudCleared
}
state Stock {
initial Checking
state Checking {}
final Reserved success {}
from Checking to Reserved on StockReserved
}
}
final Approved success {}
from Verifying to Approved
}
"""
defn = definition_from_dsl(SOURCE, "checkout")
runner = DurableRunner(DictStore(), {defn.id: defn})
exe = runner.create(defn.id)
print("start ->", exe.active_path, exe.status.name)
exe = runner.process(exe.id, Event(kind="FraudCleared"))
print("FraudCleared ->", exe.active_path, exe.status.name)
exe = runner.process(exe.id, Event(kind="StockReserved"))
print("StockReserved ->", exe.active_path, exe.status.name, "/", exe.outcome)
start -> Verifying RUNNING
FraudCleared -> Verifying RUNNING
StockReserved -> Approved DONE / success
Three things happen here:
Fork. Entering
Verifyingstarts both regions at once. Under the hood each region runs as its own child execution over the same definition — which is exactly what makes them durable and, later, distributable.Broadcast. Every event is delivered to all regions (Harel semantics).
FraudClearedreaches both;Fraudhas a transition for it and advances toCleared, whileStockignores it. The parent stays parked onVerifyingthe whole time.Join. The parent only leaves
Verifyingonce every region has finished. AfterStockReserved, both regions have reached their terminal, so the automaticfrom Verifying to Approvedfires and the order isApproved.
Important
Orthogonal regions are for a fixed set of different concurrent activities that all share the event stream. They are not the tool for “run the same machine over N items” (ship N parcels, deploy to N regions) — that is a data-parallel fan-out invoke, covered in step 12. Using an orthogonal state where you mean fan-out is a classic mis-modelling.
Region hooks and timeouts¶
A region composite (Fraud, Stock) is a first-class state and supports the same hooks as any
other state. Its on enter fires when the region starts (before descending to its initial child);
its on exit fires when the region finishes (before the parent’s join resolves). A timeout
declared on the region composite arms a timer for the whole region:
orthogonal Verifying {
state Fraud {
on enter start_fraud_check
on exit record_fraud_result
timeout 30 # the whole Fraud region must finish within 30 s
initial Screening
state Screening {}
final Cleared success {}
final TimedOut failed {}
from Screening to Cleared on FraudCleared
from Screening to TimedOut on Timeout
}
…
}
The hook order follows UML: the region’s own on enter runs first, then its initial child’s.
On completion the region’s own on exit runs after its terminal child’s.
What each region produced — and how the join can route on it (all succeeded? any failed?) — is the subject of payloads. First, let’s tackle reuse: the retry pattern from step 5 keeps reappearing. Fragments let us write it once.