Skip to content

Persistence and Transactions

Arda uses Exposed for database access.

  1. Column names must be specified in lowercase snake_case.

  2. Use column types defined under EntityTable. When those types are insufficient, use native Exposed column types.

  3. When using Filter.Eq and similar, use <TBL>.<COLUMN>.name for the locator parameter instead of hardcoded strings, to eliminate the risk of mismatched column names.

  4. JSON columns with custom serializers: The reified json<T>(name, format) overload calls serializer<T>() at runtime, which ignores @Serializable(with=...) and @Contextual annotations on type arguments. When T contains a non-@Serializable class (e.g., java.net.URI), use the three-argument overload with an explicit KSerializer:

    // WRONG — fails at runtime: serializer<Map<String, URI>>() cannot find URI serializer
    val sites = tbl.json<Map<String, URI>>(name, JsonConfig.standardJson)
    // CORRECT — explicit serializer
    val sites = tbl.json<Map<String, URI>>(name, JsonConfig.standardJson, MapSerializer(String.serializer(), URISerializer))

    EntityTable also provides standardJson<T>(name), which resolves contextual serializers from JsonConfig.standardJson.serializersModule. Prefer it over json<T>(name, JsonConfig.standardJson) when contextual serializers are sufficient.

  5. Unchecked casts to ChildTable: When casting EntityTable to ChildTable (required due to invariant generics on ExposedLocatorTranslator), always guard with check() 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)
    }
  6. QueryCompiler reuse: Never construct QueryCompiler(table) inline inside service methods. Define a module-level lazy val in the persistence package, or expose an internal accessor 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 universe
    internal 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 — the AbstractScopedUniverse / ScopedTable / ScopedRecord / Persistence stack 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 via ScopedUniversalCondition, and create / read / findOne(Filter, asOf) / list(Query, asOf) / update / delete operations 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 ScopedTable column property named source (or any name that collides with an Exposed ColumnSet member) 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) or inNewTransaction(db), and returns Result<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 with inEnclosingTransaction(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.

  • 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:

    • inTransaction joins a usable ambient transaction on the same database, else opens one;
    • inNewTransaction opens one, and refuses a usable ambient one unless told PERMIT;
    • inEnclosingTransaction requires one, and fails without;
    • inLockedTransaction opens one after taking its keys, and refuses an ambient one.

    inTransactionAsync is 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 Database object, one per connection pool: since common-module 23.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. None is the default. A joined call is never re-run by a policy of its own; what it states limits its owner, except Inherit.

  • 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.

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> and databaseNow(db): Result<Timestamp>;
  • databaseNowCoordinates() and databaseNowCoordinates(db);
  • transactionStart(): DBIO<Timestamp> and transactionStartCoordinates(): DBIO<TimeCoordinates>;
  • writerDbio(asOf, block), writerResult(asOf, body) and withWriterBound.

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, currentOrNew and newTransaction on TransactionManager or JdbcTransactionManager, and the currentOrNull extension.

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.

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.

PlantUML diagram

RuleMechanismWhere
R1UniverseReturnsDBIO, CollapsedDBIOthe standards plugin, tree
R1the library’s persistence openers run in their caller’s transaction, or are declaredcommon-module 23.0.0
R3TransactionInsideDBIOthe standards plugin, ratchet
R5readOnly without a default; the deprecated overloads warncommon-module 23.0.0
R5LabelledRetrythe standards plugin, advisory
R6the WARN on a join that caps its owner, JoinDowngradeCounterscommon-module 20.0.0
R7inTransactionAsync internalcommon-module 23.0.0
R8the writer helpers refuse a read-only transactioncommon-module 23.0.0
R8the entries refuse a read-write block joined into a read-only ambient; PostgreSQL refuses a Writethe library; the database
R8a typed mark for the three classesfollow-up, MPBOT-128
R9RetryPolicy, the owner loop, the join limitthe library
R9DBIO<T>.once()common-module 23.0.0
R10inNewTransaction’s and inLockedTransaction’s refusalsthe library
R10NoRawSessionLockthe standards plugin, tree
R11the helper setcommon-module 23.0.0
R12NoRawExposedTransactionthe standards plugin, ratchet
R14Database.hasUsableTransaction(): Result<Boolean>common-module 23.0.0
R2, R4, R6, R13reviewstanding

The plugin scopes are the defaults operations runs at, with the standards plugin at 2.2.0.

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 Database object;
  • MP’s comment on operations#327, which prefers the type over a runtime check and makes split-out helpers internal.

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.

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:

SQLSTATEConflictDefaultCommitConflicts
40001Serialization failureYesYes
40P01DeadlockYesNo
55P03Lock timeoutYesNo

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 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.

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.

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 own

The 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 open
inNewTransaction(db, ambient = AmbientTransaction.PERMIT) { … } // proceeds anyway, deliberately

There 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.

You wantUseJoins an ambient transaction?
Work that is part of the caller’s unit of workDBIO<T>, returned un-invokedYes — the caller runs it
Work that is only correct inside the caller’s and cannot be a DBIOinEnclosingTransaction(db, readOnly, retry = Inherit)Requires one; fails without
A boundary: work that must run whether or not one existsinTransaction(db, readOnly = ..., retry = ..., label = ...)Joins when present, opens when not
Work whose lifetime is not the caller’sinNewTransaction(db, readOnly = ..., retry = ...)No — and refuses if one is open
A deliberate second transaction beside an open oneinNewTransaction(..., ambient = PERMIT), with the reasonNo, by declaration
An admission that must see the previous holder’s commitinLockedTransaction(db, keys, retry)No — refuses one
A record of a failure that must survive its rollbackResult<T>.onFailureInNewTransaction(db)No — runs after the first has closed
A read that decides a writethe writer helpers, in a readOnly = false owner, in the same transaction as the writeYes
The database’s present or this transaction’s startthe helper set (R11)The DBIO form yes; the Result form opens its own
A DBIO whose effect must not repeat under retryonce() (R9)Yes
To know whether a usable transaction is opendb.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.