DictStore — in-memory (default)¶
DictStore is the in-memory ExecutionStore: a handful of plain Python dicts, lists and
sets living in the process. It is the default backend for embedded, non-durable runs —
tests, scenarios, notebooks, single-process hosts. Its defining property is that load
returns the same Execution object that was handed to save/commit: there is no
serialization round-trip, no JSON blob, no copy. A caller that holds a reference to an
Execution sees every mutation the engine makes, and load hands back that identical object
— the identity contract the in-place test harness relies on. Nothing survives the process
exiting.
Data model¶
DictStore.__init__ allocates exactly these structures (all live on self):
self._by_id : dict[str, Execution] # execution_id -> the live Execution object
self._outbox : list[OutboxEntry] # FIFO of deferred events awaiting delivery
self._processed: set[tuple[str, str]] # {(execution_id, event_id)} already-handled marks
self._timers : dict[tuple[str, str], float] # (execution_id, path) -> fire_at (epoch seconds)
self._spawns : list[SpawnEntry] # FIFO of pending child-Execution creations
self._trace : dict[str, list[dict]] # execution_id -> ordered trace steps (capped ring)
self._trace_idx: dict[str, int] # execution_id -> next monotonic step index
self.trace_max : int = DEFAULT_TRACE_MAX # ring size (200); 0/None disables trimming
self._seq : int = 0 # monotonic counter for outbox seq numbers
self._spawn_seq: int = 0 # monotonic counter for spawn seq numbers
What each one holds and why:
_by_id— the entire authoritative state of the store. Maps an Execution id to the liveExecutioninstance (seeexecution.py:id,definition_id,status,outcome,active_path,history,context,version, the orthogonalparent_id/child_id/children, etc.). Because the value is the object itself, no deserialization is needed and identity is preserved acrosssave/load._outbox— the transactional outbox: a FIFO list ofOutboxEntry(seq, target_id, event).commitappends an entry for every emitted event so delivery happens after the Execution is durably saved (here: after the dict is mutated), never inline. A relay drains it viapending_outbox/ack_outbox.target_idis the Execution to deliver to (orNonefor an untargeted event);seqis a monotonic ack token from_seq._processed— the dedupe set for at-least-once delivery. Holds(execution_id, event_id)pairs. If a redelivered event’s id is already in the set, the worker skips it. Populated bycommit’sprocessed_event_id, queried byis_processed._timers— durable timers, keyed by(execution_id, path)mapping to the absolutefire_at(epoch seconds). The composite key means re-entry replaces: re-arming the timer for the same state path overwrites the priorfire_atrather than stacking timers. ATimerOp(action="schedule")upserts;action="cancel"removes._spawns— the spawn outbox: a FIFO ofSpawnEntry(seq, parent_id, child_id, root_path, context), the orthogonal-fork sibling of_outbox. When a parent enters an AND-state,commitrecords the parent’s advance + its join expectations (thechildrendict on the Execution) and the intents to create each region child, all in one shot. A relay later creates the children idempotently viapending_spawns/ack_spawn.seqcomes from_spawn_seq._trace— the opt-in execution timeline:execution_id -> list[dict], one dict per recorded step (event/transition/actions/context_out plus a stampedindex). Capped to the lasttrace_maxsteps per id (a ring). This is a side-channel for monitoring;loadnever reads it and is unaffected by it._trace_idx—execution_id -> next monotonic step index. Survives ring trimming, so the stampedindexkeeps climbing (0, 1, 2, …) even after old steps are dropped — readers see a stable, gap-free-from-the-store’s-view ordering of recent steps.trace_max— the ring size, defaulting toDEFAULT_TRACE_MAX = 200(from_base.py). A truthy value trims;0/Nonedisables trimming (unbounded)._seq/_spawn_seq— independent monotonic counters incremented before each append, so the first outbox/spawn entry getsseq == 1. They give every entry a stable ack token.
Operations¶
load(execution_id) -> Optional[Execution]¶
return self._by_id.get(execution_id)
Returns the live object (or None). No copy, no deserialization — the caller gets the
exact instance under that id. This is what makes the store’s identity contract hold.
save(exe) -> None — the CAS check¶
prev = self._by_id.get(exe.id)
if prev is not None and prev is not exe and prev.version != exe.version:
raise StoreConflict(exe.id, expected=exe.version, found=prev.version)
exe.version += 1
self._by_id[exe.id] = exe
This is the optimistic-concurrency (CAS) gate, the single-writer backstop. The condition
prev is not None and prev is not exe and prev.version != exe.version bites only when a
different object is already stored under the same id at a different version — i.e. a
genuine concurrent writer that loaded the row, advanced it, and committed while this caller
held a stale copy. It raises StoreConflict(execution_id, expected, found) so the caller can
reload and retry (or drop the stale work).
Three deliberate exemptions:
Same object (
prev is exe): the common embedded case where the engine mutates the very object the store holds.prev is not exeis false, so the check is skipped and the write always wins — there is no other writer.First write (
prev is None): nothing to conflict with.Different object, same version: not a conflict — it’s a legitimate replacement at the expected version.
On success exe.version is bumped in place to the committed value and the dict entry is set.
commit(exe, emits, processed_event_id=None, timers=(), spawns=(), trace=None)¶
The atomic event-boundary write. Because everything is in-process and synchronous, “atomic” here means it all runs without interleaving. Step by step:
CAS first —
self.save(exe). This raisesStoreConflictbefore any emit is enqueued, so a losing writer never pollutes the outbox.Outbox append — for each
(target_id, event)inemits: bump_seq, appendOutboxEntry(self._seq, target_id, event). Deferred delivery, post-save.Dedupe — if
processed_event_id is not None, add(exe.id, processed_event_id)to_processed, marking the just-handled event so a redelivery is skipped.Timer ops — for each
TimerOpintimers:schedulesetsself._timers[(exe.id, op.path)] = op.fire_at(upsert/replace); anything else (cancel) doesself._timers.pop((exe.id, op.path), None).Spawn append — for each
(child_id, root_path, context)inspawns: bump_spawn_seq, appendSpawnEntry(self._spawn_seq, exe.id, child_id, root_path, dict(context)). Notedict(context)— the context is copied here so the spawn intent is snapshotted independently of later parent mutations (one of the few copies the store makes).Trace record — if
trace is not None,self._record_trace(exe.id, trace).
Why one call: the parent’s advance, its join expectations, the events it emitted, the dedupe mark, the timers it armed/cancelled, and the children it wants forked must all land together or not at all. The CAS guards the whole boundary.
is_processed(execution_id, event_id) -> bool¶
return (execution_id, event_id) in self._processed
The dedupe query. True iff that exact pair was recorded by a prior commit.
pending_outbox() -> list[OutboxEntry] / ack_outbox(seq)¶
pending_outbox: return list(self._outbox) # a copy, oldest first
ack_outbox: self._outbox = [e for e ... e.seq != seq] # drop the acked entry
The relay reads pending entries, delivers them, then acks each by seq — ack_outbox
rebuilds the list without that entry. Returning a copy means iterating the relay’s snapshot
is safe even if commit appends concurrently (single loop, but still defensive).
pending_spawns() -> list[SpawnEntry] / ack_spawn(seq)¶
pending_spawns: return list(self._spawns) # a copy, oldest first
ack_spawn: self._spawns = [s for s ... s.seq != seq] # drop the acked spawn
The orthogonal-fork twin of the outbox pair. The relay drains pending spawns, creates each
child Execution idempotently (skip if it already exists), then acks by seq.
due_timers(now) -> list[tuple[str, str, float]]¶
return [(eid, path, fa) for (eid, path), fa in self._timers.items() if fa <= now]
Returns every armed timer whose fire_at <= now, as (execution_id, path, fire_at). The
sweep calls this on the idle path and delivers a Timeout event for each.
delete_timer(execution_id, path, fire_at)¶
if self._timers.get((execution_id, path)) == fire_at:
del self._timers[(execution_id, path)]
Removes the timer only if it still holds the exact fire_at that fired. This guard is the
point: if the model re-scheduled the same (execution_id, path) to a new time between the
sweep reading it and deleting it, the stored fire_at no longer matches, the delete is a
no-op, and the freshly re-armed timer survives a stale sweep.
Trace: _record_trace / append_trace / read_trace¶
_record_trace(execution_id, entry):
idx = self._trace_idx.get(execution_id, 0)
self._trace_idx[execution_id] = idx + 1
steps = self._trace.setdefault(execution_id, [])
steps.append({**entry, "index": entry.get("index", idx)})
if self.trace_max and len(steps) > self.trace_max: # ring: keep only the last N
del steps[: len(steps) - self.trace_max]
append_trace(execution_id, entry): self._record_trace(execution_id, entry) # demo/test seam
read_trace(execution_id): return list(self._trace.get(execution_id, [])) # a copy
_record_trace is the internal recorder commit calls; append_trace is the public
demo/test seam that delegates to it; read_trace returns a copy of the per-id step list (or
[]).
The trace ring¶
_record_trace assigns each step a monotonic per-id index and trims the list to a ring:
Read the next index for this id from
_trace_idx(default0).Advance
_trace_idx[execution_id]by 1 — so the counter keeps climbing regardless of trimming.Append
{**entry, "index": entry.get("index", idx)}: the caller’s entry, stamped with anindexif it didn’t already carry one (an explicitindexin the entry is honored).If
trace_maxis truthy and the list now exceeds it,del steps[: len(steps) - trace_max]drops the oldest overflow, keeping exactly the lasttrace_maxsteps.
The result: _trace[id] is a bounded window of the most recent steps, but the stamped
index values reflect the true monotonic position in the full timeline (older indices are
simply no longer present once trimmed). Default window: 200 steps.
Async twin¶
harel/engine/aio_store/dict.py defines AsyncDictStore, a faithful mirror of DictStore:
Identical structures — the same
_by_id,_outbox,_processed,_timers,_spawns,_trace,_trace_idx,trace_max,_seq,_spawn_seq, with the same shapes and the sameDEFAULT_TRACE_MAX.Identical logic — the same CAS condition, the same commit ordering (CAS first, then outbox/dedupe/timers/spawns/trace), the same
delete_timerguard, the same ring trimming.async defsurface —load,save,commit,is_processed,pending_outbox,ack_outbox,pending_spawns,ack_spawn,due_timers,delete_timer,close, and the trace seamsread_trace/append_traceare coroutines, to satisfy theAsyncExecutionStorecontract.No lock — and none is needed. A single event loop schedules cooperatively, and none of these methods
awaitinternally, so each method body runs atomically between suspension points. There is no point at which another task can interleave mid-method, so the dict mutations are race-free without explicit locking. (_record_tracestays a plaindef, called synchronously from insidecommit/append_trace.)
Why it’s not durable¶
All state lives in the process’s _by_id/_outbox/_timers/_spawns/_processed/_trace
dicts. When the process exits, it’s gone — there is no file, no socket, no JSON blob, no fsync.
close() is a no-op precisely because there is nothing to release. That is the trade: zero
serialization cost and live-object identity in exchange for no persistence and no
cross-process sharing.
For durability and multi-process operation, use a durable backend (SQLite for one machine;
Redis / Postgres / rqlite for distributed) — they serialize the Execution to a JSON blob with
a broken-out version column and implement the same ExecutionStore Protocol, so swapping is
a one-line change. See the stores hub and durability.