Status: specified, not implemented. ADR-0023 records the decision.
A saga is a durable unit of work that spans many transactions and an arbitrary amount of wall-clock time, and that either lands in the database as one atomic commit or leaves no mark on the canonical state at all. This document works through the design space and specifies the chosen model: a saga is a database branch plus a registry entry — steps run as ordinary transactions on a cheap branch of the database, in-flight progress is discoverable (and readable) by anyone who asks but imposed on no one, and the whole branch merges into the parent as a single validated commit or aborts without ever having touched it.
A Corium transaction today is a single submitted batch: the transactor
expands it, validates it, assigns one t, appends one log record, and the
work is over in milliseconds. Three properties are welded together in that
model — atomicity, isolation, and brevity — and several real workloads
need the first two without the third:
What such work needs, stated as requirements:
t; abort leaves canonical state exactly as if the saga had never
run. No third outcome. (A saga may register a compensation — a
deliberately authored failure record applied atomically with the abort
— see Compensation; what never happens is a partial
landing of the saga's own writes.)| Mechanism | What it gives | Why it is not the answer alone |
|---|---|---|
Db::with_transaction | Speculative application of tx data to an immutable Db value | Ephemeral and process-local; nothing survives a crash, nobody else can see it |
corium db fork | A durable, writable copy of a database at a basis | Heavyweight (copies the log prefix), lands in the user catalog, no way back: there is no merge |
| Transaction metadata (ADR-0016) | Arbitrary datoms on the tx entity | Vocabulary for labeling saga work, but no atomicity across transactions |
:db/cas, guards in tx fns | Optimistic concurrency inside one transaction | One transaction only |
| Plan/apply with basis fencing (ADR-0020) | The pattern: observe at a basis, validate assumptions at apply time inside the writer | Built for schema; the pattern generalizes |
| Operator service jobs (ADR-0019) | Resumable, auditable long-running work with leases and progress | Jobs run operations; sagas are user data work — but expiry sweeps belong here |
The chosen design is, in one sentence: make with_transaction durable and
shared by backing it with a branch, and make fork mergeable by adding the
merge the fork never had — with the transactor's single-writer pipeline
validating the merge exactly the way it validates any transaction.
Four models were worked through. The first three fail a requirement each in an instructive way; the record of why is part of the decision.
Each step commits to the database immediately, tagged with a saga id on its transaction entity; abort runs compensating transactions in reverse order. This is the textbook saga, and ADR-0016 makes the tagging trivial.
Rejected as the core model. It abandons requirement 2 and inverts
requirement 3: partial state is not merely observable, it is canonical
— every reader sees it, whether or not they can cope, and any writer can
build on facts that a later compensation retracts. Compensation is
best-effort semantics, not atomicity: retracting a datom does not retract
the decisions other transactions made from it. In an immutable-history
database the aborted saga also stays in every history view and since
window as ordinary user data, indistinguishable from work that was meant
to last. Classic sagas do solve the external-effects problem, though, and
the design keeps them — as an orchestration layer above the database
mechanism (see Compensation), not as the database
mechanism.
Let a saga reserve entities or attributes in the main database and block or fail conflicting writers until it completes.
Rejected without much struggle. Corium's reader model is coordination-free and its writer is a serialized pipeline measured in milliseconds; a days-long reservation is a lock by another name, held across the exact time scales where owners crash, leases expire, and humans go home for the weekend. It violates requirement 5 for writers and imports every deadlock and starvation question the architecture was designed to never ask.
Commit saga steps to the parent log, but mark their transactions as
pending; the default Db view filters datoms whose transaction belongs
to an open or aborted saga, and commit is one small transaction flipping
the saga's status — atomically revealing everything.
This is seductive: one log, atomic reveal, cheap abort, and partial progress sits right there for saga-aware views. It was rejected on invariants:
t=100 is visible comes to depend on a status transaction at
t=250. as-of 200 must answer differently before and after t=250
exists — either time views become status-dependent (an as-of value
that changes retroactively is a contradiction in terms in this system)
or as-of shows pending data that db hides, which is worse.The one idea worth keeping is the atomic reveal via one small status transaction; the chosen design keeps exactly that, but with the pending data outside the parent log.
Fork-without-the-weight plus the merge fork never had. A saga:
t₀;Every requirement lands somewhere concrete. Durability: branch log + registry are ordinary durable state. Atomicity: the merge is one parent commit. Default isolation: parent readers see nothing until merge, so nothing they read can ever be rolled back. Observability: the registry entry is ordinary data in the parent — discoverable with plain Datalog — and the branch is a real database value, so partial progress is queryable with the full surface (Datalog, Pull, SQL, time views) by anyone who asks for it. No reader coordination: branches share immutable segments; nobody blocks. The rest of this document specifies the pieces.
The engine installs a small reserved vocabulary in every database —
attributes in :db.part/db, allocated from the reserved sequence range
below the first user-installable id, the same register :db/txInstant
lives in — so the registry needs no application schema and travels with
backup, restore, and replication like any data (what does not travel
with it is the branch; see the liveness invariant under
Lifecycle):
| Attribute | Type | Card | Notes |
|---|---|---|---|
:db.saga/id | uuid | one | unique identity; the saga's name everywhere |
:db.saga/status | keyword | one | :db.saga.status/open → …/committed | …/aborted | …/expired — namespaced like every enum in the :db vocabulary (:db.cardinality/one); prose below abbreviates to :open etc. |
:db.saga/basis-t | long | one | parent basis t₀ the branch was rooted at |
:db.saga/description | string | one | human-readable purpose |
:db.saga/owner | string | one | authenticated principal that opened it |
:db.saga/expires-at | instant | one | deadline; extendable by the owner while :open |
:db.saga/id-grants | ref (component) | many | entity-id blocks leased to the branch; grant entities carry :db.saga.grant/partition, /start, /length |
:db.saga/footprint | ref | many | advisory declared touch-set — entities, or attribute entities to name whole attributes |
:db.saga/reserves | ref | many | checked reservation set — entities, or attribute entities; binds the saga's own writes, never other writers (see Footprints and reservations) |
:db.saga/sealed | boolean | one | when true, the reservation set is fixed at open and cannot grow |
:db.saga/merged-tx | ref | one | on commit: the merge transaction |
:db.saga/steps | long | one | on commit: number of branch transactions squashed |
:db.saga/conflict-report | string (EDN) | one | on a failed merge attempt: what collided (latest attempt) |
:db.saga/on-abort-tx | string (EDN) | one | compensation: static tx data applied atomically with the abort/expiry flip (see Compensation) |
:db.saga/on-abort-fn | ref | one | compensation: a :db/fn entity invoked at abort/expiry with the parent and branch values; at most one of /on-abort-tx, /on-abort-fn |
:db.saga/on-abort-error | string (EDN) | one | why a system-time compensation did not land: validation failure at expiry, or the branchless-expiry skip |
:db.saga/compensations | ref (component) | many | external-compensation ledger entries (:db.saga.compensation/key, /status, /detail, /completed-at, /error) — written by the orchestrator, never executed by the engine |
:db.saga/guard | string (EDN) | many | step tx metadata, used in the branch: a guard declared by the step that established the read dependency (see Merge) |
Opening, expiry extension, commit, and abort are ordinary parent transactions touching this entity — which is the whole visibility story:
Db value and query
partial progress with the full read surface. They have "joined the saga"
for reads without any protocol: no locks, no registration, no effect on
the saga.Rollback-safety for tier 2 falls out of two facts. Everything a branch
reader saw was labeled — it came from a Db whose identity is the saga
id — and the registry transition is data they can observe. On
:committed, the merge transaction carries the saga id and the merged
novelty is exactly the branch novelty with entity ids unchanged (see id
grants below), so "what I saw" maps one-to-one onto "what became
canonical." On :aborted, nothing they saw was ever canonical, and they
know precisely which facts those were. The adaptation contract for a tier-2
reader is one sentence: treat everything read from a branch as provisional
under that saga id until the registry says :committed.
The footprint is deliberately advisory. A binding footprint would be Option B's locks through the back door. As a declaration it lets tier-1 readers and tooling warn about overlapping in-flight sagas at open time, without ever being load-bearing for correctness — correctness lives in merge validation.
An advisory declaration has a corresponding weakness: readers cannot
rely on it. A saga may touch entities it never declared, so "X is not
in any footprint" answers nothing. For sagas that know their shape up
front, the registry therefore offers a second, checked mode:
:db.saga/reserves names the exact pre-existing entities (and/or whole
attributes) the saga will operate on, and the engine enforces the
declaration — against the saga itself. The direction of the binding is
the whole trick: a reservation constrains the saga's own branch writes
and never touches any other writer, so it is a contract, not a lock.
Option B stays rejected.
Enforcement is a step-time check in the branch pipeline, after expansion and tempid resolution, refusing the step (like any validation error) on violation:
e is a pre-t₀ entity must have
e in the reserved entities or a in the reserved attributes;t₀ entity must target a reserved
entity (or be asserted under a reserved attribute). This closure rule
is not pedantry: corium indexes refs in reverse (VAET), so a new
entity merely pointing at X changes what reverse-ref navigation from
X returns after merge. Without the rule, "X is outside the reserved
set" would be false in exactly the way a reader can't see coming;What a reserved saga buys each party:
:db.saga/reserves, visible to
readers with a basis, exactly like any other registry change. A saga
opened :db.saga/sealed forgoes even that: its set is fixed at open,
which is the strongest statement a reader can lean on. (The basis-
relative contract is the honest one either way: "complete as of the
registry I read," with extensions arriving as observable tx-reports.)Reservation granularity matters at scale. Reserved entities are registry
datoms in the parent — reserving a million entities is a million datoms,
which is the wrong shape. Bulk work reserves attributes instead
("this saga writes only :order/status"), trading per-entity precision
for a set that stays small; entity-level reservation is for the
workflow-shaped sagas that motivated this design, whose write sets are
tens of entities known by name. The two combine, and both remain
optional: a saga with no reservations behaves as before, advisory
footprint and all.
A saga branch is not a db fork, though it reuses its machinery where it
can:
db fork | saga branch | |
|---|---|---|
| Creation cost | copies the log prefix through t — O(history) | overlay: parent's published root as of t₀ + an empty branch log — O(1) |
| Catalog | a full user database | internal (<db> sub-namespaced by saga id); listed via saga surfaces, not db list |
| Lifecycle | independent forever | tied to the saga: merged then retained-or-deleted, or discarded on abort/expiry |
| Entity ids | replays parent counters; diverges freely | allocates from leased blocks so ids survive merge verbatim |
| Transactor | any | the parent's transactor process, but its own writer pipeline — see below |
| Timeline | continues the source's t counters, diverging freely | its own t counters from t₀ — see Branch time |
| Encryption | fresh data key, no shared ciphertext | shares the parent's data key — same trust domain, shares segments by construction; if protection classes land (ADR-0017/0018), class keys gate protected attributes on a branch exactly as on the parent |
| Schema | full database, may evolve | data transactions only; schema changes refused on a branch |
The overlay construction is the same shape a peer already uses for its own
view — published index trees plus a log tail replayed on top — pointed at
the parent's index root for t₀ plus the branch's own log. GC reachability
needs no new mechanism: the branch root reaches the parent segments it
shares, and the sweep already collects only what no live root reaches. A
branch does pin the parent's t₀-era segments for as long as it lives,
which is one reason sagas carry deadlines (below).
Branch steps are ordinary transactions with ordinary guarantees: tempids,
lookup refs, upsert, uniqueness, :db/cas, database functions, tx
metadata — each step validated against the branch's current state and
durably appended to the branch log before ack. A saga with a hundred steps
is a hundred real transactions whose interleaved reads and writes the
owner performed and observed; that observation is what merge preserves
(and what distinguishes the chosen merge semantics from a rebase — see
below).
Steps do not pass through the parent's writer queue. The branch is hosted in the parent's transactor process, but it is its own little database with its own serialized pipeline (and its own group-commit batching): because id blocks are leased up front, a step needs no per-step coordination with the parent at all. The parent's writer is entered only where the parent's state actually changes — open (grant allocation plus the registry transaction), extend, abort, and merge. A chatty saga therefore costs the parent shared process resources (CPU, storage I/O, cache), never queueing latency in its commit path; the one parent-writer pause a saga causes is the merge scan at commit.
A branch keeps its own timeline. Its log numbers transactions from
t₀ + 1 upward, independently of the parent — exactly as a fork does —
and branch transaction entities take ids in :db.part/tx from those t
values. No id grants cover the tx partition, because branch transaction
entities never merge: the squash rewrites every novelty datom's tx to
the single merge transaction, and step tx-entity datoms (:db/txInstant,
step metadata, :db.saga/guard declarations) stay behind in the branch,
which is precisely where the step-grain story reads them (see Squash,
not splice below). Branch tx ids may therefore numerically coincide with
parent tx ids issued after t₀; the two never meet in one database, so
no ambiguity arises — but surfaces that display both (a console pointed
at a branch, corium saga log) should label ids with their timeline.
Time views on a branch Db are the branch's own: as-of t for t ≤ t₀
answers identically to the parent's as-of t (shared prefix, shared
segments); above t₀ it walks branch history. :db/txInstant follows
the ordinary monotonic rule (max(now, last + 1)) within the branch,
seeded from the parent's clock at t₀, so instant-named views on the
branch resolve sensibly even though branch instants and parent instants
after t₀ are unrelated sequences.
What does reading in-flight sagas cost, layer by layer? Never N + 1
log merges, and nothing at all for those who don't ask:
Db remains exactly today's
two-layer construction — published index trees plus the parent's log
tail (index-basis, basis-t] — with registry datoms as ordinary data
inside it, however many sagas are open.Db is the latest published parent root with index-basis
≤ t₀, plus the parent's log records (index-basis, t₀] closing the
gap, plus the branch's own tail. The gap slice is fixed at open — the
branch's base never moves — and retained history roots keep it small
when a publication sits near t₀. So a branch read is the ordinary
peer construction with one bounded, immutable slice inserted.N sagas cost per-branch, on consultation only. No view unions
branches. A reader consulting three sagas holds three Db values; a
multi-database Datalog query over them takes them as separate inputs,
not as one N-way merged log. The per-open-saga cost that accrues to
the system rather than to opting-in readers is the transactor
hosting N branch pipelines and pinning N sets of t₀-era
segments — named in the consequences, bounded by mandatory expiry.Fork's id story does not survive a merge: branch and parent both continue
the counters from t₀, so each allocates the same ids to different
entities, and merged datoms would collide. Two fixes were considered:
:db.saga/id-grants; the branch allocates only from its
blocks and the parent's allocator skips granted ranges. Merged datoms
keep every id verbatim. Sequences are 42-bit per partition; granting
generous blocks (say 2²⁰ ids a grant) to even thousands of sagas costs
nothing that matters, and blocks of aborted sagas can simply be
abandoned — a hole in the sequence space is already normal.Grants are durable (registry datoms) and fenced by the same single writer that allocates parent ids, so a transactor crash between granting and branching cannot double-issue a block. One guard closes the explicit-id loophole: a transaction may name an arbitrary entity id directly, so while a grant is live the parent's writer refuses assertions naming ids inside granted blocks. This is allocator integrity, not a lock — leased id space names no user-visible entity, and no legitimate parent transaction has business writing there.
The merge is where the design earns atomicity, and it is specified in the image of ADR-0020's plan/apply: optimistic work observed at a basis, re-validated inside the single-writer path at apply time, with drift surfaced rather than absorbed.
Semantics: replay the effects, not the inputs. The merge takes the
branch's novelty — the net datom effect of branch transactions
(t₀, t_branch] — and applies it to the parent's current value as one
transaction. It does not re-run the original transaction data (tx
functions, :db/cas forms) against the parent. The saga's owner ran those
steps against branch state, observed the results, possibly took external
actions on the strength of them, possibly had a human review them; a
rebase that re-evaluates functions against different state could silently
commit something nobody observed. When the parent has changed in a way
that matters, the right behavior is to fail loudly, not to recompute
quietly. (A --rebase merge mode that replays inputs is noted under
future work; it is a different promise and must be asked for by name.)
The merge algorithm, inside the parent's writer:
Load the branch novelty: fold branch transactions into net assertions
and retractions (an intra-branch assert-then-retract cancels; the last
write per (e, a) on cardinality-one wins — this is the squash).
Branch tx-entity datoms are excluded: step transaction entities never
merge (see Branch time).
Conflict scan against parent changes in (t₀, now]:
(e, a) the branch also wrote (cardinality-one), or the exact
(e, a, v) (cardinality-many);:db/retractEntity);(e, a, v) whose v is no
longer the parent's value (the CAS-shaped conflict).For a saga with reservations, the write–write, dangling-ref, and retraction-miss scans confine to the reserved set plus granted blocks — the step-time checks guaranteed novelty touches nothing else pre-existing (see Footprints and reservations); uniqueness stays global.
One non-conflict is a deliberate choice, not an omission:
cardinality-many assertions union. Parent and branch each asserting
different values on the same many-cardinality (e, a) merge to the
union — that is what a set-valued attribute means — and both asserting
the same (e, a, v) is idempotent. Only the exact-triple races above
(parent retracted a triple the branch asserts, or the reverse) conflict.
A saga that needs "nobody else added to this set" is asserting a read
dependency, and declares it as a guard.
Guard evaluation: guards are :db/cas-shaped preconditions and
boolean guard queries evaluated against the parent's current value —
how a saga makes its read dependencies explicit. The engine does not
track read sets (see Limits), so serializability beyond write-write is
opt-in and visible. Guards come from two places: the commit request,
and — so the contract is durable rather than living only in one
process's memory — :db.saga/guard metadata asserted on the branch
step that established the dependency, at the owner's option. Commit
evaluates the union of both by default; a step-declared guard that
fails is reported with the step that declared it, which makes the
conflict traceable to the read that mattered. A crashed owner, or a
different process resuming the saga, inherits the declared guards for
free.
Full transaction validation of the novelty against the parent's
current schema (which may have migrated since t₀ — ADR-0020 makes
schema basis-versioned, and merge validates against now, exactly as a
fresh transaction would).
If anything failed: no parent write except an updated
:db.saga/conflict-report on the registry entry; the saga stays
:open and the branch is untouched. The report is EDN naming each
conflicting (e, a) with both sides' values — a plan document, in
ADR-0020's sense.
If everything passed: append one parent transaction containing the
novelty plus the registry flip to :committed plus
:db.saga/merged-tx/:db.saga/steps, with the saga id asserted on the
transaction entity. One log record; atomicity is structural, and a
crashed-and-retried commit is idempotent because the retry finds
:committed.
Squash, not splice. Branch transactions are not replayed as individual
parent transactions. Splicing would fabricate parent history — t values
interleaving a past that did not happen on the parent's timeline — and
break :db/txInstant monotonicity (ADR-0016). The parent's log tells the
truth: one commit, at commit time, labeled with the saga id. Step-level
history — who did what, when, in which order, with what tx metadata —
remains fully queryable in the branch, which can be retained after
commit as a read-only audit annex (subject to a retention policy) or
deleted once nobody needs it. History-minded readers get:
[?tx :db.saga/id ?saga] in the parent to find the merge, and the branch
as-of/history views for the fine grain.
Conflict resolution and retry. A failed merge is information, not a
dead end. The commit request accepts per-conflict resolutions, each fenced
to the conflict report it answers: a resolution names the parent-side
value the report showed, and holds only while that value still stands —
further parent drift on a resolved (e, a) is a fresh conflict, never
silently absorbed. Reading the report is the observation that makes
resolving consistent with effects-replay: the owner is no longer deciding
against unobserved state.
Which resolutions exist depends on the conflict class, and the asymmetry is principled:
(e, a), where it has an
exact expansion: retract the parent's reported current value, assert the
branch's. Both datoms name state the owner has seen (one in the branch,
one in the report), and the write stays within the saga's own footprint.A retry validates that no conflicts appeared beyond the resolved set, then
proceeds. The saga owner can also simply keep working — resolve the
divergence with further branch transactions informed by the report, and
commit again. A general base refresh (folding parent novelty into the
branch to re-root it at a newer t₀) is future work; it is a merge in
the opposite direction and earns its own design pass.
States: :db.saga.status/open → :db.saga.status/committed | :db.saga.status/aborted | :db.saga.status/expired (abbreviated :open
etc. throughout, per the registry table). All transitions are single
parent transactions;
there is no committing limbo because the flip rides inside the merge
transaction itself. Abort and expiry likewise carry any registered
compensating transaction inside the same flip (see
Compensation); the failure table below
includes its edges.
| Failure | Outcome |
|---|---|
| Crash between registry open and branch creation | Registry entry exists, branch does not; open is resumable/idempotent by saga id (branch creation is a deterministic function of the registry entry) |
| Crash mid-step | Step either fully in the branch log or absent — ordinary transaction durability, per step |
| Crash during merge, before append | No parent effect; saga :open; retry re-runs the scan against then-current state |
| Crash during merge, after append | Saga is :committed (same record); retry observes the status and reports success |
| Abort races an in-flight merge | The parent's writer serializes them; whichever lands first wins, and the loser gets a status error — an abort arriving after the merge committed fails with "already :committed", it does not report success |
| Owner disappears | :db.saga/expires-at passes; the expiry sweep expires the saga, applying any registered compensation |
| Compensation fails at explicit abort | The abort fails like any invalid transaction; the saga stays :open — fix the tx data or abort without it |
| Compensation fails at expiry | Liveness outranks: the sweep expires the saga without the compensation datoms and records :db.saga/on-abort-error; branch retention is the grace period to record by hand |
| Transactor failover | Registry, branch log, and grants are durable storage state; the new lease holder sees all of it — nothing lived only in memory |
Restore, fork, or replica finds :open registry entries with no branch | The saga is expired on first open — see the liveness invariant below |
A saga is live only where its branch lives. The registry travels with
the parent's data — backup, restore, db fork, replication — but the
branch does not: backups do not capture saga branches (v1), and a fork
copies the parent's log prefix, registry datoms included, never the
branches. So a database can find itself holding :open registry entries
whose branches are absent — a restored parent, a fork taken while sagas
were in flight. One rule covers every such case: on first open, the
transactor expires any :open saga whose branch does not exist. :expired
already means "the system, not a decision, ended this," which is exactly
what happened; the entries remain as honest history, and nothing dangles.
A branchless expiry also never applies a registered compensation: a
restored or forked database shares its timeline's past with the original
but not its future, and applying the same failure record on both sides of
the divergence would double every externally visible consequence — the
skip is recorded in :db.saga/on-abort-error.
Expiry is mandatory. An open saga pins parent segments from t₀ and
holds id grants; an abandoned one must not do so forever. Every saga
carries :db.saga/expires-at; the owner extends it (heartbeat at whatever
period suits the workload — an interactive saga might extend daily); a
sweep — an operator-service job (ADR-0019), with an in-transactor fallback
so the data plane does not depend on the service — transitions overdue
sagas to :expired and queues their branches for deletion. :expired is
distinguished from :aborted so a returning owner knows the system, not a
decision, ended the work; the branch retention window gives them a grace
period to salvage before deletion. (Salvage means application-driven
replay: open a fresh saga at a new t₀, query the old branch, and
re-assert what is still wanted. It is not an engine operation, and it does
not depend on the future base-refresh work — the application decides what
survives, with both Db values in hand.)
Retention is a per-database policy knob — how long committed and expired branches are kept before deletion — with a per-saga override at open for workloads whose audit or salvage needs differ. The operator service administers it like any schedule; the in-transactor fallback applies the per-database default.
Concurrent sagas compose without new rules: each has its own branch and disjoint id grants; merges serialize through the parent's writer; the first to merge wins, and later merges see its effects in their conflict scan. Footprint overlap between open sagas can be warned about at open time; it is never an error, because footprints are advisory.
Abort's default is total: one status transaction, the branch discarded, canonical state untouched. That default is correct and it stays the default — but it leaves two real needs unserved, one on each side of the database boundary:
The design serves each with an explicit mechanism, and keeps them distinct, because they are distinct: one is a transaction the database can apply atomically, the other is bookkeeping about work only the application can perform.
A saga may register a compensation: tx data applied to the parent
atomically with the abort — one parent transaction carrying the flip to
:aborted (or :expired), the compensation's datoms, and the saga id on
the transaction entity. Observers get both in one tx-report: no window
exists in which the registry says the saga failed but the failure record
is missing, and [?tx :db.saga/id ?saga] finds the record forever.
Two registration forms, at most one per saga, both durable registry data
settable at open or while :open:
:db.saga/on-abort-tx — static EDN tx data, for the common case of
asserting a few known failure facts;:db.saga/on-abort-fn — a ref to an ordinary :db/fn entity, invoked
at abort as (parent-db, branch-db, saga-id) → tx-data in the same
sandboxed runtime, fuel budgets, and determinism contract as any
transaction function (ADR-0008), with one extension: it receives the
branch's Db value alongside the parent's, so it can summarize what
the saga was doing — steps completed, entities touched, external-action
metadata — into the record it writes.Registration is durable for the same reason step-declared guards are: the contract must not live only in one process's memory. A crashed owner's saga reaches its deadline and the expiry sweep applies the registered compensation with no owner present. An explicit abort may also supply compensation tx data at call time — the aborter has observed current state and decides — and call-time data replaces the registered form for that abort.
The semantics are the merge's principles pointed at failure:
branch-db), never outputs:
branch novelty is not filtered into it, and ids from the saga's granted
blocks are refused in its tx data — the explicit-id guard still holds
at the moment of abort, and the abandoned block stays what abandoned
blocks always are, a hole. New entities the compensation creates take
ordinary parent ids.:open, and the owner fixes the tx data or
aborts without it. At expiry the sweep must not be held hostage by a
buggy compensation: it expires the saga without the compensation
datoms and records the failure as :db.saga/on-abort-error; the branch
retention window is the owner's grace period to write the record by
hand.One shape was considered and rejected: a compensation that selects which branch facts to keep — a filtered merge. It drags the entire merge apparatus (conflict scan, guards, grant retention, resolution semantics) into the abort path, for a novelty subset chosen under weaker observation guarantees than commit demands, and the motivating need does not require it: a failure record is a handful of fresh facts, and re-asserting a salvageable value into the compensation covers small keeps (with parent ids — identity does not survive, which is honest, because the entity's branch history does not merge either). Genuine salvage of large partial work already has its path — query the retained branch, re-assert under a fresh saga — and a subset-merge, if it ever earns its keep, is future work beside base refresh.
The branch commits or aborts database effects atomically. It cannot un-send an email. Workflows whose steps have external side effects still need compensation logic — the classic saga — and the design gives that layer a durable home rather than a competing mechanism:
Reverse progress gets the same treatment as forward progress. Driving
compensations outward is itself long-running, crash-prone work, and
without a home for its state every orchestrator invents the same
bookkeeping schema. The registry therefore carries an
external-compensation ledger — :db.saga/compensations, component
entities with:
| Attribute | Type | Card | Notes |
|---|---|---|---|
:db.saga.compensation/key | string | one | application-chosen identifier; opaque to the engine |
:db.saga.compensation/status | keyword | one | :db.saga.compensation.status/pending → …/done | …/failed | …/skipped |
:db.saga.compensation/detail | string (EDN) | one | the handle/payload the compensating task needs — what to undo, and how |
:db.saga.compensation/completed-at | instant | one | when it resolved |
:db.saga.compensation/error | string (EDN) | one | why it failed, if it did |
The engine never executes these — the honest split stands. They are a standard ledger the orchestrator writes with ordinary parent transactions as it works. What the vocabulary buys:
on-abort-fn reads the branch's
external-action metadata and asserts the pending entries — atomically
with the :aborted flip. From the first instant any reader sees the
saga failed, the complete undo list is durably in the parent, whether
or not the orchestrator ever wakes again.corium saga status (and the SQL relation) can show
"aborted, two of five compensations pending"; tier-1 readers can
distinguish an aborted-and-unwound saga from an aborted one whose
external effects still stand — a real difference to downstream systems.No new saga status arises. :aborted and :expired stay terminal for
the database side; unwinding progress belongs to the ledger, and "fully
compensated" is a derived fact tooling computes. Retention may consult
it — a branch whose saga's ledger is fully resolved can be reclaimed
early — but a pending ledger never extends retention past its window
(liveness again), because the ledger, unlike the branch, persists.
This split is the honest division: atomicity where the system can actually promise it (its own state), durable bookkeeping where it cannot (everyone else's).
Connection::saga_open(db, opts) → Saga (opts:
description, footprint, reservations and sealed, expiry, id-grant
sizing, on-abort compensation); Saga::reserve(...) to extend an
unsealed reservation set; Saga::transact(...) / Saga::db()
(ordinary Db value of the branch); Saga::commit(guards, resolutions) → Result<MergeReport, ConflictReport>; Saga::abort() /
Saga::abort_with(tx_data) (call-time compensation replacing the
registered form); Saga::extend(expiry);
Connection::saga_resume(db, id); Connection::saga_view(db, id) → Db
for tier-2 readers. Registry queries are just queries, and the
external-compensation ledger is written with ordinary transactions —
no dedicated API.DbViewSpec-shaped
reference naming the saga), so thin clients get tier-2 reads for free.corium saga open|list|status|extend|commit|abort <db> ...
(open --on-abort <fn-ident|edn>, abort --compensate <edn>;
status includes the compensation ledger); corium console <db> --saga <id> to point a console at a branch; corium saga log <id>
for step history.corium_sagas system relation over the registry (id, status,
basis, owner, expiry, description) and a corium_saga_compensations
relation over the ledger, beside the existing system
relations; branch reads via the console/session db-view selection.
Mapping pgwire interactive BEGIN/COMMIT onto sagas is explicitly out
of scope for v1 (ADR-0015's guarded autocommit stands); it is an
obvious later customer.:db.saga/on-abort-error, never blocking expiry). The registry's own
attributes are ordinary data under the same model.corium_sagas relation, no branches yet (a saga with no
steps is already useful as a durable workflow record). Compensation
vocabulary included: static :db.saga/on-abort-tx and the
external-compensation ledger need no branch at all.:db.saga/on-abort-fn invocation,
which needs the branch value.Each phase leaves the system consistent and shippable; nothing in the data plane depends on a later phase.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |