Status: Accepted (2026-08-25); implemented but for the thin-client and
SQL polish of the delivery sketch's last phase — the :db.saga/* vocabulary,
its lifecycle transitions and the rules the writer holds them to, the peer
API, corium_sys.sagas, and corium saga; id-block leasing in the allocator,
the branch overlay and its step pipeline, and the tier-2 read surfaces; the
merge, with its conflict scan, guards, resolutions, and atomic
commit-and-flip; and the way a saga ends when nobody ends it — compensation
composed in the transactor and applied atomically with the flip, the expiry
sweep, the liveness invariant, and branch retention. Design:
docs/design/long-running-transactions.md.
Builds on ADR-0016 (transaction
metadata), follows the plan/apply pattern of
ADR-0020, and assigns its expiry sweep
to the operator service of ADR-0019.
A Corium transaction is one atomic batch, serialized and durable in milliseconds. Atomicity, isolation, and brevity are welded together, and real workloads need the first two without the third: multi-step business processes that run for days, human-reviewed repairs and backfills, incrementally prepared imports published in one motion. Such work must be durable across crashes, must commit as a whole or leave canonical state untouched, and must not hold anything resembling a lock in a system whose readers are coordination-free by invariant.
The visibility requirement is the tension that shaped the design. A saga must not be invisible to the wider system — outside readers may legitimately want to know work is in flight, and some want to read its partial progress — yet readers must not be required to join the saga or even know sagas exist, and a reader who did observe partial progress needs a way to adapt when the saga rolls back.
Existing mechanisms each fail alone: Db::with_transaction is speculative
but ephemeral and process-local; db fork is durable and writable but
heavyweight (log-prefix copy), lives in the user catalog, and has no merge;
transaction metadata labels work but cannot make many transactions atomic.
Three alternative models were rejected in design iteration (recorded in
full in the design doc): classic interleaved-commit sagas with
compensations make partial state canonical for every reader and reduce
atomicity to best-effort compensation; long-lived write intents are locks
held across days and violate the coordination-free invariant; tentative
datoms in the parent log with status-flipped visibility make as-of views
status-dependent — visibility would no longer be a pure fold of the log
prefix — and put a saga filter on every hot read path.
A saga is a database branch plus a registry entry in the parent database.
t₀ plus the branch's own
log — hosted in the parent's transactor process. O(1) creation,
segments shared by content address, parent's encryption keys, not in
the user catalog. Steps are ordinary fully-validated durable
transactions against the branch, serialized in a per-branch
pipeline: pre-leased id blocks mean a step never enters the parent's
writer queue — only open, extend, abort, and merge do. The branch keeps
its own timeline (t from t₀, its own :db/txInstant monotonicity);
branch transaction entities never merge, so the tx partition needs no
grants. Schema changes on a branch are refused.:db.saga/*: id, status,
basis, owner, expiry, id grants, advisory footprint, checked
reservations, outcome refs) in every database; enum values are
namespaced idents in the :db convention (:db.saga.status/open,
abbreviated :open etc. in prose). Open, extend, commit, abort, and
expire are ordinary parent transactions on the saga entity.:db.saga/sealed fixes the set at open), and the merge's
write–write, dangling-ref, and retraction-miss scans confine to the
reserved set. Parent writers are never constrained — races on reserved
entities are still arbitrated by the merge scan, now with an early
warning tier-1 tooling can watch for.(t₀, now], uniqueness, dangling refs, retraction misses
(cardinality-many assertions deliberately union), plus explicit guards
(CAS-shaped preconditions, guard queries) for read dependencies —
supplied with the commit request or declared durably as :db.saga/guard
step metadata along the way, with commit evaluating the union. The
registry flip to :committed rides in the same transaction — atomicity
is structural, retries are idempotent. Effects are replayed, not
inputs: tx functions are never silently re-evaluated against state the
owner didn't observe; drift fails loudly with an EDN conflict report,
and a retry may carry per-conflict resolutions fenced to that report —
accept-parent for any conflict class, override only for
cardinality-one write–write, where it has an exact observed expansion;
uniqueness, dangling-ref, and retraction-miss conflicts are never
override-able, because each override would write outside what the owner
observed or the saga touched. No splicing of branch transactions into
parent history — the parent log records what happened on the parent's
timeline; step-level history stays queryable in the branch, retained
post-commit under a per-database retention policy (per-saga override at
open).Db value
(Datalog, Pull, SQL, time views) without locks, registration, or effect
on the saga; their adaptation contract is: everything read from a
branch is provisional under that saga id until the registry says
:committed, and the merge transaction's saga id maps what they saw
onto what landed.t₀-era segments forever. :expired is distinct from
:aborted. The retention window runs from the transition that finished
the saga — a per-database policy, with a per-saga :db.saga/retain-for
override declared at open — and it covers all three terminal states: a
committed saga's branch is the step-grain annex, an aborted or expired
one's is what the orchestrator reads to find what still needs unwinding
outside the database.:open registry entries with no branch — a restored parent, a fork
taken mid-saga — expires them on first open, whether or not the periodic
sweep is enabled. So that absence means that and not "not stepped yet", a
branch's durable existence begins with its registry entry: the
transaction opening a saga writes the branch's metadata root, and the
overlay itself is still built on demand — but only ever from a root
already written, since building one on demand would forge the invariant's
own signal. An abort or expiry reaching an inherited entry before the
pass does skips the compensation for the same reason.:db/fn invoked with the parent's current value plus the branch value):
fresh tx data validated like any transaction and applied atomically
with the abort/expiry flip, saga id on the transaction entity — a
deliberately authored, user-facing failure record, never a partial
landing of branch novelty. Branch facts are inputs to it; its granted
ids are refused as outputs; a filtered (subset) merge was considered
and rejected. Registration is durable registry data, so expiry applies
it for a crashed owner; liveness outranks — a compensation failing at
expiry is recorded (:db.saga/on-abort-error) and the saga expires
without it, and a branchless expiry (restore, fork) skips it so
diverged timelines never double-apply a failure record. The transactor,
not the client, composes that transaction, because the function form is
invoked with two database values and no client holds the second.:db.saga/compensations component entities — key, status, detail,
completed-at, error) written by the orchestrator as ordinary parent
transactions and never executed by the engine, surviving branch
deletion, seedable atomically at abort by the compensating transaction.
No new saga status: :aborted/:expired stay terminal, and "fully
compensated" is derived from the ledger.V1 excludes: read-set tracking (serializability beyond write-write is
opt-in via guards), nested and cross-database sagas, base refresh and
rebase-commit modes, abort-time subset merges and any engine execution of
external compensations, and any pgwire BEGIN mapping.
t₀) inside the writer path.
Merge of a large saga is an observable pause for that database, like
index activation in ADR-0020; the conflict scan bounds it, splicing
was rejected partly to keep it one append. Steps never queue in the
parent's writer, but a chatty saga still shares the transactor
process's CPU, I/O, and cache for its whole lifetime.t₀ and holds id grants; GC
pressure and grant consumption are bounded by mandatory expiry rather
than by trusting owners. The bound is only as good as the sweep, so a
node with sweeping disabled owes the duty to an operator; and because
the window is measured from the finishing transition rather than from
the deadline, a saga that expires late still gets the full grace period
its owner would have had.≤ t₀, the frozen parent gap (index-basis, t₀], the branch tail —
never N + 1: no view unions branches, and branch index publication
(the existing indexing job) collapses long-lived branches back to
ordinary two-layer reads. Tier-0/1 reads are unchanged at any N.:db.saga/on-abort-error rather than blocking expiry).corium_sys.saga_compensations); the
engine still never executes an external compensation, and retention may
end early for a fully-resolved ledger but is never extended by a
pending one.:db/fn runtime multi-database:
a token now names which database in scope it stands for, and the
expander takes a set rather than one value. That is a small widening of
a deliberately narrow sandbox surface — the databases remain read-only,
a token naming one out of scope is an error rather than a fall back to
the primary, and no existing function sees any difference — but it is
the first place a :db/fn reads outside the value its transaction is
being prepared against, and future sandbox work has to keep that in
view.corium_sys.sagas SQL relation, authz applied
to branch views (ADR-0021 unchanged), operator-service sweep job.
The registry-first delivery order keeps each phase shippable and the
data plane free of operator-service dependence.with_transaction remains the
zero-cost speculative tool for single-process what-ifs.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 |