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 STM_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

load(id) / load_for_event(id, event_id)

rehydrate the Execution (the latter folds the dedupe check into the same round-trip)

save(exe)

persist with the version CAS (raises StoreConflict on a stale write)

commit(exe, emits, processed_event_id, timers, spawns, trace)

the one atomic write per event — the Execution + outbox + dedupe + timer ops + spawn intents + (opt-in) trace step, all-or-nothing

is_processed(id, event_id)

dedupe lookup

pending_outbox() / ack_outbox(seq)

the relay drains emitted events

pending_spawns() / ack_spawn(seq)

the relay drains orthogonal child-creation intents

due_timers(now) / delete_timer(id, path, fire_at)

the timer sweep

read_trace(id) / append_trace(id, entry)

the opt-in execution timeline (see below)

list_executions(...)

a page of lightweight summaries for the monitor

close()

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 version per record; the write applies only if the stored version still matches, else StoreConflict. 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 Finished or a fork.

  • Dedupeprocessed_events (id + event id) makes at-least-once delivery effect-once.

  • Timers(execution_id, path) fire_at, armed/cancelled in the same commit.

Execution trace (opt-in)

When tracing is on (STM_TRACE=1, or DurableRunner(..., trace=True)), commit also appends one timeline step — event in, transition from to, the actions that ran, 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 STM_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:

Backend

For

CAS mechanism

DictStore

tests, embedding (in-memory, not durable)

object identity

SqliteStore

one machine / shared volume, zero infra

UPDATE WHERE version=old

LibsqlStore

libSQL/Turso — file, sqld, or embedded replica (experimental)

UPDATE WHERE version=old

RedisStore

all-network, pairs with RedisTransport

atomic Lua CAS (state-only fast path); WATCH/MULTI/EXEC for complex commits

PostgresStore

distributed SQL

UPDATE WHERE version=old (row lock); state-only commit folds into one harel_commit_cas round-trip

RqliteStore

HA SQLite over Raft (HTTP)

guarded upsert, one request

MongoStore

document store, single-document atomic

update_one({_id, version})

DynamoDBStore

AWS serverless, pairs with SqsTransport

conditional write + TransactWriteItems

Async ports

Every store has an Async… twin under harel/engine/aio_store/ with the same data model — the async worker (STM_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 embedded DurableRunner/DistributedRunner façades and the monitor use, bridged to the async core through the anyio portal. Each per-backend page has an Async twin section with the specifics.