Distribution¶
Durability lets one process resume an execution. Distribution lets many workers share the load while guaranteeing each execution is only ever advanced by one of them at a time.
The transport seam¶
Alongside the store sits an independent transport — a queue with single-active-consumer per group, where the group is the execution id. That property is what makes concurrency safe: events for one execution are processed in order by one worker, even with a pool of workers running. Backends:
Transport |
Exclusivity mechanism |
|---|---|
|
in-process (tests, single process) |
|
|
|
|
|
per-group row claimed with |
|
queue table on Raft-replicated SQLite |
|
per-group ready-index/lock document ( |
|
libSQL (Turso’s SQLite fork): |
|
SQS FIFO |
Backends without a cheap global serialization primitive (Redis, Mongo) build per-group
exclusivity by hand with a per-group lock record indexed by when it next becomes claimable, so
claim reads only the few due groups rather than scanning the whole queue; SQLite and Postgres
lean on the database instead (SQLite’s write-lock; Postgres’s row lock via SKIP LOCKED). For
how each transport enforces the per-group invariant and lays its queue out — the lease,
claim, FIFO and parking mechanics — see the transports reference.
Store and transport are independent — mix freely, or unify on one backend (all-Postgres, all-rqlite, all-Mongo, all-libSQL: no Redis needed).
Running with workers¶
DistributedRunner(store, transport, definitions) is the façade: create an execution — it
never runs anything on this process; by default it also publishes a Start through the transport,
so a worker runs the initial on enter (pass start_on_create=False to defer that and call
start yourself later) — send further events (also published), and run one or more workers
that claim → load → dedupe → route → ack. A worker’s step() processes exactly one available
message and returns whether it did — which lets us drive it deterministically here, without
thread timing:
from harel import definition_from_dsl, DictStore, Event
from harel.engine.distributed import DistributedRunner
from harel.engine.transport import InMemoryTransport
SOURCE = """
event PlaceOrder {}
event Deliver {}
machine order {
initial Cart
state Cart {}
state AwaitingPayment {}
final Delivered success {}
from Cart to AwaitingPayment on PlaceOrder
from AwaitingPayment to Delivered on Deliver
}
"""
defn = definition_from_dsl(SOURCE, "order")
runner = DistributedRunner(DictStore(), InMemoryTransport(), {defn.id: defn})
worker = runner.worker()
def drain():
while worker.step():
pass
exe = runner.create(defn.id) # PENDING — never runs on this process; Start is already
drain() # published (start_on_create=True, the default) — a worker
# (here, our own drain) runs it
runner.send(exe.id, Event(kind="PlaceOrder"))
drain()
print("after PlaceOrder ->", runner.store.load(exe.id).active_path)
runner.send(exe.id, Event(kind="Deliver"))
drain()
final = runner.store.load(exe.id)
print("after Deliver ->", final.active_path, "/", final.status.name, "/", final.outcome)
after PlaceOrder -> AwaitingPayment
after Deliver -> Delivered / DONE / success
In production you don’t call step() in a loop — a worker runs run(stop_event), looping
claim→process and, on its idle path, sweeping due timers (fire_due_timers) so timeouts fire
without a separate scheduler.
Only stop_event ends that loop. A message whose handling raises — a store or transport outage,
a machine version the worker can’t run, an engine bug — is logged with its traceback and goes
back to the queue, to come back after suspend_recheck seconds (default 5); a claim or a timer
sweep that raises is logged, and the loop carries on after the same pause. An action’s own error
isn’t one of these: the driver routes it (on error, else the execution fails). The sync
Worker.run and the async AsyncWorker.run behave the same.
Async-native workers¶
The worker is async-native. The bundled python -m harel.worker runs one asyncio event
loop (via anyio.run) that drives up to HAREL_CONCURRENCY events in flight at once
(AsyncWorker.run, default 256): while one execution’s action awaits IO, the loop processes
others — so one worker process overlaps many events’ IO on a single event loop, with no thread
per event.
The synchronous façades shown above (DistributedRunner, worker.step()) run the same logic
either on a shared background event loop (an anyio blocking
portal, the default) or, with execution="inline", in the caller’s own thread — so the
deterministic step()-in-a-loop style works for embedding and tests, and a web view can create
an execution and queue its Start inside its own transaction. See
execution models.
Scale out by running more worker processes (or machines) against the same store and transport — the single-active-consumer-per-group property keeps each execution on one worker at a time regardless of how many are running.
A step can run twice without anything crashing¶
A worker commits a step with a CAS on the execution’s version, so its commit loses to any other
write of the same execution that landed while it ran the step: a control-plane command
(suspend, cancel, terminate, redrive) from the runner, a process() from a
DurableRunner on the same store, or another worker after the lease expired under an action
slower than visibility. The losing worker gets a StoreConflict, puts the event back
(nack), and the event is processed again on the new version. Nothing committed half, but the
step’s actions ran on the first attempt and run again on the next — at-least-once, with no
failure involved. Each attempt hands an action the same stm.idempotency_key, so a side effect
deduped on it happens once (see durability).
Deploying a new version of a machine¶
The workers share one definition of record: the store. When python -m harel.worker starts it
loads every *.stm at the top of HAREL_DEFINITIONS_DIR and publishes them — each machine
version, with the files it imports. A version published already with the same source is left as
it is; one published with other content (a machine changed without a new version) stops the
worker before it claims anything, so the deploy fails, not the executions.
From then on a worker runs any execution, whatever version it is on:
a version it has loaded runs from its own files;
a version it hasn’t — an older one whose file is gone, or a newer one a newer worker published — is read from the store and kept (a published version never changes);
a version it can’t use is left for another worker: the message goes back to the queue (
DefinitionNotLoadedin the log) and comes back aftersuspend_recheck. That is a version nobody published, one calling an action module the worker doesn’t allow, or one calling an action the worker’s code can’t import — an older worker meeting a version a newer deploy published, during a rolling deploy.
A rolling deploy of order version 2 therefore needs nothing special: the new workers publish
version 2 and start creating its executions’ children and timers; the old ones keep running
version 1’s executions, and leave version 2’s that call code they don’t have to the new ones. Run
harel publish DIR in the pipeline to stop a bad deploy before any worker rolls out.
A version read from the store may call only the action modules the worker’s own machines call,
plus those in HAREL_ACTION_MODULES (comma-separated; a module allows the modules under it). A
runner of your own takes the same as action_modules=, and actions= / guards= for what your
code binds instead of a bind in the DSL. See
changing a machine.
Scaling & throughput¶
Two independent dials:
Concurrency within a worker (
HAREL_CONCURRENCY): how many events one event loop keeps in flight. Raising it lifts throughput until either the backend’s per-event round-trips (load → commit → claim → ack) or the event loop’s own per-event CPU becomes the limit — which of the two binds depends on the backend’s per-op latency on your host (a slow-I/O backend is round-trip-bound; a fast one is loop-CPU-bound). Too high a value degrades throughput: more in-flight coroutines cost more to schedule than they save.More workers / shards: adding worker processes lifts the aggregate sublinearly until something shared saturates. That ceiling is either the host (CPU-bound worker processes oversubscribing the cores) or the single backend instance — which one binds depends on your hardware. On a busy single instance you scale horizontally by sharding: because executions are independent (single-consumer per group, no cross-execution coordination), you partition them across independent
(store, transport)instances — each shard shares nothing with the others. This is the same model Temporal (hash the id to a shard, add shards) and DBOS (partition, “your ceiling is your Postgres”) use.
Each backend’s claim/commit is folded into the fewest round-trips it can be — an atomic Lua script
on Redis, one sorted find_one_and_update on Mongo, a plpgsql function (FOR UPDATE SKIP LOCKED
inside) on Postgres — because the per-worker limit is usually round-trips, not the server.
Warning
The numbers in bench/RESULTS.md
were measured on one 8-core laptop with the backends in Docker Desktop, workers and backends
sharing the cores — so a multi-worker plateau there is often the laptop, not the backend. Read
them as relative; a backend on its own hardware, with workers on other machines, scales far higher
on a single instance before sharding is needed. Run bench/bench_async.py, bench_workers.py,
bench_shards.py against your own backend for real numbers.
harel and durable-execution engines (illustrative)¶
harel is a statechart engine; DBOS, Temporal and friends are durable-execution / workflow engines. They overlap — both keep long-lived state alive across crashes — but model the problem differently: declarative named states versus imperative durable code.
Warning
The figures below are illustrative only — NOT a fair benchmark of DBOS, and should not be cited
as one. They run the same toy FSM on the same laptop + Docker Postgres (so both carry the handicap
above), but the two tools do different work. DBOS ran in its default single-instance config. The
point is the shape of the difference, not the ratio. Full caveats and the script
(bench/bench_dbos.py) are in
bench/RESULTS.md.
On a toy FSM (start, then one more event, then done), same Postgres + laptop, measured the same way for both — the timed window covers sending and processing every event, with the clients and the workers in separate processes:
best case |
one worker process |
|
|---|---|---|
harel-on-Postgres |
~900 events/s |
~860 events/s |
DBOS — durable workflow + |
~470–500 events/s (4 workers) |
~205 events/s |
(DBOS was still gaining at 4 worker processes, the most this host could run without them
competing for cores.) harel comes out ahead, but that’s the paradigm, not a verdict on
DBOS: DBOS does full
durable-workflow bookkeeping per event — workflow-status rows, automatic recovery of arbitrary
imperative code, queues, SERIALIZABLE transactions — which is exactly what you want for “run these
steps, with retries, and survive a crash mid-way”, and is overkill for the trivial state transition
this toy does. harel’s per-event path is a lean claim → load → commit → ack. So pick by the shape of
the problem: a statechart engine when the domain is a machine of named states with hierarchy and
guards; a durable-execution engine when it’s imperative code that must survive crashes.
Fair queuing and priority¶
Round-robin fairness¶
By default workers use round-robin across active executions. After a group is processed
(its message acked), its “last claimed” timestamp is set to now, so it moves to the back of
the queue. Groups never yet claimed have timestamp 0 and are always preferred. This prevents a
handful of high-traffic executions from monopolizing all worker capacity — the hot-partition
problem that arises with pure FIFO queues.
Execution priority¶
Assign a priority level at creation time (0 = normal, up to 4):
urgent = runner.create(defn.id, priority=2)
The priority is stored in the transport on the first publish for that execution. It is
fixed at creation: subsequent send() calls carry the same execution priority when
publishing to a group that does not yet have messages in the transport, and INSERT OR IGNORE
/ $setOnInsert / HSETNX semantics ensure the first-published value wins.
Worker priority routing — high_ratio + priority_threshold¶
Workers decide which priority tier to serve on each claim step:
worker = runner.worker(
high_ratio=0.8, # 80 % of claim attempts target priority >= threshold first
priority_threshold=2, # "high" means priority 2, 3, or 4
)
On each step():
With probability
high_ratio, the worker triesclaim(min_priority=priority_threshold). If nothing is available at that priority, it falls back toclaim(min_priority=0)so the worker never idles when only normal-priority work is present.With probability
1 - high_ratio, it goes directly toclaim(min_priority=0).
The default is high_ratio=0.0 — no priority routing, every claim considers all groups
equally. A worker configured with high_ratio=1.0 always attempts high-priority work first
and falls back to any priority, effectively draining high-priority executions before touching
normal ones whenever both are available.
Dedicating workers to machines¶
A worker can be given capacity for some machines only — so a busy machine gets workers of its own, and doesn’t take them from the others:
from harel import DictStore, definition_from_dsl
from harel.engine.distributed import DistributedRunner
from harel.engine.transport import InMemoryTransport
ORDERS = """
event Go {}
machine fulfilment { initial Packing state Packing {} final Shipped success from Packing to Shipped on Go }
"""
fulfilment = definition_from_dsl(ORDERS, "fulfilment")
runner = DistributedRunner(DictStore(), InMemoryTransport(), {fulfilment.id: fulfilment})
dedicated = runner.worker("fulfilment-1", machines={"fulfilment"}) # only fulfilment's work
anyone = runner.worker("general-1") # everything, fulfilment included
python -m harel.worker takes the same as HAREL_MACHINES=fulfilment,publishing, and logs at
start which machines it claims for.
A tree travels with its root. An execution’s
routing_keyis the machine its tree started from: its orthogonal regions, fan-out instances and invoked submachines carry the root’s, so a worker givenfulfilmentruns itsparcels.parcelchildren too. Name only root machines.Dedicated capacity, not exclusivity. A worker given no
machinesclaims everything, the dedicated machines’ work included: it is the safety net when the dedicated workers are down or busy. To keep a machine’s work off the general workers, give every worker itsmachines.Nothing claims a machine no worker covers when every worker is given
machines: its executions wait. A worker warns at start about a machine it is given but has no file for.Transports. The in-memory, SQLite, libSQL, rqlite, Postgres and Mongo transports route claims; Redis and SQS can’t, and a worker given
machineson them fails at its first claim (NotImplementedError) rather than claim every machine’s work. See transports.
Orthogonal & fan-out, distributed¶
The same machinery carries orthogonal regions and
fan-out: each region or addressed child is its own execution with its
own group, so they genuinely run on different workers, and their Finished events travel back
through the transport to the parent’s join. The model you wrote in the tutorial distributes
without change. Region and fan-out children inherit the parent’s priority, so a
high-priority workflow’s parallel work is claimed at that priority too (not demoted to 0).
Independent work is marked as such in the runners’ logic, and run with coroutines (the async
runners, and the sync ones in the default execution="background") it overlaps on the event
loop — concurrency even in a single-process DurableRunner. In the caller’s thread
(execution="inline") the same work runs in order, with the same result:
Spawn relay: all pending children start concurrently — their
on enteractions (LLM calls, HTTP requests, …) overlap rather than running one after another.Broadcast (
inject): a domain event delivered to multiple live regions runs each region’s handler concurrently.Timer sweep (
fire_due_timers): a worker publishes all the timers due in one sweep concurrently. The headless runner delivers them one at a time, since two timers of the same execution must not race on its version.Control-plane propagation (
cancel/suspend/resume): status changes propagate to all children at each level of the tree concurrently.