Durability¶
Everything in the tutorial used an in-memory DictStore, so executions vanished with the
process. The point of harel is that they don’t have to: an Execution is serializable
data, and the runner checkpoints it through a store at every event boundary. Swap the
store and the same machine survives crashes and restarts.
The store seam¶
DurableRunner(store, definitions) takes any ExecutionStore. The bundled backends:
Store |
For |
|---|---|
|
tests, examples (in-memory, not durable) |
|
a single machine — durable, zero infrastructure |
|
distributed (atomic Lua CAS on the state-only fast path; WATCH/MULTI for complex commits) |
|
distributed (UPDATE-WHERE-version CAS) |
|
distributed SQLite over Raft (HTTP) |
|
distributed document store (single-document atomic |
|
libSQL (Turso’s SQLite fork): a local file, a |
|
AWS serverless (conditional-write CAS, atomic |
The networked ones are optional extras (harel[redis], [postgres], [rqlite], [mongo], [libsql], [dynamodb]) and take an
injected client. Selecting one is the only change — the machine and the driving loop are
identical. For how each backend lays its data out — tables/keys/collections, the CAS, the
outbox/dedupe/timer/trace storage — see the stores reference.
Surviving a restart¶
Point a SqliteStore at a file, and an execution created by one runner is picked up by another
using only its id — the state lives in the store, the id is the handle that crosses the
process boundary. A stand-in for “the process died and came back”:
import tempfile
from pathlib import Path
from harel import definition_from_dsl, DurableRunner, SqliteStore, Event
SOURCE = """
event Finish {}
machine order {
initial Cart
state Cart {}
final Done success {}
from Cart to Done on Finish
}
"""
defn = definition_from_dsl(SOURCE, "order")
db = str(Path(tempfile.mkdtemp()) / "stm.db")
# process #1: a runner creates the execution (persisting it) and then "crashes".
# create() returns the Execution; its `.id` is the ONLY handle that outlives the
# process — you keep it (in a URL, a queue message, another table) to refer back.
execution_id = DurableRunner(SqliteStore(db), {defn.id: defn}).create(defn.id).id
# process #2: a brand-new runner — shares nothing with the first but the file —
# reloads that execution by id from the store and drives it to completion.
exe = DurableRunner(SqliteStore(db), {defn.id: defn}).process(execution_id, Event(kind="Finish"))
print("survived restart ->", exe.active_path, "/", exe.outcome)
survived restart -> Done / success
What “durable” guarantees¶
The store is hardened beyond just persisting state:
Optimistic concurrency — every record carries a
version; a compare-and-set on write raisesStoreConflictif another writer got there first (the single-writer backstop).Transactional outbox — a commit saves the execution and its emitted events in one transaction; a relay delivers them after commit, so a crash can never lose a
Finished.Dedupe — processed event ids are recorded, so at-least-once delivery takes effect once.
Crash-safe orthogonal fork — region children are created from a durable spawn-outbox, idempotently, so a crash mid-fork neither double-spawns nor loses a region’s result.
Durable timers — a
timeoutis persisted on enter and cancelled on exit in the same commit as the transition, so a scheduled timer can’t be lost (see step 7).Execution trace (opt-in) — with
HAREL_TRACE=1(orDurableRunner(..., trace=True)) the commit also appends one timeline step (event, transition, actions,context_out) in the same transaction, so the monitor timeline is real. Off by default (zero hot-path cost); ring-capped to the lastHAREL_TRACE_MAXsteps. Per-backend storage in the stores reference.
At-least-once actions & idempotency¶
Delivery is at least once. The dedupe above stops a redelivered event from re-running once
its prior attempt committed — but when an attempt runs its actions and then doesn’t commit,
the event is redelivered and the actions run again. Dedupe is per event, not per action.
That happens when a worker crashes between an action and the commit — and, with nothing
crashing, when the commit loses to another writer of the same execution (a StoreConflict): a
control-plane command (suspend, cancel, …) or a process() landing while a worker runs the
step, or a lease that expired under an action slower than visibility.
For a side-effecting action (charge a card, send an email — local or a remote FaaS function) the
driver exposes a stable key, stm.idempotency_key = {execution_id}:{step}:{index}:{action}:
step is the id of the event being processed (start when the execution starts), index the
action’s position in that step, action its name. Every attempt at the same event hands each
action the same key — even when another writer moved the execution on in between, since the
event’s id, unlike the version, doesn’t change. So give a retried submission the same
Event(id=...) (a webhook’s own event id, a request id) and the key — and the dedupe — follow
it.
The dedupe must live in an external backend you own (Redis SET NX, a DynamoDB conditional
put, a service’s native idempotency key) — not in harel’s store or context. The gap is a crash
before the commit, so anything harel recorded would roll back with that failed commit; only a
record the callee wrote outside harel’s transaction survives. harel.idempotency ships the opt-in
helper:
# docs-test: skip
from harel import idempotent, DictIdempotency # DictIdempotency is in-memory (tests)
backend = my_redis_backed_idempotency() # an IdempotencyBackend you supply
actions = {"charge": idempotent(backend)(charge)} # bind the wrapped action
idempotent runs the body at most once per stm.idempotency_key (caching the result, so a
selector still routes the same way). Residual window: true exactly-once needs the effect and the
claim to be atomic (an idempotency-key-native service); the helper narrows the window, it doesn’t
abolish it.
Changing a machine with executions in flight¶
A machine declares a version, and a change to it is a new version:
machine order version 2 {
…
}
A machine without version is version 1. Each execution records the version it was created with
and the digest of that version’s source, and runs on it to the end; so do its regions, its
fan-out instances and the inline machines it invokes. A new execution gets the highest version
the runner has loaded, or the one create(..., version=1) asks for.
Published versions¶
The store keeps every version of every machine, so an execution finds the one it runs on
whichever process picks it up — whatever files that process has. A version is published from its
.stm file, with every file it imports, and is never changed once stored:
a worker publishes the machines of its definitions directory when it starts. A version already published is left as it is; one published with other content — a machine changed without a new version — stops the worker before it takes any work, so the deploy fails, not the executions;
harel publish DIRdoes the same from a deploy pipeline, against the store theHAREL_STORE_*settings name (publish_definitions(store, read_definitions(dir))in code);a runner that meets an execution of a version it hasn’t loaded reads it from the store. So
order.stmsimply moves on to version 2: the workers keep running version 1’s executions from the store, and no copy of the old file is kept anywhere.
To change a machine, bump its version and deploy. That’s all.
A version read from the store is checked before it runs: it may call only the action modules the
runner’s own machines call, plus those it is told to allow — action_modules= on a runner,
HAREL_ACTION_MODULES (comma-separated) for a worker; a module allows the modules under it. A
record written to the store by someone else can’t make a worker run code its own machines don’t.
The store keeps the machines, not the code of their actions: an action a published version
names has to stay importable, with the same meaning, for as long as executions run on it. What
the code binds — definition_from_dsl(..., actions=, guards=) rather than a bind in the DSL —
isn’t in the store either: give the runner the same actions= and guards=, and it binds them
in the versions it reads from there (a handler it can’t bind is reported like a version it
hasn’t got).
What harel checks¶
A version edited without a bump. The digest covers the machine’s block and what its file shares with it — imports (by alias and content, not by where the file lives), events, guards,
binds, fragments — but not comments, layout or the file’s other machines, nor the code of the actions. Publishing such a version is refused (above). A runner whose own copy differs from the one an execution started on runs it on the published one, and logs a warning; if no published one matches either, the execution fails (FAILED) with aDefinitionChangederror —process()raises it instead underon_action_error="raise", and the event that found it is consumed with the dead letter.harel hash FILEprints each machine’s version and digest.A version neither this runner nor the store has. A worker logs a
DefinitionNotLoadederror and puts the message back to be retried, for a worker that has it; a durable runner raises it to the caller and leaves that execution’s timers armed. The execution is not touched. A published version this runner can’t use is reported the same way: one that calls a module it doesn’t allow, or an action its code can’t import — an older worker meeting a version a newer deploy published, which a newer worker then runs.The same version twice with different content among the files a runner loads is refused:
load_definitionsanddefinition_registryraise. A file declares each machine once.A state an execution is parked in, renamed or removed — which a bump prevents, but an execution from before versions, or an edit without one, can still meet — fails it with a
DefinitionChangedthat names the missing state.
redrive brings back an execution failed this way: it is
repositioned on a state of the machine
loaded for its version, and carries on with that source from then on. cancel and terminate
work on it as ever. A finished or suspended execution is not checked: only one the engine would
drive (RUNNING, or CANCELLING on its way to a Cancel).
An execution written before versions existed runs as version 1, and takes the digest of the version 1 loaded the first time it is processed.
Retry & backoff is a composite, not a feature¶
Because timers are durable and the model decides what a Timeout does, retry-with-backoff is
assembled, not configured:
a
Waitingstate whose delay is read from context —timeout context backoff— so anon entercan compute the next wait;a
selectthat branches succeeded / retry again;the composite’s own
timeoutas the overall budget, whoseTimeoutexits to a failed terminal.
harel ships composable backoff actions in harel.lib — exponential_backoff,
linear_backoff, reset_backoff — ordinary inputs-parameterised actions that compute the next
delay. Policy stays in the model; the engine just keeps time. The repository’s retry.stm /
charge_retry.stm are the complete pattern.