7. Time: durable timers

Some transitions are driven by the clock, not by an event. An unpaid order shouldn’t sit forever — give it a payment window, and cancel it when the window closes. harel models that with a durable timer.

timeout arms a timer

Add timeout <seconds> to a state. Entering the state arms a timer; leaving it cancels the timer. When the timer is due, the engine delivers a reserved Timeout event — and your model decides what that means by handling it like any other event:

event PaymentAuthorized {}
event Deliver {}

machine order {
  initial AwaitingPayment
  state AwaitingPayment { timeout 900 }
  state Paid {}
  final Delivered success {}
  final Cancelled cancelled {}

  from AwaitingPayment to Paid on PaymentAuthorized
  from AwaitingPayment to Cancelled on Timeout
  from Paid to Delivered on Deliver
}
        stateDiagram-v2
    [*] --> AwaitingPayment
    AwaitingPayment --> Paid : PaymentAuthorized
    AwaitingPayment --> Cancelled : Timeout (900s)
    Paid --> Delivered : Deliver
    Delivered --> [*]
    Cancelled --> [*]
    

This is the key design choice: the engine schedules, the model decides. The engine arms, fires, and cancels the timer durably; what a timeout does — cancel, retry, escalate — is an ordinary transition you write. There is no special “timeout handler” concept.

Firing it deterministically

Timers fire against a clock, and the clock is injectable — so examples (and tests) are deterministic, with no real sleeping. We pass a clock we control, then advance it and ask the runner to sweep for due timers:

from harel import definition_from_dsl, DurableRunner, DictStore, Event

SOURCE = """
event PaymentAuthorized {}
event Deliver {}

machine order {
  initial AwaitingPayment
  state AwaitingPayment { timeout 900 }
  state Paid {}
  final Delivered success {}
  final Cancelled cancelled {}

  from AwaitingPayment to Paid on PaymentAuthorized
  from AwaitingPayment to Cancelled on Timeout
  from Paid to Delivered on Deliver
}
"""

defn = definition_from_dsl(SOURCE, "order")

clock = [1000.0]  # a mutable clock we advance by hand
runner = DurableRunner(DictStore(), {defn.id: defn}, clock=lambda: clock[0])

exe = runner.create(defn.id)               # entering AwaitingPayment arms a timer for t=1900
print("start       ->", exe.active_path)
print("sweep @1000 -> fired", runner.fire_due_timers(), "(window still open)")

clock[0] = 1900.0                          # the window closes
print("sweep @1900 -> fired", runner.fire_due_timers())
exe = runner.store.load(exe.id)
print("result      ->", exe.active_path, "/", exe.outcome)
start       -> AwaitingPayment
sweep @1000 -> fired 0 (window still open)
sweep @1900 -> fired 1
result      -> Cancelled / cancelled

fire_due_timers() delivers every timer due at the current clock and returns how many fired. In production you don’t call it by hand — a worker’s idle loop sweeps automatically (see distribution). And because the timer is persisted in the store, it survives a crash or restart: a timer armed before the process died still fires when a worker comes back and sweeps. If the payment does arrive first, leaving AwaitingPayment cancels the timer, so a later sweep finds nothing due.

Note

The Timeout is anchored to the state that armed it and bubbles up: it fires that state’s own on Timeout, or — if it has none — an enclosing ancestor’s. A timeout for a state that is no longer active is silently ignored (a staleness guard), so a stale sweep can never derail a machine that already moved on.

Retry and backoff are modelled, not built in

Because the model decides what a timeout does, retry-with-backoff isn’t an engine feature — it’s a small composite you assemble: a Waiting state whose delay is read from context (timeout {context: backoff}), a selector that branches succeeded / retry again, and the composite’s own timeout as the overall budget. harel ships composable backoff actions (harel.lib.exponential_backoff, linear_backoff, reset_backoff) to compute the next delay. The full pattern is laid out in durability; for now the takeaway is that policy lives in the model, and the engine just keeps time.

Next we leave the single-thread-of-control world entirely: orthogonal regions let a machine be in several states at once.