SqliteStore — one machine, zero infrastructure¶
SqliteStore is the durable ExecutionStore backend for the single-host case: one
process (or several processes on the same box / a shared volume) running statecharts that must
survive a restart with no external service to operate. It is the simplest durable backend
and the golden exemplar for the SQL family — PostgresStore and RqliteStore are the same
shape over a networked engine. If you only need throwaway state, point it at :memory:; if you
need persistence, point it at a file path.
What it is and how it stores things¶
Engine: the Python stdlib
sqlite3module — no driver to install, no server to run. The connection is opened once in__init__and reused for every operation:sqlite3.connect(str(path), check_same_thread=False)(thecheck_same_thread=Falselets the same connection be used from a worker thread, e.g. the timer sweep).Storage model: each
Executionis stored as one row inexecutions, keyed by itsid, with the whole object serialized to JSON in thedatacolumn (exe.model_dump_json()). Reloading rehydrates it withExecution.model_validate_json(...). A freshSqliteStoreopened on the same file reads the committed rows, so a run resumes after a process restart.One write per event is one transaction. The central method,
commit, performs the CAS write of the Execution and every side-effect of that step (outbox events, the dedupe marker, timer arms/disarms, spawn intents, the optional trace step) and then callsself._conn.commit()once. Either all of it lands or none of it does — that all-or-nothing property is what makes a crash safe (the state and everything the engine deferred are a single durable unit). See durability.PRAGMAs. Two are set at open time:
PRAGMA journal_mode=WAL -- readers don't block the single writer PRAGMA busy_timeout=5000 -- wait up to 5s for the write-lock instead of erroring SQLITE_BUSY
WAL (write-ahead logging) lets the monitor’s
list_executions/loadread while a writer holds the write-lock;busy_timeoutmakes a contended writer wait for the lock rather than immediately raisingdatabase is locked.
Schema¶
Six tables, all created with CREATE TABLE IF NOT EXISTS in __init__ and then committed.
executions — the durable state¶
CREATE TABLE IF NOT EXISTS executions
(id TEXT PRIMARY KEY, definition_id TEXT NOT NULL, data TEXT NOT NULL,
version INTEGER NOT NULL)
Column |
Type |
Meaning |
|---|---|---|
|
TEXT, PRIMARY KEY |
the |
|
TEXT NOT NULL |
the id of the |
|
TEXT NOT NULL |
the full Execution serialized, |
|
INTEGER NOT NULL |
the optimistic-concurrency token — a broken-out copy of |
data is the source of truth for the Execution; definition_id and version are denormalized
out of it purely so the hot paths (CAS, listing) don’t have to parse JSON.
outbox — the transactional event outbox¶
CREATE TABLE IF NOT EXISTS outbox
(seq INTEGER PRIMARY KEY AUTOINCREMENT, target_id TEXT, event TEXT NOT NULL)
Column |
Type |
Meaning |
|---|---|---|
|
INTEGER, PRIMARY KEY AUTOINCREMENT |
monotonic delivery order; the ack key. |
|
TEXT (nullable) |
the Execution to deliver the event to ( |
|
TEXT NOT NULL |
the |
Events emitted by a step are not delivered inline; they are inserted here in the same
transaction as the state advance, and a relay drains them after commit (pending_outbox /
ack_outbox). A crash between the state write and the delivery cannot lose an emitted
Finished — it is already durable in this table.
processed_events — at-least-once dedupe¶
CREATE TABLE IF NOT EXISTS processed_events
(execution_id TEXT NOT NULL, event_id TEXT NOT NULL,
PRIMARY KEY (execution_id, event_id))
Column |
Type |
Meaning |
|---|---|---|
|
TEXT NOT NULL |
the Execution that handled the event. |
|
TEXT NOT NULL |
the |
— |
PRIMARY KEY (execution_id, event_id) |
one marker per (execution, event). |
Under at-least-once delivery the same event can arrive twice; recording its id here (and
checking it on load) makes the effect take hold once. The composite primary key is what
makes the INSERT OR IGNORE in commit a no-op on a redelivery.
timers — durable timers¶
CREATE TABLE IF NOT EXISTS timers
(execution_id TEXT NOT NULL, path TEXT NOT NULL, fire_at REAL NOT NULL,
PRIMARY KEY (execution_id, path))
Column |
Type |
Meaning |
|---|---|---|
|
TEXT NOT NULL |
the Execution that armed the timer. |
|
TEXT NOT NULL |
the |
|
REAL NOT NULL |
wall-clock epoch seconds at which the |
— |
PRIMARY KEY (execution_id, path) |
one live timer per state — re-arming the same path replaces it (the upsert in |
A timeout: state arms a timer on enter and cancels it on exit, both applied in the same
commit as the transition that entered/exited it. The sweep (due_timers) finds rows whose
fire_at <= now and publishes a Timeout event.
spawns — the orthogonal-fork spawn outbox¶
CREATE TABLE IF NOT EXISTS spawns
(seq INTEGER PRIMARY KEY AUTOINCREMENT, parent_id TEXT NOT NULL, child_id TEXT NOT NULL,
root_path TEXT NOT NULL, context TEXT NOT NULL)
Column |
Type |
Meaning |
|---|---|---|
|
INTEGER, PRIMARY KEY AUTOINCREMENT |
monotonic order; the ack key. |
|
TEXT NOT NULL |
the orthogonal parent Execution forking the child. |
|
TEXT NOT NULL |
the deterministic id of the child Execution to create. |
|
TEXT NOT NULL |
the branch node the child runs (its |
|
TEXT NOT NULL |
the child’s seed context, JSON ( |
Entering an AND-state does not create the region children inline; it enqueues one spawn
intent per region here, committed atomically with the parent’s advance and its children join
expectations. A relay then creates each child idempotently (pending_spawns / ack_spawn).
This mirrors the event outbox and is what makes a crash-during-fork safe.
trace — the execution-timeline ring (opt-in)¶
CREATE TABLE IF NOT EXISTS trace
(execution_id TEXT NOT NULL, idx INTEGER NOT NULL, entry TEXT NOT NULL,
PRIMARY KEY (execution_id, idx))
Column |
Type |
Meaning |
|---|---|---|
|
TEXT NOT NULL |
the Execution whose timeline this step belongs to. |
|
INTEGER NOT NULL |
the monotonic step index within that Execution (assigned inline, see below). |
|
TEXT NOT NULL |
one step (event/transition/actions/context_out), JSON. |
— |
PRIMARY KEY (execution_id, idx) |
one row per step. |
Off by default; written by commit only when a trace step is passed. The store keeps a ring
of the last trace_max steps per Execution (DEFAULT_TRACE_MAX = 200). Powers the monitor’s
timeline without affecting load (which still reads the snapshot, not a replay).
The CAS write (_write)¶
_write is the heart of the optimistic-concurrency guarantee. It is deliberately separate
from the commit of the transaction so it can be batched with the rest of commit’s
statements:
UPDATE executions SET data = ?, version = ? WHERE id = ? AND version = ?
The code:
old = exe.version
exe.version = old + 1
data = exe.model_dump_json()
cur = self._conn.execute(
"UPDATE executions SET data = ?, version = ? WHERE id = ? AND version = ?",
(data, exe.version, exe.id, old),
)
if cur.rowcount == 0:
found = self._conn.execute("SELECT version FROM executions WHERE id = ?", (exe.id,)).fetchone()
if found is None and old == 0:
self._conn.execute(
"INSERT INTO executions (id, definition_id, data, version) VALUES (?, ?, ?, ?)",
(exe.id, exe.definition_id, data, exe.version),
)
else:
exe.version = old # undo the in-memory bump; the commit did not happen
raise StoreConflict(exe.id, expected=old, found=found[0] if found else None)
Step by step:
Capture
old = exe.version, then bumpexe.version = old + 1. The bump happens before the dump so the JSON indatacarries the new version too (the blob and the broken-outversioncolumn stay consistent — the blob’s ownversionfield equals the column).The CAS: the
UPDATE ... WHERE id = ? AND version = ?matches the row only if its storedversionis stillold. If another writer advanced the row in the meantime, theWHEREmatches nothing androwcount == 0.rowcount == 0disambiguation. Zero matched rows means one of two things, distinguished by a follow-upSELECT version:Brand-new Execution (
found is None and old == 0): no row existed yet and we are at the initial version — so this is the first save. Do theINSERT.Stale write (a row exists but moved past
old, or it’s missing withold != 0): another writer won. Roll back the in-memory bump (exe.version = old, since this transaction will not commit) and raiseStoreConflict(exe.id, expected=old, found=...).
_writedoes not commit. It only issues statements on the connection. That is the whole point:commitcan run_writeplus the outbox/dedupe/timer/spawn/trace statements and then issue oneself._conn.commit(), so they are atomic together.StoreConflictis the single-writer-per-Execution backstop — the caller (a runner/worker) reloads and retries, or drops the stale work.
save is the standalone version used outside an event step: _write then self._conn.commit(),
with except StoreConflict: self._conn.rollback(); raise.
commit — the one atomic transaction¶
commit is the only write path on the hot loop. It issues a sequence of statements on the open
connection and commits them as one transaction, so the state advance and every deferred
effect of that event are a single durable unit:
def commit(self, exe, emits, processed_event_id=None, timers=(), spawns=(), trace=None):
try:
self._write(exe) # 1. CAS the Execution (no commit yet)
for target_id, event in emits: # 2. enqueue emitted events (outbox)
self._conn.execute(
"INSERT INTO outbox (target_id, event) VALUES (?, ?)",
(target_id, event.model_dump_json()),
)
if processed_event_id is not None: # 3. record this event handled (dedupe)
self._conn.execute(
"INSERT OR IGNORE INTO processed_events (execution_id, event_id) VALUES (?, ?)",
(exe.id, processed_event_id),
)
for child_id, root_path, context in spawns: # 4. enqueue orthogonal child creations
self._conn.execute(
"INSERT INTO spawns (parent_id, child_id, root_path, context) VALUES (?, ?, ?, ?)",
(exe.id, child_id, root_path, json.dumps(context)),
)
for op in timers: # 5. arm / disarm durable timers
if op.action == "schedule":
self._conn.execute(
"INSERT INTO timers (execution_id, path, fire_at) VALUES (?, ?, ?) "
"ON CONFLICT(execution_id, path) DO UPDATE SET fire_at = excluded.fire_at",
(exe.id, op.path, op.fire_at),
)
else:
self._conn.execute(
"DELETE FROM timers WHERE execution_id = ? AND path = ?", (exe.id, op.path)
)
if trace is not None: # 6. append a trace step (opt-in)
self._write_trace(exe.id, trace)
self._conn.commit() # 7. ONE commit: all-or-nothing
except StoreConflict:
self._conn.rollback() # CAS lost: discard the whole batch
raise
Statement by statement:
_write(exe)— the CASUPDATE(orINSERTfor a brand-new Execution). If it raisesStoreConflict, no further statements run; theexceptrolls back and re-raises.Outbox INSERTs — one row per emitted event. Deferred delivery, durable before the relay touches it.
INSERT OR IGNOREintoprocessed_events— mark the event that drove this step as handled.OR IGNOREmakes a redelivery a no-op against the composite PK, so dedupe is idempotent.Spawn INSERTs — one row per orthogonal child to create. Committed together with the parent’s
childrenjoin expectations (which live insideexe’sdata), so the fork and its bookkeeping are atomic.Timer ops — a
scheduleis an upsert:ON CONFLICT(execution_id, path) DO UPDATE SET fire_at = excluded.fire_at, so re-arming the same state replaces the prior fire time (the(execution_id, path)PK guarantees one live timer per state). Acancelis aDELETEby(execution_id, path). Both are part of the same transaction as the transition that armed or disarmed them — a scheduled timer can never be lost relative to the state that scheduled it._write_trace— append the opt-in trace step (see next section). Only whentraceis given.The single
self._conn.commit()— flushes the whole batch atomically.
The except StoreConflict: self._conn.rollback(); raise is the only failure branch: if the CAS
lost, the entire batch (state, outbox, dedupe, timers, spawns, trace) is discarded together.
Because all of this is one transaction, a crash at any point leaves the store either fully
at the prior step or fully at the new one — never half-applied (e.g. a state advanced but its
Finished lost, or a fork’s children created but the parent’s join expectations missing). This
is the durability contract; the relay and sweep below complete the at-least-once delivery
after commit.
The trace ring (_write_trace)¶
_write_trace appends one timeline step without committing (so it batches into commit’s
transaction). It is two statements:
INSERT INTO trace (execution_id, idx, entry)
SELECT ?, COALESCE((SELECT MAX(idx) FROM trace WHERE execution_id = ?), -1) + 1, ?
then, if trace_max is set, the ring cap:
DELETE FROM trace WHERE execution_id = ? AND idx <=
(SELECT MAX(idx) FROM trace WHERE execution_id = ?) - trace_max
Why it is shaped this way:
idxis computed inline withCOALESCE(MAX(idx), -1) + 1rather than read first and passed in. There is no pre-read round-trip, and the value is monotonic per Execution. Crucially it is computed offMAX(idx), not off a row count — so it keeps climbing even after the ringDELETEremoves old rows (a count-based index would collide with a surviving row after eviction). The first step getsidx = 0(COALESCE(NULL, -1) + 1).The cap deletes everything whose
idxis at leasttrace_maxbehind the current max, keeping only the lasttrace_maxsteps. WithDEFAULT_TRACE_MAX = 200the timeline is a 200-step ring per Execution.read_tracere-stamps the index from theidxcolumn, so the stored JSONentryneed not carry its own index:SELECT idx, entry FROM trace WHERE execution_id = ? ORDER BY idx -> [{**json.loads(entry), "index": idx} for idx, entry in rows]
append_trace is the demo/test seam — it writes a step and commits it on its own (used to
seed a timeline outside a real event step):
def append_trace(self, execution_id, entry):
self._write_trace(execution_id, entry)
self._conn.commit()
Reads and sweeps¶
load¶
SELECT data FROM executions WHERE id = ?
Returns Execution.model_validate_json(row[0]) or None if no row. The full snapshot, rehydrated.
load_for_event (async only)¶
The sync SqliteStore has no load_for_event; the worker uses load + is_processed
separately. The async twin folds both into one round-trip (see Async twin below). The
ExecutionStore Protocol lists load_for_event as optional sugar.
list_executions¶
The monitor/list view. It never selects the heavy data blob; it projects only the scalar
summary fields, reaching inside the JSON with json_extract for the ones that aren’t broken-out
columns:
SELECT id, definition_id, version, json_extract(data,'$.status'),
json_extract(data,'$.outcome'), json_extract(data,'$.active_path'),
json_extract(data,'$.parent_id') FROM executions
WHERE <conditions> ORDER BY id LIMIT ? OFFSET ?
WHERE building. Conditions start at
["1=1"]and accumulate:definition_id = ?(exact match, a real column);json_extract(data,'$.status') IN (?, ?, ...)for a status set (OR);json_extract(data,'$.parent_id') IS NULLwhenroots_only. Becausestatus/outcome/parent_idlive inside the blob,json_extractis how the filters reach them — no separate columns needed.Pagination. The cursor is an opaque base64 of an integer offset (
_decode_offset/_encode_offset). It fetcheslimit + 1rows: if more thanlimitcome back there is a next page, so it returns the firstlimitasExecutionSummaryitems and anext_cursorencodingoff + limit; otherwisenext_cursorisNone. Ordering is stable byid.
is_processed¶
SELECT 1 FROM processed_events WHERE execution_id = ? AND event_id = ?
Returns True if the marker exists — the dedupe lookup under at-least-once delivery.
pending_outbox / ack_outbox¶
SELECT seq, target_id, event FROM outbox ORDER BY seq -- oldest first
DELETE FROM outbox WHERE seq = ? -- then commit
pending_outbox returns the undelivered OutboxEntry list (oldest first), deserializing each
event. ack_outbox(seq) removes a delivered entry and commits on its own (the relay’s ack
is its own small transaction, independent of the event step that enqueued it).
pending_spawns / ack_spawn¶
SELECT seq, parent_id, child_id, root_path, context FROM spawns ORDER BY seq
DELETE FROM spawns WHERE seq = ? -- then commit
pending_spawns returns SpawnEntry rows (oldest first), json.loads-ing the context.
ack_spawn(seq) removes a created child’s intent and commits. The relay creates each child
idempotently before acking, so a crash between create and ack just retries a harmless create.
due_timers / delete_timer¶
SELECT execution_id, path, fire_at FROM timers WHERE fire_at <= ? ORDER BY fire_at
due_timers(now) returns the timers whose fire_at <= now as (execution_id, path, fire_at),
soonest first — the sweep publishes a Timeout for each.
DELETE FROM timers WHERE execution_id = ? AND path = ? AND fire_at = ?
delete_timer is guarded on fire_at: it deletes the row only if it still holds the exact
fire time the sweep saw. This is the subtle, important bit — if the model re-scheduled the
same (execution_id, path) to a new time between the sweep reading it and acting on it, the
fire_at no longer matches, the DELETE removes nothing, and the freshly-armed timer survives
the stale sweep instead of being silently dropped. It commits on its own.
close¶
self._conn.close() — releases the connection.
Async twin (aio_store/sqlite.py)¶
AsyncSqliteStore is a faithful mirror of SqliteStore over aiosqlite — same schema,
same SQL, same CAS, same one-transaction commit. The differences are mechanical:
Every DB call is
awaited (await self._conn.execute(...),await cur.fetchone(),await self._conn.commit()).Atomicity still holds.
aiosqliteserializes a connection’s operations on its own worker thread, so the multi-statementcommitruns as one ordered batch on that thread and remains a single atomic transaction — exactly like the sync version. Theawaits interleave with other tasks at the event loop, not mid-transaction.Construction is async: the connection must be awaited open, so you build it with
await AsyncSqliteStore.create(path)(a classmethod that opens the connection, sets the PRAGMAs, creates the tables, commits, and returns the instance).__init__just stores the connection and setsself.trace_max = DEFAULT_TRACE_MAX(the sync version sets it after theCREATE TABLEs; the async version sets it in__init__).It additionally provides
load_for_event— load + dedupe-check in one round-trip (the worker’s per-event pair), a single query that selects the data and anEXISTSoverprocessed_eventstogether:SELECT (SELECT data FROM executions WHERE id = ?), EXISTS(SELECT 1 FROM processed_events WHERE execution_id = ? AND event_id = ?)It returns
(Execution | None, already_processed: bool); if the data sub-select isNULL(no such Execution) it returns(None, False).
:memory: works in both as a non-persistent variant for tests.
When to pick it / tradeoffs¶
Pick SqliteStore when:
You run on one host (or several processes over a shared volume) and want durability with zero infrastructure — no server, no driver to install.
A single writer per Execution is acceptable: WAL gives concurrent readers, but SQLite has one writer at a time, and the version CAS is the single-writer-per-Execution backstop. This is exactly the model the engine assumes.
You want the simplest thing that survives a restart, or
:memory:for fast tests.
Reach for a networked backend instead when you need many hosts writing concurrently
(horizontal scale, no shared filesystem): PostgresStore (same SQL shape, server-side CAS via
UPDATE ... WHERE version) or RqliteStore (distributed SQLite over Raft). Those are the same
design as this page, just over a network engine — SqliteStore is the reference to read first.
See the stores hub for the full backend matrix and the
STM_STORE_BACKEND selector, and durability for the
crash-safety guarantees this backend implements.