Stores¶
A store (ExecutionStore) is how a runner persists and reloads an Execution. This is the
hub for the per-backend reference: the contract every backend implements, the durability shape
they share, the opt-in execution trace — and a page per backend with its exact data model and
every operation (what each table/key/document holds, how each update is done, and why). For the
concepts (the seam, surviving a restart, idempotency) start with durability; to
select a store at the worker see HAREL_STORE_BACKEND.
The contract every backend implements¶
ExecutionStore is a small Protocol (store/_base.py) —
each backend is a sibling module under harel/engine/store/, re-exported from the package, with
a twin under harel/engine/aio_store/ (same data model, awaited IO):
Method |
Purpose |
|---|---|
|
rehydrate the Execution (the latter folds the dedupe check into the same round-trip) |
|
persist with the version CAS (raises |
|
the one atomic write per event — the Execution + outbox + dedupe + timer ops + spawn intents + (opt-in) trace step, all-or-nothing; returns the |
|
dedupe lookup |
|
the relay drains emitted events (a caller that delivers an entry itself acks it by the |
|
the relay drains orthogonal child-creation intents |
|
the timer sweep |
|
the opt-in execution timeline (see below) |
|
a page of lightweight summaries, for the monitor and |
|
ids of the stored Executions starting with |
|
the published machine versions: a version is stored once — publishing it again returns the one stored first — and read by the runners that haven’t loaded it (see changing a machine) |
|
permanently delete one Execution and every row keyed by it (dedupe, trace, timers, outbox entries addressed to it, spawn intents it issued), iff still at |
|
release the connection/client |
Everything commit writes lives in one transaction (or one atomic request), which is the
property that makes a crash safe — see durability. The common shape across the
durable backends, which each per-backend page then spells out exactly:
CAS — a
versionper record; the write applies only if the stored version still matches, elseStoreConflict. This is the single-writer-per-execution backstop.Outbox / spawns — emitted events and region-spawn intents are persisted with the advance and delivered by the relay afterwards, so a crash never loses a
Finishedor a fork.Dedupe —
processed_events(id + event id) makes at-least-once delivery effect-once.Timers —
(execution_id, path) → fire_at, armed/cancelled in the same commit.
Writing your own backend¶
A store outside harel — over a framework’s ORM, a database harel doesn’t ship — builds on
harel.engine.store.base, the stable API for backend authors: the protocols
(ExecutionStore, AsyncExecutionStore), the records (OutboxEntry, SpawnEntry, TimerOp,
ExecutionSummary, ExecutionPage), the errors (StoreConflict, ExecutionAlreadyExists) and
the helpers the built-in backends share (encode_offset/decode_offset for the listing cursor,
matches, listing_page, like_prefix), and Step — the description of each commit every
writer passes to commit(..., step=): a store must take it, may ignore it, and can act on it
(see observing executions).
harel.testing holds the contracts harel runs on its own backends; run them on yours, one call
each:
from harel.testing import assert_listing_contract, assert_outbox_contract, assert_purge_contract
from harel.testing import assert_definitions_contract
def test_my_store():
assert_listing_contract(MyStore(), ordered=True) # ordered=False for an unordered scan
assert_purge_contract(MyStore())
assert_outbox_contract(MyStore())
assert_definitions_contract(MyStore())
Each has an assert_async_* twin for an async store, and takes ns= to prefix the ids it seeds
on a backend shared with other data. When the protocol changes, they are what tells you.
Execution trace (opt-in)¶
When tracing is on (HAREL_TRACE=1, or DurableRunner(..., trace=True)), commit also appends
one timeline step — event in, transition from → to, the actions that ran, what a set
wrote (assigned, only when there was one), and the resulting context_out — in the same
transaction as the advance. It is off by default, so
the hot path pays nothing; when on it costs ~one extra in-transaction write (~+10 µs/commit on a
local SQLite, no extra round-trip or fsync), and load is unaffected (it still reads the
snapshot — there is no event replay). Each backend keeps a ring of the last HAREL_TRACE_MAX
steps (default 200) using its natural primitive (a capped table, an LTRIM’d list, a
$slice’d array, a Put+Delete window); the monitor renders it as the timeline. Only
context_out is stored (the monitor derives each step’s context_in from the previous step).
from harel import definition_from_dsl, DurableRunner, SqliteStore, Event
defn = definition_from_dsl(
"event Go {} machine m { initial A state A {} final Done success {} from A to Done on Go }", "m"
)
store = SqliteStore(":memory:")
runner = DurableRunner(store, {defn.id: defn}, trace=True) # opt-in
exe = runner.create(defn.id)
runner.process(exe.id, Event(kind="Go"))
for step in store.read_trace(exe.id):
print(step["index"], step["event_kind"], step["from_path"], "->", step["to_path"])
0 Start None -> A
1 Go A -> Done
The backends¶
Each backend has its own page with the full schema/key-space and every operation broken down with the real SQL/commands and the reasoning:
- DictStore — in-memory (default)
- SqliteStore — one machine, zero infrastructure
- LibsqlStore — libSQL / Turso (experimental)
- RedisStore — all-network, no shared filesystem
- PostgresStore — distributed SQL
- RqliteStore — distributed SQLite over Raft
- MongoStore — document store
- DynamoDBStore — AWS serverless
Backend |
For |
CAS mechanism |
|---|---|---|
tests, embedding (in-memory, not durable) |
object identity |
|
one machine / shared volume, zero infra |
|
|
libSQL/Turso — file, |
|
|
all-network, pairs with |
atomic Lua CAS (state-only fast path); |
|
distributed SQL |
|
|
HA SQLite over Raft (HTTP) |
guarded upsert, one request |
|
document store, single-document atomic |
|
|
AWS serverless, pairs with |
conditional write + |
What each backend supports¶
Every backend implements the whole protocol, sync and async, and passes every contract in
harel.testing (listing, purge, outbox, published definitions) — in CI for the in-process ones,
in the stack (make test-stack) against the real servers. They differ in two places only:
Dict |
SQLite |
libSQL |
rqlite |
Postgres |
Mongo |
Redis |
DynamoDB |
|
|---|---|---|---|---|---|---|---|---|
durable |
— |
✓ |
✓ |
✓ |
✓ |
✓ |
✓ |
✓ |
|
— / — |
✓ / ✓ |
✓ / ✓ |
✓ / ✓ |
✓ / ✓ |
✓ / — |
✓ / — |
✓ / ✓ |
|
— |
async |
✓ |
async |
async |
async |
async |
async |
Dict keeps everything in the process: tests and embedding, nothing to name or create.
Mongo and Redis have no schema to create: a Mongo collection and a Redis key come into being with their first write, so they take a
prefixbut nocreate_schema.load_for_eventis an optional optimization: a worker that finds it folds the dedupe check into its load, else it makes two calls. The sync ports run only underexecution="inline".
Naming and schema ownership¶
Every persistent backend takes a prefix (default "harel") and names everything it creates
with it, so several harel deployments — or harel and other applications — share one database
without colliding:
backend |
what |
|---|---|
SQLite, libSQL, rqlite, Postgres |
tables |
Redis |
every key: |
Mongo |
collections (under |
DynamoDB |
tables |
A prefix is an identifier — a letter or _, then letters, digits or _ — of at most 30
characters (so every name built from it fits Postgres’ 63). harel.engine.schema.Names(prefix)
gives each name.
Every backend that has a schema also takes create_schema (default True): it creates its
tables, indexes and functions when it is built, idempotently. With create_schema=False it
creates nothing and expects them to be there — when something else owns the schema: a migration
tool, infrastructure-as-code. For the SQL backends the schema is data:
from harel.engine.schema import SCHEMA_VERSION, sql_schema
statements = sql_schema("postgres", prefix="harel") # or "sqlite" (SQLite, libSQL, rqlite)
assert any("CREATE TABLE IF NOT EXISTS harel_executions" in s for s in statements)
sql_schema returns the statements the backends run themselves — the store’s and the
transport’s (store_schema / transport_schema for one of them) — each idempotent (IF NOT EXISTS, CREATE OR REPLACE, and DROP … IF EXISTS for what an earlier version created). Hand them to the migration tool, and build the backends with
create_schema=False. SCHEMA_VERSION changes whenever the schema does, and the CHANGELOG says
how to move from one version to the next. The worker reads both settings from HAREL_PREFIX and
HAREL_CREATE_SCHEMA ("0"/"false" to turn it off).
Upgrading a deployment from before 0.7, whose tables had no prefix: see upgrading to 0.7.
Async ports¶
Every store has an Async… twin under harel/engine/aio_store/ with the same data model —
the async worker (HAREL_CONCURRENCY events in flight on one loop) talks to those. Most are native
async (aiosqlite, redis.asyncio, psycopg async pool, motor, aioboto3, httpx for rqlite);
AsyncLibsqlStore wraps the sync driver on a thread (the libsql package is sync-only). The
synchronous Store classes are what the sync DurableRunner/DistributedRunner and the monitor
use — bridged to the async core through the anyio portal by default, or called directly in the
caller’s thread with execution="inline" (see execution models). Each per-backend page has an
Async twin section with the specifics.