Persistence and Transactions
Database Mappings
Section titled “Database Mappings”Arda uses Exposed for database access.
-
Column names must be specified in lowercase snake_case.
-
Use column types defined under
EntityTable. When those types are insufficient, use native Exposed column types. -
When using
Filter.Eqand similar, use<TBL>.<COLUMN>.namefor the locator parameter instead of hardcoded strings, to eliminate the risk of mismatched column names. -
JSON columns with custom serializers: The reified
json<T>(name, format)overload callsserializer<T>()at runtime, which ignores@Serializable(with=...)and@Contextualannotations on type arguments. WhenTcontains a non-@Serializableclass (e.g.,java.net.URI), use the three-argument overload with an explicitKSerializer:// WRONG — fails at runtime: serializer<Map<String, URI>>() cannot find URI serializerval sites = tbl.json<Map<String, URI>>(name, JsonConfig.standardJson)// CORRECT — explicit serializerval sites = tbl.json<Map<String, URI>>(name, JsonConfig.standardJson, MapSerializer(String.serializer(), URISerializer))EntityTablealso providesstandardJson<T>(name), which resolves contextual serializers fromJsonConfig.standardJson.serializersModule. Prefer it overjson<T>(name, JsonConfig.standardJson)when contextual serializers are sufficient. -
Unchecked casts to
ChildTable: When castingEntityTabletoChildTable(required due to invariant generics onExposedLocatorTranslator), always guard withcheck()before the cast:override val translator by lazy {check(persistence.bt is ChildTable) {"MyUniverse requires a ChildTable, got ${persistence.bt::class}"}queryConfig.bindToTable(persistence.bt as ChildTable)} -
QueryCompilerreuse: Never constructQueryCompiler(table)inline inside service methods. Define a module-levellazy valin the persistence package, or expose aninternalaccessor on the universe:// Module-level lazy val (preferred for child universes)internal val myQCompiler by lazy {QueryCompiler(MY_TABLE, myQueryConfig.bindToTable(MY_TABLE))}// Internal accessor on parent universeinternal val queryCompiler get() = qCompiler
Persistence shape: Universe vs. plain table
Section titled “Persistence shape: Universe vs. plain table”Choose how to persist an entity by whether it has a lifecycle:
- No lifecycle — standalone, immutable records that are never updated (an audit / event log). A plain Exposed table is fine; there are no “versions of one entity” to interpret, so the Universe machinery buys nothing. Deduplication, if needed, is a unique-index concern.
- Has a lifecycle — created, mutated, soft-deleted, or otherwise evolving over time
(multiple rows are versions of one logical entity). Default to a bitemporal
ScopedUniverse. Do not hand-roll soft-delete, version history, or tenant scoping on a plain table — theAbstractScopedUniverse/ScopedTable/ScopedRecord/Persistencestack already provides them, tested and composable:retired-flag soft-delete, a row per change (full audit history), scoped metadata (tenant_id, author, created / updated timestamps), structural tenant isolation viaScopedUniversalCondition, andcreate/read/findOne(Filter, asOf)/list(Query, asOf)/update/deleteoperations that honor the “latest non-retired version” semantics. - Special cases (e.g. a Draft Store, or another non-standard store) — consult the user / operator before deviating from the Universe default; do not pick a bespoke persistence shape unilaterally.
“No tenant” is not a reason to leave the Universe. AbstractScopedUniverse is the default
because nearly every entity belongs to a tenant, but an entity that is global — or that is keyed
by a user across all of that user’s tenants — belongs on the non-scoped AbstractUniverse,
not on a hand-rolled table. A ScopedTable requires a tenant_id on every row, so it genuinely
cannot hold such an entity; that constraint rules out the scoped base, not the Universe stack.
Choosing a bespoke store there gives up identity, the version chain, audit columns, and
latest-non-retired semantics in exchange for solving a problem the non-scoped base already
solves.
A concept that needs both — the same payload stored sometimes tenant-partitioned and sometimes
not — is served by two universes, one on each base, sharing their behavior through a by
delegate rather than by one universe with an optional tenant column. See
Universe Design.
A business-key uniqueness constraint on a bitemporal table (e.g. “at most one active row per
(tenant, key)”) is enforced in the application layer — check for a live entry before
inserting — not by a DB partial-unique index: a row-version write inserts the new version
before retiring the old one and would otherwise trip a non-deferrable unique constraint.
Naming pitfall: a
ScopedTablecolumn property namedsource(or any name that collides with an ExposedColumnSetmember) fails to compile — “‘source’ hides member of supertype ‘ColumnSet’ and needs an ‘override’ modifier.” Name the Kotlin property non-collidingly and keep the DB column name via the string argument, e.g.enumerated<…>("source").
Deferred DB actions carry the transaction requirement (DBIO)
Section titled “Deferred DB actions carry the transaction requirement (DBIO)”Enforced by CollapsedDBIO (tree).
A database action is a DBIO<T> — suspend () -> Result<T> — that reads the ambient
Exposed transaction from the coroutine context when it runs. Invoking one (dbio())
outside an active transaction is a runtime error. So the transaction requirement is real,
and it should live in the type, not in a comment.
When a method produces a DB action whose execution must be controlled by the caller’s
transaction, return the un-invoked DBIO<T> — do not call () internally. Persistence
methods already follow this (Universe.create/update/… return DBIO<…>); a method that
composes one for a caller to run must not collapse it back to a value.
This matters most for a deferred write that must be atomic with something else — e.g. a
domain write that has to commit in the same transaction as an idempotency settlement, an
outbox row, or a sibling aggregate. Such a write must run inside the caller’s outer
transaction, so it must be handed back as a DBIO the caller runs — never opened in its own
nested inTransaction { … }. (A nested inTransaction joins a usable ambient transaction on the same
database, but the join is invisible at the call site, and a joined call that states its own retry policy limits
its owner: see R6. So
atomicity-bound deferred writes are DBIO, run by the caller.)
// WRONG — invokes the DBIO (`()`) inside a lambda and re-wraps it as an untyped closure,// so the "needs a transaction" guarantee is only a convention + comment.FreshAttemptResult.Commit(job) { universe.create(job, metadata, asOf)().unitify() // runs the insert here; throws if no tx}
// CORRECT — hold the un-invoked DBIO<Unit>; the caller runs it inside its transaction.FreshAttemptResult.Commit( result = job, persist = universe.create(job, metadata, asOf).map { }, // DBIO<Unit>, not yet run)The carrier type states the contract: val persist: DBIO<Unit> (not
suspend Transaction.() -> Result<Unit> invoked eagerly). The caller does
inTransaction(db) { … attempt.persist() … }, and the type makes it impossible to forget
that a transaction is required.
Exception — a deferred action that may legitimately do no persistence. If a carrier’s
action is sometimes a DB write and sometimes a pure computation, do not force a DBIO; route
the DB case through a service method that owns its own inTransaction, which joins the caller’s transaction
on the same database when one is open, and so states retry = RetryPolicy.Inherit
(R6). Choose this only when non-persistence
implementations are a real, foreseen case — not to avoid typing a write as DBIO.
Trade-off to note: a DBIO built from create(payload, metadata, asOf) captures asOf at
build time, not run time. When the exact recorded/effective instant must be the settlement
time rather than the build time, evaluate it inside the DBIO body; otherwise the build-time
value (usually the more meaningful business instant) is fine.
Service contracts and implementation bindings
Section titled “Service contracts and implementation bindings”Starting with common-module 25.0.0, public data-authority and sequence service contracts
carry no DBIO member, including a DBIO nested inside another type. Database-action
hooks belong to implementation-facing bindings. A binding can be public for a library
adopter to implement; it is still local to the owning business module and is never a
cross-module service contract. Data-authority contracts also omit universe, db, and
draftStore, so callers do not obtain persistence indirectly through them.
The split changes API placement, not transaction policy. Data-authority update/delete
retain their transaction boundary and hook dispatch. Sequence getters on
ScopedSequenceBinding and SerialNumberBinding remain participants in their caller’s
transaction. SerialNumberService.getNext and getNextString retain their deliberate
separate transaction. See the project notebook
for the complete member inventory and adopter edits.
Every database access is a boundary or a participant
Section titled “Every database access is a boundary or a participant”MP’s rule (2026-10-15), verbatim:
All methods that execute DB access must, without exception:
- Return a DBIO Effect that can be composed and invoked in a transaction
- Explicitly wrap the calls in a transaction primitive, either joining, new or requiring an ambient transaction
- Universes should always return DBIO for these cases unless I give an explicit waiver (under rachet conditions)
Read with his choice of the two-role reading (“Option (A)”, 2026-10-15), every method that touches the database is one of two things, decided by who owns the transaction:
- A boundary opens and runs its transaction. It names its primitive,
inTransaction(db)orinNewTransaction(db), and returnsResult<T>. - A participant runs inside a caller’s transaction. It returns a
DBIO<T>that the caller composes (the preferred form), or it requires an ambient transaction withinEnclosingTransaction(db), which fails when none is open. - A Universe always returns
DBIO. A Universe method that does not needs MP’s explicit waiver, asked under the ratchet.
The rule applies to new or changed database access; existing code meets it when it is next changed. A reviewer checks each changed method that touches the database: which role it plays, and that its signature says so.
The rules below are the standard that grew from it: what each rule requires, why it holds, and what
enforces it. A mechanism named under “Enforced by” ships in common-module or in the standards
plugin; where none does, review carries the rule.
Vocabulary
Section titled “Vocabulary”-
DBIO. A deferred database action,
DBIO<R>: it does nothing until invoked, and when invoked it runs in whatever transaction is current for the coroutine. It states nothing about that transaction.TDBIO<R>is the block type the transaction entries take; a DBIO runs inside one by invocation. -
Transaction entry. A library function that gives a block a transaction:
inTransactionjoins a usable ambient transaction on the same database, else opens one;inNewTransactionopens one, and refuses a usable ambient one unless toldPERMIT;inEnclosingTransactionrequires one, and fails without;inLockedTransactionopens one after taking its keys, and refuses an ambient one.
inTransactionAsyncis the library’s owner entry, internal to the library, and not an application entry (R7). -
Ambient transaction. The transaction already open for the same database in the current coroutine or thread, and usable: present, on the same database, its connection still open. The same database is the same
Databaseobject, one per connection pool: sincecommon-module23.0.0 the library compares the object and reads no URL to decide it (MP, QA684 (B)); before 23.0.0 it compared the JDBC URL. -
Boundary, participant. A boundary opens and runs its own transaction and returns
Result. A participant runs inside a caller’s transaction and returns a DBIO, or requires the ambient transaction (COORDINATOR-Q534 (A)). -
Owner, join. The entry that opened a transaction owns it: it commits or rolls back, and it retries. A call that runs inside an owner’s transaction has joined it and owns nothing.
-
Retry policy. The owner’s statement of how many times its whole block may be re-run on a database conflict, and under what backoff.
Noneis the default. A joined call is never re-run by a policy of its own; what it states limits its owner, exceptInherit. -
Writer bound. The recorded-axis bound of a writer’s own reads: the transaction’s exact start on the database’s clock. A read at the writer bound decides what the transaction writes.
-
Placement. Where a transaction runs. In production a read-only transaction is routed to an Aurora reader instance, which lags the writer; a read-write one runs on the writer.
-
Three classes of action. A Read decides no write. A ReadOnWrite is a read that decides a write: every read at the writer bound, every admission re-read. A Write changes rows.
The rules
Section titled “The rules”R1. The persistence layer returns DBIO and manages no transaction
Section titled “R1. The persistence layer returns DBIO and manages no transaction”A Universe method, and every other persistence operation, returns DBIO<...> and neither opens, joins
nor probes a transaction. The caller composes the DBIOs and wraps them in a transaction it configures.
Why. A persistence operation that ran its own transaction has already executed by the time the caller
sees its result, outside any transaction the caller controls, and nothing at the call site shows it: a
Result and a DBIO<Result> read the same in a chain. The DBIO type is what lets the service layer decide
atomicity, placement and retry in one place.
Enforced by. UniverseReturnsDBIO and CollapsedDBIO (the standards plugin, tree). In the library,
since common-module 23.0.0 the sequence getters and the draft store run in their caller’s transaction;
getNext and getNextString remain boundaries, the serial sequence’s own transaction is declared
(ambient = PERMIT, RetryPolicy.Inherit), and the connection-level keystore is outside the DBIO world by
nature.
R2. A DBIO has no immediate effect outside the database
Section titled “R2. A DBIO has no immediate effect outside the database”A DBIO’s body performs database statements and nothing else. An effect that belongs to the transaction’s
outcome (a notification at commit, a refusal row after rollback) is registered through the library’s
outcome hooks or onFailureInNewTransaction, never performed in the body.
Why. A retry re-runs the owner’s whole block, every DBIO in it included. An immediate external effect inside a DBIO is repeated the moment its owner states a policy; an outcome-bound effect fires once, when the transaction ends.
Enforced by. Review, and once() (R9)
for the effect that cannot be moved.
R3. A DBIO never opens, joins or probes a transaction
Section titled “R3. A DBIO never opens, joins or probes a transaction”No inTransaction, inNewTransaction, inTransactionAsync, inEnclosingTransaction,
requireTransaction or TransactionManager.current() inside a DBIO { } body or inside a function that
returns a DBIO. A transaction-aware read (the transaction’s start, the writer bound, a lock) goes through
the library’s participant helpers.
Why. A DBIO whose body joins has two transactional meanings, one when it runs inside a caller’s transaction and another when it runs alone, and the policy it states inside only limits an owner it cannot see. A DBIO that reads Exposed’s current transaction directly depends on the thread-bound state rather than on the library’s ambient contract.
Enforced by. TransactionInsideDBIO (the standards plugin, ratchet). It also reports an inTx call
inside a DBIO: inTx builds a context wrapper whose only effect is to open or join a transaction. It
resolves each call to its callable id, against the same table of Exposed’s callables as
R12’s rule.
R4. A DBIO-returning function suspends only when building the action must
Section titled “R4. A DBIO-returning function suspends only when building the action must”A function returning a DBIO is not suspend unless constructing the action needs to suspend (a filter
built by a suspending compiler). When it is, its KDoc says what runs at construction and what runs at
invocation.
Why. A suspend fun that returns a DBIO runs part of its work at construction, outside the transaction
the caller will place the DBIO in, and the split is invisible at the call site.
Enforced by. Review.
R5. Every database access is a boundary or a participant, and a boundary states its three parameters
Section titled “R5. Every database access is a boundary or a participant, and a boundary states its three parameters”A participant returns a DBIO; when its body cannot be a DBIO it requires the ambient transaction with
inEnclosingTransaction. A boundary opens with inTransaction or inNewTransaction, states readOnly,
states retry (None when the block must not be repeated), states label when it retries, and returns
Result. The bare inTransaction(db) { ... } is not written.
Why. The same bare call is a write boundary in one place and a silent join in another, and today’s
silence and today’s “I did not think about it” read the same. readOnly is placement
(R8), retry is the owner’s contract
(R9), and the counters that make
retries diagnosable are per label.
Enforced by. Since common-module 23.0.0, inTransaction and inNewTransaction take readOnly with
no default. The overloads without it are deprecated, so each bare call is a compiler warning, each module’s
baseline under the ratchet. They forward readOnly = null, which means no opinion: join an ambient
transaction of either kind, and open a new one on the writer. The label: LabelledRetry (the standards
plugin, advisory), which resolves the retry = argument and exempts RetryPolicy.None and
RetryPolicy.Inherit. The two roles: review (COORDINATOR-Q534 (A)).
R6. A method entered from another service’s transaction in its own module offers a participant twin
Section titled “R6. A method entered from another service’s transaction in its own module offers a participant twin”A service method that other code enters from inside its own transaction on the same database offers a
DBIO-returning form (or an inEnclosingTransaction form when its body cannot be a DBIO), and the
Result-returning boundary is a one-line wrapper over it. Where a boundary is nonetheless entered from
inside a same-database transaction, it states retry = RetryPolicy.Inherit and its KDoc says why.
Why. A boundary entered from inside a transaction joins it, and a joined call that states nothing ends
its owner’s attempts from that one. Composing through inTransaction gives atomicity and silently decides
retry. The twin removes the join; Inherit makes the remaining joins honest. Across databases the question
does not arise: inTransaction joins only an ambient transaction on the same database, and a cross-module
call is two transactions whatever the entry says (the saga rules). The twin stays inside its module: a DBIO
never crosses a module boundary
(Data Authority, No DBIO Crosses a Module Boundary).
Enforced by. Review, with the library’s WARN line when a joined call caps an owner of more than one
attempt (since common-module 20.0.0; once per pair of call-site labels, counted by
JoinDowngradeCounters).
R7. The owner entry is the library’s; a deliberate second transaction says so
Section titled “R7. The owner entry is the library’s; a deliberate second transaction says so”inTransactionAsync is internal to the library. A service that needs its own transaction beside a possibly
ambient one opens it with inNewTransaction(db, readOnly = ..., ambient = PERMIT) and states the reason in
its KDoc (for example: a writer coordinate belongs to the transaction that built it).
Why. Opening a second connection beside one that holds locks is the shape behind a class of self-deadlock that is correct in a test and wrong in production. The library’s default refuses it; the application says when it means it, where it means it.
Enforced by. inTransactionAsync is internal since common-module 23.0.0. An application that moves
off it uses inTransaction(db, readOnly = false) where joining a caller’s writer transaction is acceptable,
and inNewTransaction(..., ambient = PERMIT) with the reason where the read must own its transaction.
Review checks the reason.
R8. Read, ReadOnWrite and Write: placement decides admissibility
Section titled “R8. Read, ReadOnWrite and Write: placement decides admissibility”A Read is admissible in any transaction and inherits its owner’s retries. A ReadOnWrite runs only in
a read-write transaction (readOnly = false), on the writer, and in the same transaction as the write it
decides. A Write runs only in a read-write transaction, and its retry depends on its effects
(R9). A boundary therefore states
readOnly = true only for reads that decide nothing, knowing that the transaction may run on a reader that
lags.
Why. A read-only transaction is routed to a reader instance in production. A read that decides a write and runs there decides on stale rows, with a bound that looks correct. A ReadOnWrite that hands its result to a caller writing in another transaction keeps the bound and loses the guarantee (COORDINATOR-Q191). PostgreSQL refuses a Write in a read-only transaction, late and with a generic error.
Enforced by. Since common-module 23.0.0 the writer helpers (writerDbio, withWriterBound,
writerResult, and the transaction-start readers) refuse a read-only transaction with
AppError.Implementation, so a library write inside a read-only owner is refused before it runs. The entries
refuse a read-write block joined into a read-only ambient. PostgreSQL refuses a Write. A typed mark for the
three classes is a follow-up, MPBOT-128.
R9. Retry is the owner’s, and a retried block repeats nothing it should not
Section titled “R9. Retry is the owner’s, and a retried block repeats nothing it should not”The owner states the policy. A block that may be re-run re-reads what it decides on inside the transaction,
holds no immediate external effect, and captures no state an earlier attempt mutated. A helper with an
immediate external effect is a boundary at RetryPolicy.None and says why, or splits into a DBIO for the
database part and an outcome-bound effect. A DBIO whose effect must not repeat is wrapped in once():
DBIO<T>.once(): DBIO<T>, failing with AppError.Implementation in any attempt but the first.
Why. What a retry re-runs is the whole block in a new transaction, after the competitor has committed, so the second attempt sees it. That is correct only for a block whose every step is repeatable. The writer helpers resolve their coordinate when the DBIO runs, not when the verb is built, which is what makes a retried writer-bound read correct.
Enforced by. The library’s retry contract (RetryPolicy, the owner loop, the join limit); once(),
since common-module 23.0.0; review for the split.
R10. A separate transaction is deliberate and named
Section titled “R10. A separate transaction is deliberate and named”Work that must escape or survive the caller’s transaction uses inNewTransaction (refusing an ambient one by
default; PERMIT with the reason), onFailureInNewTransaction for a refusal row that must survive the
rollback, or inLockedTransaction for an admission that takes its keys before the transaction begins. A
hand-made session advisory lock is never written.
Why. Each of these has one meaning at the call site. The hand-made forms (a lock statement that must be
the transaction’s first, a loop around an owner entry, an isolation change by exec) carry contracts the
type cannot state and the reader cannot see.
Enforced by. inNewTransaction’s refusal of an ambient transaction; inLockedTransaction’s refusal of
one; NoRawSessionLock (the standards plugin, tree); NoRawExposedTransaction
(R12).
R11. One public name per time or writer quantity and form
Section titled “R11. One public name per time or writer quantity and form”The database’s present, this transaction’s start and the writer-bound read each have one DBIO form (in the
caller’s transaction) and, where it is meaningful, one Result form (a boundary that opens its own short
transaction), as a single timestamp and as time coordinates. No module keeps a private copy, and no bound is
taken from the JVM clock. The names: the stems databaseNow, transactionStart and writer, the DBIO form
bare and the Result form taking db first:
databaseNow(): DBIO<Timestamp>anddatabaseNow(db): Result<Timestamp>;databaseNowCoordinates()anddatabaseNowCoordinates(db);transactionStart(): DBIO<Timestamp>andtransactionStartCoordinates(): DBIO<TimeCoordinates>;writerDbio(asOf, block),writerResult(asOf, body)andwithWriterBound.
recordedAsOf() and currentReadBound() stay as the primitives underneath.
Why. Several primitives for one quantity, each with a slightly different definition (a short transaction’s start, the clock at a statement, a millisecond ceiling, an exact start), mean a reader cannot tell from a call which bound it gets. The reviews of operations#348 and #352 found the choice wrong where it mattered.
Enforced by. The set ships in common-module 23.0.0 (QA613 condition 1, “one primitive per purpose”);
review carries the rule at a site that still uses another primitive.
R12. Raw Exposed stays in the library and the staging tree
Section titled “R12. Raw Exposed stays in the library and the staging tree”Exposed’s transaction(...), newSuspendedTransaction(...) and TransactionManager.current() appear only
under cards.arda.common.lib and cards.arda.operations.common.lib; migration packages are excluded.
Why. The library’s ownership, outcome and retry machinery sees only the transactions its entries open or join. A raw Exposed transaction is outside it, and a raw read of the current transaction bypasses the ambient contract.
Enforced by. NoRawExposedTransaction (the standards plugin, ratchet). A migration is recognized by its
package: each repository lists its migration packages in config/standards/repository.properties (MP,
QA659 (A)). The rule resolves each call to its callable id, so a local function named transaction is not
reported and Exposed’s imported under another name is. It reads the same table of Exposed’s callables as
TransactionInsideDBIO:
transaction,inTopLevelTransaction,suspendTransaction,inTopLevelSuspendTransaction,newSuspendedTransaction,suspendedTransactionAsync;JdbcTransaction.withSuspendTransaction;current,currentOrNull,currentOrNewandnewTransactiononTransactionManagerorJdbcTransactionManager, and thecurrentOrNullextension.
R13. A helper split out for size is internal
Section titled “R13. A helper split out for size is internal”A function moved into its own file to keep a service file small, but meant only for that service, is
declared internal.
Why. MP, operations#327: a helper that is public is a contract; one that is internal is an
implementation detail that the rule above can still see.
Enforced by. Review.
R14. A usable-transaction check is a named predicate
Section titled “R14. A usable-transaction check is a named predicate”Code that must refuse or branch on an open transaction asks the library’s predicate, an extension function
on the database, fun Database.hasUsableTransaction(): Result<Boolean>, never a probing call to an entry.
Why. The probe idiom (inEnclosingTransaction(db) { Result.success(Unit) }.isSuccess) reads as a
transaction and is a question; the predicate’s Result keeps a broken connection from being read as “no
transaction”.
Enforced by. The predicate, since common-module 23.0.0; review.
The decision procedure
Section titled “The decision procedure”A method that touches the database is a participant when it runs as part of a caller’s unit of work: it
returns a DBIO, or uses inEnclosingTransaction only when the body cannot be one. It is a boundary when it
is entered from outside any transaction: it opens with its mode, policy and label stated, and returns
Result. It opens a separate transaction only when it must escape or survive the caller’s, or take its
keys before its transaction begins (inNewTransaction, onFailureInNewTransaction, inLockedTransaction).
An immediate effect outside the database is split out of a participant as an outcome-bound effect
(R2, R9). A boundary that other services of its
own module enter from their own transactions offers its participant twin.
Enforcement map
Section titled “Enforcement map”| Rule | Mechanism | Where |
|---|---|---|
| R1 | UniverseReturnsDBIO, CollapsedDBIO | the standards plugin, tree |
| R1 | the library’s persistence openers run in their caller’s transaction, or are declared | common-module 23.0.0 |
| R3 | TransactionInsideDBIO | the standards plugin, ratchet |
| R5 | readOnly without a default; the deprecated overloads warn | common-module 23.0.0 |
| R5 | LabelledRetry | the standards plugin, advisory |
| R6 | the WARN on a join that caps its owner, JoinDowngradeCounters | common-module 20.0.0 |
| R7 | inTransactionAsync internal | common-module 23.0.0 |
| R8 | the writer helpers refuse a read-only transaction | common-module 23.0.0 |
| R8 | the entries refuse a read-write block joined into a read-only ambient; PostgreSQL refuses a Write | the library; the database |
| R8 | a typed mark for the three classes | follow-up, MPBOT-128 |
| R9 | RetryPolicy, the owner loop, the join limit | the library |
| R9 | DBIO<T>.once() | common-module 23.0.0 |
| R10 | inNewTransaction’s and inLockedTransaction’s refusals | the library |
| R10 | NoRawSessionLock | the standards plugin, tree |
| R11 | the helper set | common-module 23.0.0 |
| R12 | NoRawExposedTransaction | the standards plugin, ratchet |
| R14 | Database.hasUsableTransaction(): Result<Boolean> | common-module 23.0.0 |
| R2, R4, R6, R13 | review | standing |
The plugin scopes are the defaults operations runs at, with the standards plugin at 2.2.0.
Sources
Section titled “Sources”The rules are TN-027, the DBIO and transaction-composition standard, as MP ruled it on QA624 (2026-10-23) and accepted it (QA639, 2026-10-26). They rest on these rulings:
- COORDINATOR-Q532, the general rule;
- COORDINATOR-Q534 (A), boundary and participant;
- COORDINATOR-Q155 and Q191: reads that decide writes run on the writer, at its bound, in its transaction;
- QA613 condition 1, one primitive per purpose;
- QA639, the entry changes, the helper names, the predicate’s form and
once(); - QA659, what counts as a migration;
- QA684 (B), the same database is the same
Databaseobject; - MP’s comment on operations#327, which prefers the type over a runtime check and makes split-out helpers
internal.
Nested joins and the owner’s retry
Section titled “Nested joins and the owner’s retry”This section describes common-module v24.1.0. The implementation lives in
TransactionExt.kt
and the owner loop in
TransactionOwnerLoop.kt.
Joining requires a usable transaction on the same database
Section titled “Joining requires a usable transaction on the same database”inTransaction(db, readOnly, retry, label) joins a usable ambient transaction on the same
Database object, or opens a transaction if none is usable. A closed connection is unusable;
an error while checking the connection is returned as a failure, not treated as permission to
open another transaction. inEnclosingTransaction has the same joining behavior but returns
AppError.Implementation without running the block when no usable transaction exists.
A join uses the exact ambient transaction, without a savepoint, commit or rollback of its own.
It refuses a read-write block (readOnly = false) inside a read-only transaction; a read-only
block may join a writer. The library carries ownership through its coroutine context at every
joining depth. A raw Exposed join that loses a library-owned transaction’s ownership context is
unsupported: the next library join fails before running its block.
inNewTransaction is a separate boundary. Its ambient = AmbientTransaction.REFUSE default
refuses a usable same-database transaction; PERMIT deliberately opens beside it. This ambient
policy controls whether a separate transaction may open. It does not grant a joined block a
retry loop. See Work that must not join the caller’s transaction.
Only the owner can retry the unit of work
Section titled “Only the owner can retry the unit of work”A joined call invokes its block once and hands its result to its caller. It does not roll back the transaction itself. If the owner’s block returns a failure, the owner rolls back; when retry is allowed, it opens a fresh transaction and reruns the whole owner’s block, including every joined call that control flow reaches again. Work before the failing join repeats too.
An owner can recover from an application failure deliberately. Recovering from a PostgreSQL
error does not repair the transaction: the connection’s transaction remains aborted, and a
subsequent statement fails with SQLSTATE 25P02. Propagate the original conflict to the owner
so its retry classifier can see the SQLSTATE.
The owner inspects both thrown failures and failed Result values, following causes and chained
SQL exceptions. Its policy selects which conflicts can retry:
| SQLSTATE | Conflict | Default | CommitConflicts |
|---|---|---|---|
40001 | Serialization failure | Yes | Yes |
40P01 | Deadlock | Yes | No |
55P03 | Lock timeout | Yes | No |
RetryPolicy.None allows one attempt, including the first, and retries neither conflicts nor
failovers. Default allows three attempts, with 50 ms then 100 ms conflict backoff;
CommitConflicts uses those limits for 40001 alone. The library sets Exposed’s maxAttempts
to one per transaction, so Exposed does not add a second retry loop. Failover retries use the
pool’s limit and backoff, but share the owner’s total-attempt cap and the joined-call limits.
An escaping conflict becomes AppError.ConflictingState, retaining the database failure as its cause.
A joined policy can shorten the owner’s retry
Section titled “A joined policy can shorten the owner’s retry”At a join, a policy states how many owner attempts may include that call. A join entered on
owner attempt k with attempts = n sets an upper bound of k + n - 1 on the owner’s total
attempts. The owner keeps the smallest bound across joins and attempts. The conflict classes
and backoff remain the owner’s; a joined policy does not replace them.
For example, an owner allowing five attempts reaches a two-attempt join on its second attempt:
the owner may run at most three attempts in total. A defaulted join states None, so the owner
may not retry after the attempt that entered it. RetryPolicy.Inherit adds no bound. When used
at an entry that opens its own transaction, Inherit has Default’s behavior; it does not make
inEnclosingTransaction open one.
A join that removes attempts the owner’s policy allowed records a join downgrade and emits a WARN once per pair of call-site labels in the JVM. An owner that is already more restrictive than its join causes no downgrade warning. If a joined limit prevents an otherwise eligible conflict retry, another WARN identifies the owner, limiting join and conflict.
Invocation counts describe one entry-point call
Section titled “Invocation counts describe one entry-point call”BlockInvocationCounters.reruns(label) is a JVM-lifetime count of extra invocations after the
first, grouped by label; omitted labels use (unlabeled). Each entry-point call has its own
BlockInvocations. When that call’s block ran more than once, its report adds runs - 1 and
emits one WARN with its label and total run count, without a Throwable. The owner’s report also
runs on cancellation.
A joined call runs once per entry. If an owner retries and reaches that join again, this is a new joined call with a new counter. Its label therefore does not accumulate an extra run merely because the owner called it on several attempts. The owner’s counter records those reruns. A zero count on a join is not proof that the joined code executed only once across the whole owner operation.
Actions need a fresh instance per attempt
Section titled “Actions need a fresh instance per attempt”Actions are single-use: executing a failed instance again returns a conflicting-state failure.
inTx, the Action context wrapper, chooses inEnclosingTransaction when retry.attempts > 1, requiring an owner
rather than opening a transaction that would retry the same spent Action. With a one-attempt
policy it chooses inTransaction, joining when possible and opening when needed.
executeWithRetry(db, retry, label, factory) owns a writer transaction and calls the factory
again on every attempt. Build and configure a new Action inside that factory, including its
effective time and author, and give its inTx wrapper a policy that permits the owner’s retry.
A value captured outside the factory remains the same across attempts.
Cancellation is not another retry
Section titled “Cancellation is not another retry”The caller’s own cancellation propagates out of the transaction call. The owner checks that caller before any subsequent attempt, including a zero-backoff retry. An already-running owner block uses a coroutine without the caller’s Job, so it can still settle its transaction and notification outcome after the caller stops awaiting it. A cancellation thrown inside the block while the caller is active is handled as a failure; it does not by itself authorize a retry. The join’s bridge retains a child-coroutine boundary, so an uncaught child failure can cancel its owner. Do not swallow cancellation to continue transaction work.
Work that must not join the caller’s transaction
Section titled “Work that must not join the caller’s transaction”Most deferred work should join the caller’s transaction — that is what DBIO is for, and R10 exists to keep it that way. A small amount must not, and the distinction is worth stating because getting it wrong is silent in both directions.
The convention
Section titled “The convention”A record of a failure must be written outside the transaction that failed.
When a guard refuses an operation, the unit of work that asked is rolled back — that is what refusing means. An audit record written inside it goes down with it, and the result is the worst kind of gap: no error, no log, just a missing record that nobody notices until someone asks what happened and the answer is nothing.
So the record needs a transaction of its own, opened after the first has closed. In common-module:
inTransaction(db) { universe.doThing()() } // the caller's own unit of work .onFailureInNewTransaction(db) { err -> … } // the record, in a transaction of its ownThe Result receiver makes the correct call the natural one to write: there is no Result to act on until inTransaction has returned.
Nesting a transaction: refused by default, permitted deliberately
Section titled “Nesting a transaction: refused by default, permitted deliberately”Opening a second pooled connection beside a transaction still holding its locks deadlocks if the two touch the same rows. This is a bug that reproduces only under load — in a test the pool has spare connections and the two rarely collide, so the code looks correct until production, where a request waits on itself and the stack trace says nothing about why.
inNewTransaction therefore refuses a usable ambient transaction on the same database by default:
inNewTransaction(db) { … } // refuses if a transaction is openinNewTransaction(db, ambient = AmbientTransaction.PERMIT) { … } // proceeds anyway, deliberatelyThere are rare but real reasons to nest on purpose, so this is a default rather than a prohibition. If you find yourself passing PERMIT, that is fine — but say in a comment what made it necessary, because the next reader’s first assumption will be that it is a mistake.
“Usable” is not “present.” Detached work — a completion launched on its own coroutine — inherits the launching scope without inheriting its connection. That is not a transaction to deadlock against, and those callers are unaffected.
What to reach for
Section titled “What to reach for”| You want | Use | Joins an ambient transaction? |
|---|---|---|
| Work that is part of the caller’s unit of work | DBIO<T>, returned un-invoked | Yes — the caller runs it |
| Work that is only correct inside the caller’s and cannot be a DBIO | inEnclosingTransaction(db, readOnly, retry = Inherit) | Requires one; fails without |
| A boundary: work that must run whether or not one exists | inTransaction(db, readOnly = ..., retry = ..., label = ...) | Joins when present, opens when not |
| Work whose lifetime is not the caller’s | inNewTransaction(db, readOnly = ..., retry = ...) | No — and refuses if one is open |
| A deliberate second transaction beside an open one | inNewTransaction(..., ambient = PERMIT), with the reason | No, by declaration |
| An admission that must see the previous holder’s commit | inLockedTransaction(db, keys, retry) | No — refuses one |
| A record of a failure that must survive its rollback | Result<T>.onFailureInNewTransaction(db) | No — runs after the first has closed |
| A read that decides a write | the writer helpers, in a readOnly = false owner, in the same transaction as the write | Yes |
| The database’s present or this transaction’s start | the helper set (R11) | The DBIO form yes; the Result form opens its own |
| A DBIO whose effect must not repeat under retry | once() (R9) | Yes |
| To know whether a usable transaction is open | db.hasUsableTransaction() (R14) | n/a |
Static analysis flags the lexical form of a nested inNewTransaction as a warning. It cannot see the cross-function case, which is why the runtime check exists as well; see PDEV-1594.
Related
Section titled “Related”- Universe Design — building a Universe once you have chosen one.
- Service-Layer Architecture — who owns the transaction boundary.
- Bitemporal Persistence — the substrate.
- Kotlin Coding Standards — index.
Copyright: © Arda Systems 2025-2026, All rights reserved