Where the guarantee lives¶
A protocol of load and record is not enough to run a workflow safely, whatever
the store underneath it can do, because it has no way to say "only if nobody else is
running this" or "only if I am still the one who may write". That is the same
problem Temporal answers with a server and DBOS answers by requiring Postgres. This
page is where the answer here is written down: what Checkpointer demands of a
store, what Durable demands of a pair of them, and what the two questions cost.
Temporal, DBOS, and a third position¶
Temporal and DBOS sit at two ends of one axis, and the axis is who enforces that only one writer touches a workflow at a time.
Temporal puts it in a server. A workflow execution belongs to a shard, a shard has one owning host, and that ownership is what orders the writes to its history. Portable persistence is the consequence, not the motive: because the server supplies the ordering itself, the database underneath only has to do conditional single-partition updates, which is why Cassandra qualifies. The server exists so that the storage requirements can be weak.
DBOS is the inverse. There is no server, so the database has to supply the semantics, and Postgres can. What that buys beyond exclusion is the thing no amount of care in user code reproduces: a step's business write and its checkpoint commit in one transaction, which makes that step exactly-once rather than at-least-once.
This puts it in the interface, which is a third position rather than a midpoint on that
line: Checkpointer states the guarantees as requirements, and an implementation
says how many of them it can meet. All three stores here meet all of them, which is
the point of having three. What it costs is that they are still not
interchangeable, and the bottom row is why.
| Capability | Redis | SQL (Postgres, SQLite) | What it buys |
|---|---|---|---|
record a value durably |
yes | yes | resumption at all |
| Record only if absent, returning the winner and which pass it was | HSETNX and an encoding comparison, in a script |
an upsert whose DO UPDATE returns the row already there |
two passes that both ran an effect agree on its result instead of diverging, and a graph run knows to stop |
| Exclusive pass with a fencing token | HINCRBY plus a lease, in a script |
an upsert whose DO UPDATE carries a WHERE |
one pass at a time, holding even when a process stalls past its lease |
| Step and checkpoint in one commit | a Lua script, for effects in this Redis | a transaction, for effects in this database | exactly-once for that step |
All four are implemented in every store, and the fourth is worth stating carefully because the obvious phrasing is wrong. It is not that Redis lacks what Postgres has: a Lua script is an atomic commit over Redis data, so a step whose effect is a Redis write records itself in the same script exactly as DBOS records itself in the same transaction. The real constraint is that you can only transact within a single datastore. Postgres wins this row only for effects that live in that Postgres, and loses it for everything else in precisely the way Redis does. What it wins in practice is that the effects usually do live there, which is a fact about where applications keep their data and not about the database.
transact, and what an effect has to be¶
Run.transact is where that lands. step performs an effect and then writes the
record, so a crash in between leaves the effect done and unrecorded and the next pass
repeats it. transact hands the store an effect it can perform itself, and the store
does the work and writes the record in one commit, so there is no in-between for a
crash to occupy:
await run.transact(
"reserved",
LuaEffect(
source="return cjson.encode(redis.call('HINCRBY', KEYS[1], ARGV[1], tonumber(ARGV[2])))",
keys=(f"{checkpointer.hash_key(workflow)}:ledger",),
args=("piano", 1),
),
as_count,
)
That step is exactly-once on Redis. Run the workflow ten times and the ledger moves
once, without an idempotency key and without the effect being written to tolerate
repetition. The same step against PostgresCheckpointer is the same sentence with
the store's own language in it, and the effect is now an ordinary application write
rather than something staged into the checkpoint's datastore:
async def reserve(cursor: AsyncCursor[TupleRow]) -> object:
await cursor.execute(
"UPDATE stock SET reserved = reserved + 1 WHERE sku = %s RETURNING reserved",
("piano",),
)
return (await cursor.fetchone())[0]
await run.transact("reserved", reserve, as_count)
What it costs is that the effect has to be something the store can perform, which
means it has to live in the store. An effect that leaves the datastore (a payment
gateway, a carrier) cannot be in the commit, is not a transaction anyone can offer,
and belongs in step behind an idempotency key. On a Redis Cluster the same
constraint appears as a slot: an effect's keys must carry the workflow's own {id}
tag, because a script spanning two slots is a distributed transaction wearing a local
disguise. Postgres asks the same question once it is sharded rather than being exempt
from it (see One datastore is a question).
This is why Checkpointer is generic. Checkpointer[Effect] names the type of thing
this store can commit alongside a record, and there is no shared answer: Redis takes
a Lua script, Postgres an async callback handed a cursor inside the open transaction,
SQLite the same callback without the async, and MemoryCheckpointer a function over
its own dict. Effect defaults to Never, so a store with nothing to offer here says
so in its type and transact becomes uncallable rather than absent, while code that
never transacts keeps writing the bare Checkpointer and still accepts every store.
So a family of stores is not one good implementation and one compromise. It is the same offer made to several populations, each able to co-commit for the effects that live where its checkpoint lives.
Six notes on the shape that took¶
None of these is obvious from the protocol alone.
- A lease alone is not exclusion. A process that stalls past its lease still
believes it holds the workflow. Only the store knows better, so every write carries
the token it was claimed with and the store refuses anything below the highest it
has issued (
Fenced). This is whyPasscarries a number rather than a name, and why the number is minted by the store rather than by the claimant. - One deadline was answering two questions. A single lease has to exceed the longest a pass can honestly take, or a slow-but-healthy pass is fenced and repeats the step it was in the middle of; and it has to be short, or a dead worker's workflow waits that long before anyone may touch it. Those pull opposite ways, so a claim carries two deadlines instead: a liveness window the holder renews for as long as it runs, and a budget renewal cannot lift. The first measures how fast a death is noticed and the second how long the work may take, and each is answerable on its own where the single number was not. Temporal pairs a start-to-close timeout with a heartbeat timeout for exactly this reason; DBOS takes the other road, tying ownership to an executor's identity and pushing liveness out to a control plane of its own.
- Each script is a script because it is only correct as one step. Checking whether a workflow is free and taking it; checking a token and applying the write it guards; testing whether a key is recorded and reading back the winner. Split any of them into two round trips and the gap is where the guarantee leaks. SQL says the same thing with a statement or a transaction, which is the whole difference between the stores.
- Writes from outside a pass do not take the claim.
supplyis what theCheckpointerhalf offers, and gating it on the claim would mean an approval failing because a worker happened to be mid-pass, for a value nothing is racing it to write. It keeps first-writer-wins, which is what makes a resubmitted order harmless. - The store says who won; the caller cannot work it out.
recordreturns aRecorded, which carries the stored value and whether this pass is the one that put it there. Inferring the second from the first looks free and is wrong: a result crosses the codec both ways, so a pass that won outright can be handed back something unequal (a tuple returns as a list underJsonCodec), andrun_durablyreading that as a lost race would fail a run in which nothing raced. The store is the only party holding both encodings, so it answers. - The store says what order they came in, for the same reason. A workflow's
records have two independent writers: the pass, through
record, and anything outside it, throughsupply. Neither can order itself against the other. A counter either one keeps is read from a stale in-process snapshot or observed from the store and then raced, so the pass writes response N, the handler loads and takes N+1, and the pass's own next write is N+1 too. The store sees every write, so the store is the only thing that can say, andloadreturns its records in the order they were first recorded. First-writer-wins already decides what a key holds; this says the same writer decides where it sits, so a losing write moves neither.
The order is the guarantee and the number behind it is not. load returns a
dict, which preserves insertion order, so a caller reads the order by iterating
and no store owes a sequence anyone outside it can see. That leaves the requirement
invisible in the signature, which is the cost of keeping it out of the API: nothing
but the cross-store conformance suite holds an implementation to it, and a
third-party store can satisfy the type while ignoring the contract.
What each store reaches for differs, and the differences are instructive. SQLite
names the rowid it already assigns as an explicit seq INTEGER PRIMARY KEY,
which is why its checkpoint table is the one table there that is not
WITHOUT ROWID, and why it declares a column it could have left implicit: SQLite
reserves the right to renumber the rowids of a table that has no explicit
INTEGER PRIMARY KEY when the database is VACUUMed. Postgres adds a seq
column off a sequence, because a heap scan looks like insertion order right up
until the no-op conflict update rewrites a tuple and moves it. Redis has the hardest job and the least
obvious answer: it packs the position into the hash field in front of the encoded
value, because a hash preserves insertion order only while it is listpack-encoded
and stops once it converts to a hashtable. Keeping the order in the field is what
keeps it to one key, so there is no second structure that has to expire in step with
the first.
- The store names an inbox key, because only the store can. append is supply
under a key the store picks, and that is the whole difference between them. It owes
three things a caller cannot arrange: two concurrent appends to one workflow get
distinct keys and neither value is lost, the keys sort into append order within that
workflow, and the entry is an ordinary record that load returns in place.
The keys need not be contiguous and need not order across workflows, and saying so is
what makes the requirement implementable. A shared counter with gaps satisfies it and
is far easier to make atomic than per-workflow numbering: Postgres takes nextval off
a sequence, since it is the store with genuinely concurrent writers and a maximum read
inside the insert is a race two callers can both win, with the loser's message
vanishing into first-writer-wins and no error to show for it. SQLite reads the highest
seq in one statement, which is atomic there because SQLite admits one writer at a
time. Redis and the in-memory double take the count of the workflow's own records.
What every one of them arrives at, by a different route, is that the key and the load
position are one number. Two counters would be two orders, and the second and third
requirements above are a claim that those orders agree: under concurrent appends the
keys would sort one way and load render the other, which is a store meeting each
guarantee alone and neither together. Redis and the double get it for free, since the
count they name a key from is already the position. Postgres has to arrange it, by
drawing one nextval in a CTE and writing it as both the key and the row's seq,
which is why that column takes a DEFAULT rather than being an identity: an identity
is a number no statement may supply, and this one has to.
Nothing is ever consumed, which is the load-bearing half. A destructive read would
move a value out of the inbox and into the workflow's own records, leaving two copies
to keep in step; append-only means the entry is the record and a pass writes a
reference to it. That reference replays correctly for exactly the reason the entry is
safe to share: first-writer-wins, so the key still holds what it held. Forking is then
free, since a consumer copying a prefix of load copies entries like anything else
and needs no rule about the unread ones.
What a destructive queue would buy is competing consumers, and that is already
answered a layer down: claim guarantees one pass per workflow, so there is nobody to
distribute the work between.
- The store says when each record landed, for the third time and the same reason.
history returns what load returns, in the same order, each with the moment it was
written. The clock is the store's, read at the winning write, which is the same clock
every lease here is measured by and for the same argument: the writer is a different
machine, so a moment stamped by whichever process happened to record it is only as good
as the agreement between the two. First-writer-wins already decides what a key holds and
where it sits; this says it decides when the key was written too, so a store stamping on
every write would report a replayed step as having run at the moment of the replay.
It is a second read rather than a richer load because the two have different readers.
A pass calls load at its top and has no use for the times; what wants them is outside
a pass, reading one workflow at a time, so the write path stamps every record and the
read path splits.
- Deleting a workflow raises its fence rather than removing it. discard forgets every
record and takes the fencing token up, keeping the claim row. That is the whole
difference between a delete a caller could write for itself and one that is safe: a pass
in flight holds a Pass and is about to write, and deleting the claim hands the next
claim token 1 on the stores whose tokens are a counter, so the pass holding 7 outranks
it and fills the deleted workflow back up one step at a time. Raising it instead refuses
that pass at its next write and leaves the id claimable immediately, since what is kept
is the ordering rather than the claim.
The queue closes the same race from its own end, and it has to: a worker answers for its
delivery after the pass, so Scheduler.cancel sweeping the queue is undone a moment
later by the deadline that pass chose. So wake_at MUST NOT reinstate a workflow whose
delivery has been cancelled since it was taken, which the visibility-scored queues get
for free (the receipt is a score, and a removed entry has none) and the Redis stream
answers by asking whether its entry is still there.
Every step names its parser, and the graph names none¶
A step hands back what the store holds, not the object its effect produced, so
run.step("charged", charge) returning the effect's own type was a lie the type
checker accepted. Not only after a crash: a step returning a tuple is handed a list on
the very pass that ran it. So step, transact, and awaiting take a
parse: Callable[[object], T], and the return type is proven by a function that ran
rather than asserted by a cast.
The effect's own type is deliberately not tied to the parser's. What goes in and what
comes out are related by encode-then-decode, which is not the identity, so one type
for both would assert something false. Run.sleep is the proof rather than the
exception: it records an ISO string and reads back a datetime.
run_durably needs none of this, and the asymmetry is the point rather than an
inconsistency. It holds both values at the moment it records: what the node
returned, and what the store now has. So it verifies instead of parsing, and refuses a
node whose result does not survive its own store, naming the node, on the pass that
wrote it. That check matters more for a graph than a parser would, because a graph
feeds a node's result straight to its dependents: without it they would see a tuple
on the pass that computed it and a list on the pass that restored it, with no crash
needed for the two to disagree.
Verifying beats parsing whenever you still hold what you sent. Run.awaiting is
exactly the case that does not: it reads a value some other process wrote, so there
is nothing to compare against and only a parser can establish its shape.
The codec is an interface too¶
What a step's result becomes in the store is a boundary decision, and boundary
decisions belong to the application: what a workflow's steps return, what an operator
needs to read out of the store, and what a service in another language has to parse
are questions this library cannot answer. So CheckpointCodec is a protocol every
store takes, defaulting to JsonCodec over the stdlib.
It is one object rather than a pair of functions because both requirements on it are
about the pair. decode(encode(x)) MUST equal x, or a resumed pass sees something
the first pass did not, silently, one crash later. And encode MUST be deterministic,
because record decides who won a race by comparing encodings.
Only the encoded side is a type parameter. Encoded genuinely varies (every store
here holds text, and one holding bytes would say so), while the decoded side cannot:
a checkpoint is heterogeneous by construction, since one codec carries a workflow's
string, its mapping, and its deadline alike. Precision belongs inside a codec instead,
where a pydantic TypeAdapter can be as exact as it likes while still presenting
object at the interface, which is the move without_dag.Node already makes.
MemoryCheckpointer applies the codec too, which is the part that is easy to skip and
is exactly what makes a double lie. A dict can hold a value directly, so encoding into
it looks like ceremony, but then a step's result comes back by identity in the suite
and through a round trip in production, and every property that depends on the round
trip passes in tests and fails in deployment. So its Stored carries the encoding, as a
hash field and a TEXT column do, and reading a checkpoint means load.
Losing the workflow is not the workflow failing¶
Fenced, Contended, and Suspended descend from BaseException rather than
Exception, for the reason asyncio.CancelledError does. Each says something about
whether this pass may continue, not about the work; an except Exception written to
handle a declined gateway must not absorb one.
Suspended is the one a driver never sees, because resume catches it and returns a
Sleeping or a Blocked. It still descends from BaseException for the half of its
life that matters: the part where it is travelling up through the workflow author's own
code, past whatever they wrapped their steps in.
BaseException is not enough on its own, though, which is worth stating because it looks
like it should be. It defeats except Exception; it does not defeat asyncio.wait or
gather(return_exceptions=True), which hand exceptions back as values rather than raising
them, so a suspension can be captured without anyone writing an except at all. Each wait
therefore writes itself onto the Run before raising, and a body that returns having
reached one is refused (Swallowed) rather than reported as a finished workflow. That
also settles a second thing the type could not: asyncio.gather propagates only the first
exception, so a report built from what came out would name one key of a fan-out's several.
The case that forced it is a saga, which is an except Exception around a forward run
that drives a rollback. A Fenced forward run is not a failure: it says another pass
holds this workflow and is advancing it, so a loser that compensated would refund a
charge the winner is still building on. Making the exception's own shape enforce that
beats keeping a list of types correct at every except site, which matters more here
than it would inside a library, since the except in question is one an application
wrote (see Sagas are not a feature here).
One interface or two¶
A workflow's durable state is two things, what it has done and whether it may run now, and they are two protocols on the grounds that they can be two stores. They can: a Postgres checkpoint beside an SQS queue is an ordinary architecture. But can be unbundled is not the same as should be handed to the caller unbundled.
The argument for that is already in the Redis stream scheduler, about wake_due:
the protocol names the transition, so a caller cannot hold a claimed-but-unqueued id
at all, which is the state that was lossy. Making it unrepresentable beats remembering
to do both halves. Recording the value a workflow is waiting on and making the workflow
runnable are also durable only together, so they get the same treatment.
Scheduler needed the same move internally, and the way it was found is worth
recording, because the argument above predicted it. It used to state a cross-call
ordering rule in prose ("a wake_at survives a done for a delivery taken before it,
because the worker calls them in that order"), and a protocol that constrains the order
its own methods are called in is carrying coupling it isn't expressing. The coupling was
real: on a store that holds one entry per workflow, scheduling and acknowledging as two
calls is a read-modify-write over a value somebody else may have just written, so a
confirmation that landed while a pass was ending was overwritten by the deadline that
pass chose, and a workflow that should have run at once waited out a settlement window.
So wake_at takes the Delivery rather than a workflow id and answers for it too: one
call, no order to get right, and the receipt is what lets the store tell its own
delivery from a wakeup that arrived since.
One more tell that the boundary would be in the wrong place there. Three of its methods are no-ops in every implementation but the Redis stream, which says the protocol is shaped around one implementation's mechanism (stream, group, pending list, timer) rather than around the question "when may this workflow run".
So the answer is not one big implementation, which would bundle a mechanism to
repair an interface and forfeit the split deployment. It is to bundle the
interface and leave the mechanisms unbundled. Durable owns the two stores and names the transitions
across them; Checkpointer and Scheduler are unchanged underneath and are what
implementations actually are:
What varies between implementations is not whether arrive exists but what it
guarantees, which is exactly how Checkpointer already treats transact.
SplitDurable composes any two stores and does two writes; PostgresDurable and
SqliteDurable require that their two stores share one pool or one connection
(checked at construction, not documented) and do one commit.
The ordering inside SplitDurable is the whole of what it can offer, and it is not
arbitrary. It records first, so a crash leaves a workflow holding its value and waiting
for a wakeup, which anything asking again supplies. The reverse would queue a pass that
wakes, finds nothing recorded, and answers for the delivery, which drops the value for
good.
What this costs: a third named concept, and a SplitDurable whose guarantee is
deliberately weaker than the interface's strongest form. The second is the one to watch,
because a weaker guarantee behind an identical signature is how a system teaches people
to assume the stronger one.
One datastore is a question, not a product¶
"Both things live in one datastore" is easy to read as "both things are in Postgres", and that reading is wrong in a way that matters at exactly the scale where you would care. The real question is whether the two writes land in one local commit, and every store asks it, just at a different place and with a different answer when you get it wrong.
Redis Cluster refuses. Keys declared to a script must hash to one slot, or the
server rejects the call before running anything (CROSSSLOT Keys in request don't hash
to the same slot). And the rule is about locality rather than declaration: a script
that reaches a key it never declared, owned by another node, dies partway with
ERR Script attempted to access a non local key in a cluster node script, having
written nothing. On a single node owning every slot the same script succeeds, which is
why a single-node test can't tell you this. There is no escalation path: a cross-node
atomic write is not expensive on Redis Cluster, it is unavailable.
Sharded Postgres escalates. Vanilla single-node Postgres does not shard, so a
transaction is one WAL and one fsync and the local-commit claim is unconditional. Under
Citus
it is not: a transaction touching shards on more than one node becomes a real
distributed transaction, with the coordinator running PREPARE TRANSACTION and then
COMMIT PREPARED, a distributed deadlock detector, and max_prepared_transactions to
size on every worker. It still commits atomically, which is more than Redis offers, but
it is a different guarantee with different failure modes and an operational tax,
arriving silently.
SQLite has one answer and it is yes. The datastore is a file, so there is nothing to co-locate and no sharding to grow into, which is the whole of what buys the smallest store the strongest form of the guarantee.
The escape is the same shape on both of the first two sides, which is the point worth
taking away. Redis's hash tag has an exact analogue: distribute workflow_checkpoint,
workflow_claim, and workflow_queue by the workflow id and co-locate them, and every
transaction here stays on one node. LuaEffect.keys forces an author to confront that
question because a cluster will not let them avoid it; PostgresDurable can only state
it, and does, by requiring its two stores to share a pool. Sharing a pool is the
necessary half and not the sufficient one: on a sharded deployment, co-location is the
rest.
So the honest form of the rule is that Redis makes you answer the question at development time and Postgres lets you answer it at scale, which is a real convenience and a real trap.