Durable Timeouts
Why durable
Fiber timeouts are fast and local. If the node dies while an FSM sits in a timed state, that
fiber is gone. Durable timeouts store deadlines in a TimeoutStore so another node's sweeper
can fire them.
TimeoutStrategy
| Strategy | Layer | Survives restart |
|---|---|---|
| Fiber | TimeoutStrategy.fiber[Id] | No |
| Durable | TimeoutStrategy.durable[Id] (+ TimeoutStore) | Yes |
Schedule with durable strategy, then send the timeout event the sweeper would fire (DocSpecs use
a live clock; unit tests can TestClock.adjust fiber timeouts instead):
{
enum OrderState derives Finite:
case Pending, Started, Done, Cancelled
enum OrderEvent derives Finite:
case StartPayment, Complete, PaymentTimeout
import OrderState.*, OrderEvent.*
val machine = Machine(
assembly[OrderState, OrderEvent](
(Pending via StartPayment to Started) @@ Aspect.timeout(1.hour, PaymentTimeout),
Started via Complete to Done,
Started via PaymentTimeout to Cancelled,
)
)
val orderId: OrderId = "order-timeout-1"
ZIO
.scoped {
for
fsm <- FSMRuntime(orderId, machine, Pending)
_ <- fsm.send(StartPayment)
_ <- fsm.send(PaymentTimeout)
state <- fsm.currentState
yield state
}
.provide(
InMemoryEventStore.layer[OrderId, OrderState, OrderEvent],
ZLayer.fromZIO(InMemoryTimeoutStore.make[OrderId]),
TimeoutStrategy.durable[OrderId],
LockingStrategy.optimistic[OrderId],
)
.asDoc
}CancelledTimeoutSweeper
A background sweeper:
Queries expired, unclaimed timeouts
Claims each timeout
Validates
(stateHash, sequenceNr)so stale timeouts do not fireLooks up the timeout event via
Machine.timeoutEventsandruntime.sends itMarks complete
Use TimeoutSweeperConfig for interval, jitter, batch size, claim duration, and nodeId.
Optional leader election via LeaseStore keeps a single active sweeper to reduce DB load.
See examples/heartbeat for a full sweeper alongside FSMRuntime, and Testing
for the DocSpec vs TestClock choice.
Next: Distributed Coordination.