MongoStore — document store¶
MongoStore is a durable ExecutionStore (store/mongo.py)
that keeps every Execution, its transactional outbox, its pending orthogonal-fork spawns, its dedupe
marks, its durable timers and its optional execution trace in MongoDB — over the network, with no
shared filesystem. Like RedisStore it is all-network: there is no local file and no shared
volume, so the same store works across machines and containers as long as they can all reach the same
Mongo. It is the natural partner of MongoTransport for a pure-Mongo stack (one backend for both
the persistence seam and the queue seam — see distribution).
The Mongo client is injected (duck-typed), so pymongo stays an optional dependency: the engine
never imports it at module load, the constructor only grabs ReturnDocument/DuplicateKeyError
lazily, and the test suite passes a mongomock client instead of a real server. A convenience
from_url classmethod does the lazy import pymongo for you, pinging the server and retrying so a
worker starting alongside Mongo (compose) waits for it to accept connections rather than crashing:
MongoStore(client, db_name="harel") # inject any pymongo-compatible client (incl. mongomock)
MongoStore.from_url("mongodb://host:27017") # lazily imports pymongo, builds MongoClient, pings + retries
Collections live under db_name (default "harel"): executions (the documents) and counters
(the monotonic outbox/spawn/trace seq allocator).
The key design point: one document, one atomic update¶
MongoDB has no multi-document transaction without a replica set. The SQL backends lean on a real
multi-table transaction to commit the Execution snapshot, the outbox rows, the spawn rows, the timer
rows and the dedupe mark together. Mongo cannot assume that machinery is available. So instead of
spreading one Execution’s state across separate collections, everything for one Execution lives in
its single document — the serialized Execution (data), plus the outbox/spawns/trace arrays,
the timers sub-document and the processed array.
The payoff: a commit is therefore one update_one, and an update of a single document is
atomic on its own with no replica set required. That single-document atomicity is the whole reason
for embedding everything — it buys the all-or-nothing commit contract (Execution advance + outbox +
spawns + dedupe + timers, all together or none) on a bare standalone mongod.
Performance note — never round-trip the whole document. Embedding everything would be ruinous if every write re-sent the whole (growing) blob. It does not:
Writes are partial.
commituses$set(thedataJSON andversion, plus timer schedule keys),$push(append to theoutbox/spawns/tracearrays),$addToSet(the dedupe mark) and$unset(timer cancels) — never a fullreplace_one.Reads are projected.
loadpulls onlydata; the relay/sweep reads (pending_outbox/pending_spawns/due_timers) project only the relevant array/sub-document and neverdata. So a growingdatablob is not dragged through every queue or timer scan.
Document model¶
An executions document has this exact shape:
{
_id: "<execution id>", // the Execution id (Mongo primary key)
definition_id: "<definition id>", // top-level so list_executions can filter it server-side
version: 7, // the optimistic-concurrency counter (the CAS guard)
data: "<Execution JSON>", // the full serialized Execution (model_dump_json)
outbox: [ // transactional outbox: deferred events awaiting delivery
{ seq: 42, target_id: "<id|null>", event: "<Event JSON>" },
...
],
spawns: [ // pending orthogonal-fork child creations
{ seq: 17, parent_id: "<id>", child_id: "<id>", root_path: "Fork.A", context: { ... } },
...
],
processed: [ "<event id>", "<event id>", ... ], // dedupe set: event ids already handled
timers: { "<enc(path)>": <fire_at>, ... }, // durable timers, keyed by encoded node path
trace: [ { ...step, index: 5 }, ... ] // optional execution-trace ring (last trace_max)
}
Every field, and why it is embedded rather than living in its own collection:
_id— the Execution id, so a load/CAS is a primary-key lookup.definition_id— promoted to a top-level field (not buried indata) precisely solist_executionscan filter by it server-side;status/parent_idare not promoted, so those filters run client-side (see Reads).version— the optimistic-concurrency counter. The CAS filter{"_id": id, "version": old}is what makes the single-writer guarantee hold; on a winning commit it advances toold + 1. It is also duplicated insidedata(the serialized Execution carries its ownversion).data— the whole Execution serialized withmodel_dump_json(). This is the snapshotloadreturns; everything else in the document is bookkeeping the engine drives separately.outbox— an array of{seq, target_id, event}. Embedded so the event enqueue commits in the same single-document update as the Execution advance (the transactional-outbox guarantee without a multi-document transaction).seqis monotonic (for ordered delivery + ack);target_idis the Execution to deliver to (null= no target);eventis the Event JSON.spawns— an array of{seq, parent_id, child_id, root_path, context}. A pending child-Execution creation (an orthogonal fork), embedded for the same reason: the fork intent commits atomically with the parent’s advance and its join expectations (thechildrendict insidedata), so a crash mid-fork cannot lose or double the spawn. A relay creates the child afterwards, idempotently.processed— an array of event ids the Execution has already handled (the dedupe set under at-least-once delivery). Embedded so recording “handled” lands in the same atomic update as the work it guards.timers— a sub-document mapping an encoded node path to afire_atepoch time. Embedded so arming/cancelling a durable timer commits atomically with the transition that armed it (no dual-write; a scheduled timer cannot be lost). Keyed by path so re-arming the same node replaces.trace— an optional array of trace steps (opt-in execution timeline), ring-trimmed to the lasttrace_maxentries; each step carries a stamped 0-basedindex.
The counters collection and _next_seq¶
The monotonic seqs (outbox, spawn, and a per-execution trace:<id>) come from a separate
counters collection, allocated atomically with find_one_and_update + $inc returning the
post-increment value:
def _next_seq(self, name: str, count: int) -> int:
doc = self._counters.find_one_and_update(
{"_id": name}, {"$inc": {"n": count}}, upsert=True, return_document=self._after
)
return int(doc["n"]) - count + 1
It reserves a block of count ids and returns the first of the block (n - count + 1).
return_document=ReturnDocument.AFTER makes find_one_and_update return the document after the
increment, so n is the new high-water mark. A block burned by a commit that later loses the CAS is
harmless — seqs only need to be monotonic and unique, never gapless, so a gap is fine.
Path encoding: _enc / _dec¶
Node paths use . as the separator (e.g. Fork.A). But MongoDB treats a . inside a field name
as a path operator (it would mean “sub-field A of field Fork”), so a raw node path cannot be used
as a key in the timers sub-document. The store encodes the dot to a character that cannot appear in
a path, reversibly:
@staticmethod
def _enc(path: str) -> str:
return path.replace(".", ".")
@staticmethod
def _dec(key: str) -> str:
return key.replace(".", ".")
The replacement char is . (U+FF0C-class fullwidth full stop, not an ASCII .). So timers.Fork.A
becomes the single sub-key timers.Fork.A and due_timers decodes it back when it reads the timers
out.
The CAS commit (one update_one)¶
commit is the one atomic write per event: it persists the Execution, enqueues its emitted events into
the outbox, records the processed event id, applies the timer ops, enqueues the spawn intents, and
appends the optional trace step — all or nothing — with an optimistic-concurrency check on the
Execution’s version. On a single-document Mongo this is one update_one, no replica set needed.
Step 1 — allocate the seqs up front. The outbox/spawn seqs and the trace index come from the
counters collection (one find_one_and_update each), allocated before building the update:
outbox_entries: list[dict] = []
if emits:
base = self._next_seq("outbox", len(emits))
outbox_entries = [
{"seq": base + i, "target_id": t, "event": e.model_dump_json()}
for i, (t, e) in enumerate(emits)
]
spawn_entries: list[dict] = []
if spawns:
base = self._next_seq("spawn", len(spawns))
spawn_entries = [
{"seq": base + i, "parent_id": exe.id, "child_id": cid, "root_path": rp, "context": dict(ctx)}
for i, (cid, rp, ctx) in enumerate(spawns)
]
trace_step: Optional[dict] = None
if trace is not None:
trace_step = {**trace, "index": self._next_seq("trace:" + exe.id, 1) - 1}
A seq wasted by a later-aborted commit is harmless (gaps are fine), so allocating eagerly before the CAS is safe.
Step 2 — bump the version and serialize. old is the version the caller loaded at; the in-memory
Execution is bumped to old + 1 and serialized into data:
old = exe.version
exe.version = old + 1
data = exe.model_dump_json()
Step 3 — build the partial update. $set carries data, the new version, and each timer
schedule (key timers.{enc(path)}); $unset carries each timer cancel; $push appends to the
arrays (outbox/spawns each {$each: [...]}, and trace with {$each: [...], $slice: -trace_max}
— see the ring below); $addToSet records the dedupe mark:
set_ops: dict[str, Any] = {"data": data, "version": exe.version}
unset_ops: dict[str, str] = {}
for op in timers:
key = f"timers.{self._enc(op.path)}"
if op.action == "schedule":
set_ops[key] = op.fire_at
else:
unset_ops[key] = ""
update: dict[str, Any] = {"$set": set_ops}
push: dict[str, Any] = {}
if outbox_entries:
push["outbox"] = {"$each": outbox_entries}
if spawn_entries:
push["spawns"] = {"$each": spawn_entries}
if trace_step is not None:
push["trace"] = {"$each": [trace_step], **({"$slice": -self.trace_max} if self.trace_max else {})}
if push:
update["$push"] = push
if processed_event_id is not None:
update["$addToSet"] = {"processed": processed_event_id}
if unset_ops:
update["$unset"] = unset_ops
Note $addToSet for processed: it makes the dedupe array set-like (a duplicate event id is a no-op,
not a second copy), which is exactly the at-least-once dedupe semantics.
Step 4 — the CAS itself. The update is applied with a filter on both _id and the loaded
version:
res = self._exes.update_one({"_id": exe.id, "version": old}, update)
if res.matched_count == 1:
return # CAS won
If matched_count == 1, a document at exactly version == old existed and was updated — this writer
won, and because it is a single-document update it landed atomically. If matched_count == 0, no
document was at version == old: either the Execution is brand-new, or someone else moved the version
(a stale write). The store disambiguates:
existing = self._exes.find_one({"_id": exe.id}, {"version": 1})
if existing is None and old == 0:
doc: dict[str, Any] = {
"_id": exe.id,
"definition_id": exe.definition_id,
"version": exe.version,
"data": data,
"outbox": outbox_entries,
"spawns": spawn_entries,
"trace": [trace_step] if trace_step is not None else [],
"processed": [processed_event_id] if processed_event_id is not None else [],
"timers": {self._enc(op.path): op.fire_at for op in timers if op.action == "schedule"},
}
try:
self._exes.insert_one(doc)
return
except self._DuplicateKeyError:
existing = self._exes.find_one({"_id": exe.id}, {"version": 1})
exe.version = old # undo the in-memory bump; the commit did not happen
raise StoreConflict(exe.id, expected=old, found=existing["version"] if existing else None)
So: no document exists and old == 0 ⇒ this is a fresh Execution → insert_one the full
document (note it must seed every embedded field, including trace: [], so later $push/$set
operators have an array/sub-document to target). The insert is itself the atomic create. If two writers
race the insert, the loser hits DuplicateKeyError on the _id and falls through to the conflict
path. Any other case (a document exists but at a different version, or old != 0 with no document) is a
stale write: the in-memory version bump is undone (exe.version = old, so the caller can reload
and retry) and StoreConflict is raised carrying the expected and found versions.
Why one update_one is atomic without a replica set. Mongo guarantees that a single-document
write — applying all of an update’s $set/$push/$addToSet/$unset operators — is atomic on that
document. There is no partial state visible to a reader: either the whole update (Execution advance +
all the embedded outbox/spawn/timer/dedupe changes) is applied, or none of it is. Multi-document
transactions (which do need a replica set) are unnecessary here precisely because the store collapsed
everything for one Execution into one document.
The trace ring¶
The execution trace (opt-in, off by default) is a bounded ring: only the last trace_max steps
(DEFAULT_TRACE_MAX = 200) are kept. The trimming is native — Mongo’s $push with $slice trims
the array in the very same update that appends, so there is no separate read-modify-write:
push["trace"] = {"$each": [trace_step], **({"$slice": -self.trace_max} if self.trace_max else {})}
$slice: -trace_max keeps only the last trace_max elements after the push, dropping the oldest as
the array grows — a fixed-size ring done server-side, atomically, alongside the rest of the commit. The
step’s index is a per-execution monotonic counter stamped via _next_seq("trace:" + id, 1) - 1
(0-based), and because it lives inside the step document it stays meaningful even after the ring
evicts the entries before it.
append_trace is the standalone (non-commit) writer — same $push/$slice shape, upserting so the
document is created if absent, and honoring an index already on the entry if present:
def append_trace(self, execution_id: str, entry: dict) -> None:
idx = entry.get("index", self._next_seq("trace:" + execution_id, 1) - 1)
step = {**entry, "index": idx}
push = {"$each": [step], **({"$slice": -self.trace_max} if self.trace_max else {})}
self._exes.update_one({"_id": execution_id}, {"$push": {"trace": push}}, upsert=True)
read_trace projects only the trace array:
def read_trace(self, execution_id: str) -> list[dict]:
doc = self._exes.find_one({"_id": execution_id}, {"trace": 1})
return list(doc.get("trace", [])) if doc is not None else []
(The trace is fully recorded by MongoStore. The commit docstring across the family marks
execution-trace as recorded by the SQL-family and Dict backends; MongoStore implements the same
$push/$slice ring as Redis.)
Reads & sweeps¶
load is one find_one projecting only data, parsed back through pydantic (or None if
absent) — the growing embedded arrays are never shipped:
def load(self, execution_id: str) -> Optional[Execution]:
doc = self._exes.find_one({"_id": execution_id}, {"data": 1})
return Execution.model_validate_json(doc["data"]) if doc is not None else None
load_for_event (on the async twin) folds the load and the dedupe check into one round-trip
via an aggregation: $match the id, then $project data plus a server-side $in membership flag
hit over the processed array. The key point is that the (potentially large, ever-growing)
processed array is never shipped to the client — the membership test runs on the server and only
the boolean hit comes back:
cursor = self._exes.aggregate(
[
{"$match": {"_id": execution_id}},
{"$project": {"data": 1, "hit": {"$in": [event_id, {"$ifNull": ["$processed", []]}]}}},
]
)
docs = [d async for d in cursor]
if not docs:
return None, False
return Execution.model_validate_json(docs[0]["data"]), bool(docs[0].get("hit"))
$ifNull: ["$processed", []] defends against a document that has no processed field yet.
list_executions can only filter definition_id server-side (it is a top-level field);
status/parent_id live inside the data JSON string, so they filter client-side with the
shared _matches. The find projects only {version, data} (never the embedded arrays), sorts by
_id, applies the cursor as a skip offset, and over-fetches so client-side filtering still
fills the page:
query: dict = {} if definition_id is None else {"definition_id": definition_id}
off = _decode_offset(cursor)
cur = self._exes.find(query, {"version": 1, "data": 1}).sort("_id", 1).skip(off)
scanned = 0
for doc in cur:
scanned += 1
summary = ExecutionSummary.from_data(json.loads(doc["data"]), doc.get("version", 0))
if _matches(summary, status, definition_id, roots_only):
items.append(summary)
if len(items) >= limit:
break
nxt = _encode_offset(off + scanned) if len(items) >= limit else None
return ExecutionPage(items=items, next_cursor=nxt)
The next cursor is off + scanned (the over-fetch offset, not off + limit), so the next page resumes
after everything actually scanned. If the cursor exhausts before reaching limit, there simply is no
next page (next_cursor = None). Ordering by _id is stable. A page may return fewer than limit
matches (the status/roots filter rejected some), so callers keep paging while next_cursor is set.
is_processed is the dedupe membership test — a find_one querying the array element directly
(Mongo matches processed: event_id against any element of the array), projecting only _id:
def is_processed(self, execution_id: str, event_id: str) -> bool:
return self._exes.find_one({"_id": execution_id, "processed": event_id}, {"_id": 1}) is not None
Outbox relay — pending_outbox finds documents whose outbox exists and is non-empty, projecting
only the outbox array (never data), rebuilds each OutboxEntry, and sorts by seq so delivery is
oldest-first; ack_outbox $pulls the delivered entry by its outbox.seq:
def pending_outbox(self) -> list[OutboxEntry]:
entries: list[OutboxEntry] = []
for doc in self._exes.find({"outbox": {"$exists": True, "$ne": []}}, {"outbox": 1}):
for e in doc.get("outbox", []):
entries.append(OutboxEntry(e["seq"], e["target_id"], Event.model_validate_json(e["event"])))
return sorted(entries, key=lambda e: e.seq)
def ack_outbox(self, seq: int) -> None:
self._exes.update_one({"outbox.seq": seq}, {"$pull": {"outbox": {"seq": seq}}})
Spawn relay — identical shape over the spawns array; pending_spawns projects only spawns and
sorts by seq, ack_spawn $pulls by spawns.seq:
def pending_spawns(self) -> list[SpawnEntry]:
entries: list[SpawnEntry] = []
for doc in self._exes.find({"spawns": {"$exists": True, "$ne": []}}, {"spawns": 1}):
for s in doc.get("spawns", []):
entries.append(
SpawnEntry(s["seq"], s["parent_id"], s["child_id"], s["root_path"], dict(s["context"]))
)
return sorted(entries, key=lambda s: s.seq)
def ack_spawn(self, seq: int) -> None:
self._exes.update_one({"spawns.seq": seq}, {"$pull": {"spawns": {"seq": seq}}})
Timer sweep — due_timers finds documents whose timers sub-document exists and is non-empty,
projecting only timers, then iterates the sub-document, decoding each key back to a real node
path with _dec and keeping any whose fire_at <= now (sorted by fire time):
def due_timers(self, now: float) -> list[tuple[str, str, float]]:
out: list[tuple[str, str, float]] = []
for doc in self._exes.find({"timers": {"$exists": True, "$ne": {}}}, {"timers": 1}):
for enc, fire_at in (doc.get("timers") or {}).items():
if fire_at <= now:
out.append((doc["_id"], self._dec(enc), float(fire_at)))
return sorted(out, key=lambda t: t[2])
delete_timer is guarded on the stored value: it $unsets the timer key only if the document
still holds exactly the fire_at the caller knows about. This protects a concurrent re-schedule — if
the model re-armed the same (execution_id, path) to a new time, a stale sweep trying to delete the
old one finds a mismatched value, matches nothing, and leaves the new timer intact:
def delete_timer(self, execution_id: str, path: str, fire_at: float) -> None:
key = f"timers.{self._enc(path)}"
self._exes.update_one({"_id": execution_id, key: fire_at}, {"$unset": {key: ""}})
close calls self._client.close() on the injected client.
Async twin¶
AsyncMongoStore (aio_store/mongo.py) is the
native-async mirror over motor.motor_asyncio.AsyncIOMotorClient: the same document model, the same
counters/_next_seq allocator, the same _enc/_dec path encoding, and the same
single-document CAS — update_one({"_id": id, "version": old}, update), atomic without a replica
set because the whole Execution and its embedded outbox/spawns/timers/processed live in one document.
Every collection method is awaited and cursors are iterated with async for:
res = await self._exes.update_one({"_id": exe.id, "version": old}, update)
...
async for doc in self._exes.find({"outbox": {"$exists": True, "$ne": []}}, {"outbox": 1}):
...
trace_max is set in __init__ (defaulting to DEFAULT_TRACE_MAX), exactly as in the sync store, so
the $push/$slice ring behaves identically. It adds load_for_event (the aggregation shown under
Reads). Build it with await AsyncMongoStore.from_url(url) (which awaits the ping and anyio.sleeps
between retries) or inject an already-connected AsyncIOMotorClient.
When to pick / tradeoffs¶
Pick MongoStore when you are a document-store shop — you already run MongoDB and want the engine’s
durable state to live there too, all-network (no local file, no shared volume), reachable from workers
on any machine or container. It pairs cleanly with MongoTransport for a single-backend, pure-Mongo
deployment (store + queue on one Mongo; see transports and
distribution).
The elegant part is the single-document atomicity: collapsing one Execution’s snapshot, outbox,
spawns, timers and dedupe marks into one document means commit is one update_one and gets the
full all-or-nothing contract on a bare standalone mongod — no replica set, no multi-document
transaction. Partial $set/$push/$addToSet/$unset writes and projected reads keep that
embedding cheap even as the document grows.
Tradeoffs:
List/monitor filtering is partly client-side. Only
definition_idis a top-level field;status/parent_idlive inside thedataJSON, solist_executionsfilters them after the fetch and over-fetches to fill a page — fine for a monitor that keeps paging whilenext_cursoris set.Durability is Mongo’s durability — as durable as your write concern / journaling config.
The version CAS and the per-event single-document atomic commit give the same single-writer / crash-safe outbox guarantees as the other durable backends; see durability for the contract.
See the stores hub for the full backend comparison.