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 Verifying starts 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). FraudCleared reaches both; Fraud has a transition for it and advances to Cleared, while Stock ignores it. The parent stays parked on Verifying the whole time.

  • Join. The parent only leaves Verifying once every region has finished. After StockReserved, both regions have reached their terminal, so the automatic from Verifying to Approved fires and the order is Approved.

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.