DynamoDBStore — AWS serverless¶
DynamoDBStore is a durable ExecutionStore
(store/dynamodb.py) that keeps every Execution,
its transactional outbox, its orthogonal-fork spawn intents, its dedupe marks, its durable timers
and its opt-in execution trace in AWS DynamoDB. It is the serverless sibling of the SQL
backends — no instance to provision, no file, no shared volume — and the natural store-side partner
of SqsTransport for an all-AWS, no-server stack: one cloud for both the persistence seam and
the queue seam (see distribution).
It runs unchanged against real DynamoDB, against LocalStack, or against moto (no AWS
account needed for tests). The boto3 client is injected (duck-typed), so boto3 stays an
optional extra: the engine never imports it at module load, the constructor only grabs
TypeSerializer/TypeDeserializer and ClientError lazily, and tests pass a moto-backed client.
A convenience create(...) classmethod does the lazy import boto3 for you (LocalStack-friendly:
dummy creds + an injected endpoint_url; pass endpoint_url=None for real AWS), retrying until the
endpoint is reachable.
DynamoDBStore(client, prefix="harel") # inject any boto3 dynamodb client (incl. moto)
DynamoDBStore.create(endpoint_url="http://localhost:4566") # LocalStack: builds the client + ensures tables
DynamoDBStore.create() # real AWS (endpoint_url=None)
Why DynamoDB fits the store seam¶
DynamoDB hands the engine the two primitives the durability model needs, directly — no hand-rolled locking, no WATCH/MULTI loop:
Conditional writes are the CAS. A
Putwith aConditionExpressionapplies only if the condition holds against the stored item. That is exactly the optimistic-concurrency check (Execution.version): insert iff the item is absent, or update iff the stored version still matches what we loaded.TransactWriteItemsmakes the wholecommitatomic across items. The Execution write plus the outbox, spawns, processed (dedupe) and timer writes either all apply or none do, in one transaction. So a stale write never leaks its outbox, and an orthogonal fork’s children + the parent’s join expectations land together. The only constraint is DynamoDB’s transaction cap of 100 items / 4MB per request — far above a normal commit (1 Execution + a handful of emits / spawns / timers).
Tables¶
_ensure_tables creates seven tables (idempotent — a pre-existing table is fine), each prefixed by
prefix via _t(name) → "{prefix}_{name}", all PAY_PER_REQUEST:
table key schema contents
----------- --------------------------------- ----------------------------------------------------
executions id (S, HASH) the Execution as JSON in `data` + `version`,
`definition_id`
outbox seq (N, HASH) deferred events: {seq, target_id, event_json}
spawns seq (N, HASH) pending orthogonal-fork children:
{seq, parent_id, child_id, root_path, context_json}
timers execution_id (S, HASH), path (S, R) durable timers: {execution_id, path, fire_at}
processed execution_id (S, HASH), event_id (S, R) dedupe marks (one item per handled event)
counters id (S, HASH) the monotonic seq allocator (one item per counter)
trace execution_id (S, HASH), idx (N, R) opt-in timeline ring: {execution_id, idx, entry_json}
The spec list and the ["HASH", "RANGE"] role assignment build each KeySchema/
AttributeDefinitions pair:
specs = [
("executions", [("id", "S")]),
("outbox", [("seq", "N")]),
("spawns", [("seq", "N")]),
("timers", [("execution_id", "S"), ("path", "S")]),
("processed", [("execution_id", "S"), ("event_id", "S")]),
("counters", [("id", "S")]),
("trace", [("execution_id", "S"), ("idx", "N")]),
]
roles = ["HASH", "RANGE"]
Each table’s key schema reflects how the store reads it back:
executions—idHASH only. A load / save / CAS is a single-item operation keyed by the Execution id. Theversion,status,parent_idetc. live inside thedataJSON; onlyversionanddefinition_idare also broken out as top-level attributes (the CAS readsversion;definition_idcan be a server-side filter).outbox/spawns—seq(N) HASH only. Each deferred event / fork intent is its own item keyed by a globally monotonic integerseq, so a delivered entry is acked with a singledelete_itemby seq and the relay can sort the backlog by seq.timers—(execution_id HASH, path RANGE). Re-arming the same(execution_id, path)replaces the item (aPutupsert);delete_timercancels by the same composite key.processed—(execution_id HASH, event_id RANGE). One item per handled event; a dedupe check is a singleget_item, and all events for one execution share a partition.counters—idHASH. One item per counter name ("outbox","spawn"), holding the running totaln. The monotonic seq allocator (see_next_seq).trace—(execution_id HASH, idx RANGE). A per-execution contiguous index lets aQueryread the steps in order, and the ring drop a single item by exact key (see the ring trick below).
ResourceInUseException (table already exists) is swallowed; any other ClientError re-raises:
except self._ClientError as exc:
if exc.response["Error"]["Code"] != "ResourceInUseException":
raise # already exists is fine; anything else is real
Creating tables is a convenience for LocalStack / moto / dev; on real AWS the tables usually pre-exist (managed by IaC) and the create simply no-ops.
Native ↔ typed attribute-value form (_raw / _item)¶
The DynamoDB wire format is the typed attribute-value form ({"S": "..."}, {"N": "..."}), not
plain dicts. The store uses boto3’s pure (no-IO) serializers to convert both ways, so the rest of the
code works in native dicts:
def _raw(self, item: dict) -> dict:
"""A native dict → DynamoDB's typed attribute-value form."""
return {k: self._ser.serialize(v) for k, v in item.items()}
def _item(self, raw: dict) -> dict:
"""DynamoDB's typed form → a native dict (numbers come back as Decimal)."""
return {k: self._deser.deserialize(v) for k, v in raw.items()}
Numbers round-trip through DynamoDB as Decimal — which is why fire_at is stored as
Decimal(str(...)) and read back via float(...), and idx / version via int(...).
The monotonic seq allocator (_next_seq)¶
Outbox and spawn entries need globally ordered ids so the relay drains them oldest-first. _next_seq
reserves a contiguous block of count ids from a counter item with a single atomic ADD, returning
the first id of the block:
resp = self._db.update_item(
TableName=self._t("counters"),
Key=self._raw({"id": name}),
UpdateExpression="ADD n :k",
ExpressionAttributeValues={":k": {"N": str(count)}},
ReturnValues="UPDATED_NEW",
)
return int(resp["Attributes"]["n"]["N"]) - count + 1
ADD n :count increments (creating the attribute at 0 if absent) and ReturnValues="UPDATED_NEW"
returns the post-increment total; subtracting count and adding 1 yields the first id of the block.
This runs before the transaction (it is a separate write — you cannot read your own counter inside
the same TransactWriteItems). A block wasted by a later-cancelled transaction is harmless: seqs are
only required to be monotonic, not gapless.
The CAS via conditional write + commit (TransactWriteItems)¶
commit is the heart of the backend. It allocates seqs, bumps the version, and assembles one
TransactWriteItems list whose first item is the Execution Put carrying the CAS condition.
First, seqs are allocated up front and the outbox / spawn items built:
if emits:
base = self._next_seq("outbox", len(emits))
outbox = [{"seq": base + i, "target_id": t, "event": e.model_dump_json()} for i, (t, e) in enumerate(emits)]
if spawns:
base = self._next_seq("spawn", len(spawns))
spawn = [{"seq": base + i, "parent_id": exe.id, "child_id": cid, "root_path": rp,
"context": json.dumps(ctx)} for i, (cid, rp, ctx) in enumerate(spawns)]
Then the version is bumped and the Execution item built (the whole Execution as JSON in data, plus
the broken-out version and definition_id):
old = exe.version
exe.version = old + 1
exe_item = {"id": exe.id, "data": exe.model_dump_json(), "version": exe.version,
"definition_id": exe.definition_id}
The CAS condition depends on whether this is an insert or an update:
if old == 0:
cas = {"ConditionExpression": "attribute_not_exists(id)"}
else:
cas = {"ConditionExpression": "version = :ov",
"ExpressionAttributeValues": {":ov": {"N": str(old)}}}
Insert (
old == 0, a never-saved Execution):attribute_not_exists(id)— apply only if no item with this id exists yet. Two racing creates: one wins, the other’s condition fails.Update (
old > 0):version = :ov— apply only if the stored item’sversionstill equals the version we loaded. Another writer that already advanced the row makes the condition false.
The transaction list starts with the CAS-conditioned Execution Put, then appends the outbox Puts,
the spawn Puts, the processed (dedupe) Put, and each timer Put (schedule) or Delete (cancel):
txn = [{"Put": {"TableName": self._t("executions"), "Item": self._raw(exe_item), **cas}}]
for o in outbox:
txn.append({"Put": {"TableName": self._t("outbox"), "Item": self._raw(o)}})
for s in spawn:
txn.append({"Put": {"TableName": self._t("spawns"), "Item": self._raw(s)}})
if processed_event_id is not None:
txn.append({"Put": {"TableName": self._t("processed"),
"Item": self._raw({"execution_id": exe.id, "event_id": processed_event_id})}})
for op in timers:
if op.action == "schedule":
txn.append({"Put": {"TableName": self._t("timers"),
"Item": self._raw({"execution_id": exe.id, "path": op.path,
"fire_at": Decimal(str(op.fire_at))})}})
else:
txn.append({"Delete": {"TableName": self._t("timers"),
"Key": self._raw({"execution_id": exe.id, "path": op.path})}})
(A trace step, if supplied, appends two more items — see the ring section below.)
Finally the transaction is issued, and a CAS miss is translated into a StoreConflict:
try:
self._db.transact_write_items(TransactItems=txn)
except self._ClientError as exc:
code = exc.response["Error"]["Code"]
if code not in ("TransactionCanceledException", "ConditionalCheckFailedException"):
raise # a real error, not a CAS miss
exe.version = old # undo the in-memory bump; the txn was cancelled
resp = self._db.get_item(TableName=self._t("executions"), Key=self._raw({"id": exe.id}),
ProjectionExpression="version")
found = int(self._item(resp["Item"])["version"]) if "Item" in resp else None
raise StoreConflict(exe.id, expected=old, found=found)
Two error codes are treated as a conflict: DynamoDB raises TransactionCanceledException when
any item’s condition fails inside a transaction; older moto raises the bare
ConditionalCheckFailedException instead, so both are caught. Anything else (throttling, a real
fault) re-raises untouched. On a conflict the in-memory version bump is undone, the currently
stored version is read back, and StoreConflict(exe.id, expected=old, found=found) is raised so the
caller can reload-and-retry or drop the stale work.
Important
A failed condition cancels the WHOLE transaction. Because the CAS lives on the first item of the
TransactWriteItems, a lost CAS means nothing in the transaction is written — not the Execution,
not its outbox, not its spawns, not its dedupe mark, not its timers. This is what guarantees a stale
write never leaks a half-committed Finished event or a phantom fork: the outbox and the Execution
advance are atomically all-or-nothing with the version check.
save(exe) is just commit(exe, []) — the version-checked write with no side effects.
The trace ring — the DynamoDB-specific trick¶
The opt-in execution trace (off by default) appends one timeline step per commit and keeps only the
last trace_max steps (a ring; DEFAULT_TRACE_MAX = 200). On the SQL backends a ring is a
LTRIM-style range delete or a $slice; DynamoDB has no LTRIM, no $slice, and no range-delete
inside an atomic write. A Delete can only target one exact key. So the ring is expressed as
Put idx=K + Delete idx=K-N in the SAME TransactWriteItems — append the new step and drop the
one that just fell out of the last-N window, atomically:
if trace is not None:
idx = self._max_trace_idx(exe.id) + 1 # contiguous per execution (read max, +1)
txn.append({"Put": {"TableName": self._t("trace"),
"Item": self._raw({"execution_id": exe.id, "idx": idx, "entry": json.dumps(trace)})}})
if self.trace_max and idx - self.trace_max >= 0:
txn.append({"Delete": {"TableName": self._t("trace"),
"Key": self._raw({"execution_id": exe.id, "idx": idx - self.trace_max})}})
Deleting an absent key is a no-op in DynamoDB, so the Delete is safe even before the window
fills (when idx < trace_max the guard skips it anyway).
The index is contiguous per execution because it is derived by reading the current max and adding
one, not from a counter. _max_trace_idx is a Query on the partition key, newest first, limited to
one row:
resp = self._db.query(
TableName=self._t("trace"),
KeyConditionExpression="execution_id = :e",
ExpressionAttributeValues={":e": {"S": execution_id}},
ProjectionExpression="idx",
ScanIndexForward=False,
Limit=1,
)
items = resp.get("Items")
return int(self._item(items[0])["idx"]) if items else -1
ScanIndexForward=False returns the highest idx first; Limit=1 takes just it (or -1 if the
execution has no trace yet). Because the engine is single-writer-per-execution (the transport’s
single-active-consumer-per-group guarantee), the read-max → write-max+1 is race-free. Crucially, since
the index comes from the stored data and not a separate counter, a cancelled commit leaves no
gap: nothing was incremented, so the next successful commit reuses the same idx.
Caution
The ring keeps exactly the last N only if trace_max is fixed from the first traced commit — the
production / startup case (trace_max is set once at process start). Because exactly one item is
dropped per write (idx - trace_max), changing trace_max mid-stream does not retroactively trim:
lowering it over-retains harmlessly (extra old items linger until they age out past the new window),
and the count converges as new commits arrive. It never over-deletes. Set trace_max once at startup
and the ring holds exactly N.
read_trace reads the ring in order (a Query ascending) and merges the stored idx back into each
entry as its index:
resp = self._db.query(
TableName=self._t("trace"),
KeyConditionExpression="execution_id = :e",
ExpressionAttributeValues={":e": {"S": execution_id}},
ScanIndexForward=True, # oldest → newest
)
out = []
for raw in resp.get("Items", []):
it = self._item(raw)
out.append({**json.loads(it["entry"]), "index": int(it["idx"])})
return out
append_trace is the standalone (non-transactional) appender used outside a commit — a put_item
plus a guarded ring drop, with the same idx - trace_max window logic:
idx = entry.get("index", self._max_trace_idx(execution_id) + 1)
self._db.put_item(TableName=self._t("trace"),
Item=self._raw({"execution_id": execution_id, "idx": idx, "entry": json.dumps(entry)}))
if self.trace_max and idx - self.trace_max >= 0:
self._db.delete_item(TableName=self._t("trace"),
Key=self._raw({"execution_id": execution_id, "idx": idx - self.trace_max}))
Reads & sweeps¶
load — a single get_item projecting only the data attribute (the data keyword is reserved
in DynamoDB, hence the #d expression-attribute-name alias), parsed back to an Execution:
resp = self._db.get_item(
TableName=self._t("executions"),
Key=self._raw({"id": execution_id}),
ProjectionExpression="#d",
ExpressionAttributeNames={"#d": "data"},
)
item = resp.get("Item")
return Execution.model_validate_json(self._item(item)["data"]) if item else None
load_for_event (async store) — loads the Execution and checks the dedupe mark in one
round-trip with BatchGetItem across the executions and processed tables:
resp = await self._db.batch_get_item(RequestItems={
self._t("executions"): {"Keys": [self._raw({"id": execution_id})],
"ProjectionExpression": "#d",
"ExpressionAttributeNames": {"#d": "data"}},
self._t("processed"): {"Keys": [self._raw({"execution_id": execution_id, "event_id": event_id})]},
})
...
return Execution.model_validate_json(self._item(exe_items[0])["data"]), bool(proc_items)
Returns (execution, already_processed) — the worker’s hot path collapses load + dedupe to a single
DynamoDB call.
list_executions — a Scan (DynamoDB Scan is unordered) projecting only data + version.
status / roots_only live inside the data JSON, so they are filtered client-side via the
shared _matches; definition_id is a broken-out attribute and can be a server-side
FilterExpression. The cursor is the base64 of DynamoDB’s native LastEvaluatedKey:
kwargs = {"TableName": self._t("executions"),
"ProjectionExpression": "#dat,#v",
"ExpressionAttributeNames": {"#dat": "data", "#v": "version"},
"Limit": limit}
if definition_id is not None:
kwargs["ExpressionAttributeNames"]["#def"] = "definition_id"
kwargs["FilterExpression"] = "#def = :def"
kwargs["ExpressionAttributeValues"] = {":def": {"S": definition_id}}
if cursor:
kwargs["ExclusiveStartKey"] = json.loads(base64.urlsafe_b64decode(cursor.encode()).decode())
Limit bounds the items examined before the filter, so a page may return fewer than limit
matches — the caller keeps paging while next_cursor is set. next_cursor is the base64-encoded
LastEvaluatedKey (or None when the scan is exhausted).
is_processed — a single get_item on the processed table; presence of Item is the answer:
resp = self._db.get_item(TableName=self._t("processed"),
Key=self._raw({"execution_id": execution_id, "event_id": event_id}))
return "Item" in resp
pending_outbox / ack_outbox — the relay drains the outbox: Scan (unordered) then sort by
seq client-side; an ack is a delete_item by seq:
rows = self._scan("outbox")
rows.sort(key=lambda r: int(r["seq"])) # Scan is unordered; sort by seq
return [OutboxEntry(int(r["seq"]), r.get("target_id"), Event.model_validate_json(r["event"])) for r in rows]
def ack_outbox(self, seq: int) -> None:
self._db.delete_item(TableName=self._t("outbox"), Key=self._raw({"seq": seq}))
pending_spawns / ack_spawn — identical shape over the spawns table, rebuilding a SpawnEntry
per row and deleting by seq on ack.
due_timers — a Scan with a server-side FilterExpression for the due window, then sorted by
fire_at:
rows = self._scan("timers",
FilterExpression="fire_at <= :now",
ExpressionAttributeValues={":now": {"N": str(now)}})
out = [(r["execution_id"], r["path"], float(r["fire_at"])) for r in rows]
return sorted(out, key=lambda t: t[2])
delete_timer — a delete_item guarded on the stored fire_at, so a concurrent re-schedule
to a new time survives a stale sweep (the guard fails → no-op):
self._db.delete_item(
TableName=self._t("timers"),
Key=self._raw({"execution_id": execution_id, "path": path}),
ConditionExpression="fire_at = :f",
ExpressionAttributeValues={":f": {"N": str(Decimal(str(fire_at)))}},
)
# on ConditionalCheckFailedException: the guard didn't match (stale sweep) — a no-op, as intended
Note
Why the relay/sweep use Scan and not a Query. The outbox, spawns and timers tables are
work queues: entries are written by commit and deleted as soon as the relay delivers / the timer
fires, so these tables drain and stay near-empty in steady state. A full-table Scan over a
near-empty table is cheap, and it needs no secondary index or partition-key probe — the relay wants
everything pending, not the rows for one key. _scan follows LastEvaluatedKey to drain every page
(a single Scan returns at most 1MB). The trade is that these reads are not point-lookups; that is the
deliberately accepted cost of the document model here.
Async twin¶
aio_store/dynamodb.py is AsyncDynamoDBStore —
a native-async mirror over aioboto3 / aiobotocore, not a thread-pool wrapper. Every call is
awaited on one long-lived aiohttp-backed client, so concurrent workers (STM_CONCURRENCY) issue
real parallel DynamoDB requests through the aiohttp connection pool rather than being bounded by a
thread pool. The semantics are identical to the sync store — the same CAS conditions
(attribute_not_exists(id) to insert, version = :ov to update), the same TransactWriteItems
atomicity, the same trace ring. The boto3 TypeSerializer / TypeDeserializer are pure (no IO), so
they are reused as-is, and trace_max is set in __init__.
Build it with await AsyncDynamoDBStore.create(...), which owns the client through an
AsyncExitStack and releases it in close():
stack = AsyncExitStack()
client = await stack.enter_async_context(aioboto3.Session().client("dynamodb", **kwargs))
inst = cls(client, prefix)
inst._stack = stack
...
await inst._ensure_tables()
async def close(self) -> None:
# release only a client we own (created via create()); an injected client is the caller's to close
if self._stack is not None:
await self._stack.aclose()
self._stack = None
You may also inject an already-entered aiobotocore client through the constructor, in which case the
caller owns its lifecycle (close() then no-ops because _stack is None). The client binds to the
event loop that creates it — build it on the loop you run on (e.g. inside anyio.run); never share
one client across loops.
Note
Tests mock AsyncDynamoDBStore in-process with aiomoto — plain moto.mock_aws cannot
intercept aiobotocore’s aiohttp transport, so the async twin needs the aiohttp-aware mock.
When to pick / tradeoffs¶
Choose DynamoDBStore when you want a fully serverless, all-AWS statechart deployment with
nothing to run or patch: DynamoDB is the store, SqsTransport is the queue (its per-group
exclusivity is native via MessageGroupId), and your workers run on Lambda or ECS/Fargate. It pairs
naturally with SqsTransport for a no-server stack and develops locally against LocalStack / moto
with no account.
Tradeoffs to weigh:
Scan-based relay reads.
pending_outbox/pending_spawns/due_timersScantheir tables and sort client-side. This is cheap because those tables drain and stay near-empty, but it is not a point-lookup pattern — a backlog that grows large (a stalled relay) makes these scans more expensive.list_executionsis unordered (Scan) and filtersstatus/roots_onlyclient-side, so a page can return fewer thanlimitmatches — page untilnext_cursorisNone.The trace ring’s fixed-
trace_maxcaveat (above): set it once at startup; changing it mid-stream over-retains harmlessly rather than trimming immediately.The 100-item / 4MB transaction cap bounds a single
commit— far above a normal commit, but a pathological fan-out emitting dozens of events plus dozens of spawns in one step could approach it.
See the stores hub for the full backend matrix, and durability for the CAS / outbox / dedupe model these tables implement.