Status: Proposed (2026-08-25). 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.:open registry entries with no branch — a restored parent, a fork
taken mid-saga — expires them on first open.: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.: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.≤ 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_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.corium_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 |