Skip to content

improvement: allow write transactions per resource via write_transactions? - #223

Open
C-Sinclair wants to merge 5 commits into
ash-project:mainfrom
C-Sinclair:feat/write-transactions
Open

improvement: allow write transactions per resource via write_transactions?#223
C-Sinclair wants to merge 5 commits into
ash-project:mainfrom
C-Sinclair:feat/write-transactions

Conversation

@C-Sinclair

@C-Sinclair C-Sinclair commented Aug 21, 2026

Copy link
Copy Markdown

Closes the implementation half of #91. Supersedes #95, which started this and was closed as stale.

What

can?(:transact) has returned false since this data layer was split out of ash_postgres. The transactions guide explains why: SQLite allows one write lock at a time, and a write attempted while another transaction holds that lock fails immediately rather than queueing.

That is a reason to make transactions opt in, not a reason to leave them unimplemented. SQLite is fully ACID, and without a transaction a create whose after_action hook fails leaves its row behind with nothing to undo it.

This adds write_transactions? to the sqlite section, defaulting to false so nothing changes for existing resources:

sqlite do
  table "accounts"
  repo MyApp.Repo
  write_transactions? true
end

Why this shape

#91 settled on a per-resource opt-in with this name, after discussion between @jimsynz and @zachdaniel. This follows that.

It composes with functional repos rather than replacing them. The read/write pool split discussed in #91 is repo fn _, :mutate -> WriteRepo; _, :read -> ReadRepo end plus this flag on the write side, and the guide documents the pair.

The callbacks

  • transaction/4 opens write transactions as BEGIN IMMEDIATE. This part is really important. A deferred transaction takes no lock until its first write, so a read-then-write has to upgrade partway through. And SQLite cannot make an upgrade wait for busy_timeout, because the snapshot it already read from may be stale by the time the lock frees. It fails immediately regardless of the timeout. BEGIN IMMEDIATE has nothing to upgrade. Read-only transactions stay deferred, since they never take the write lock.

    This holds regardless of the repo's default_transaction_mode, so it works without asking users to configure the adapter correctly first.

  • in_transaction?/1 answers rather than raising when the repo was reached through put_dynamic_repo/1 and started under no name of its own. Ecto.Repo.in_transaction?/0 resolves the current dynamic repo through the registry and raises when it is absent; Ash asks this before opening a transaction, so it needs an answer. No running repo means no open transaction.

  • prefer_transaction_for_atomic_updates?/1 is false. An atomic update is a single statement and so already atomic; wrapping it would hold the one write lock across the surrounding work and buy nothing.

There is deliberately no verifier

#91's proposal 3 and #95 both included a verifier rejecting transaction? true on a resource that has not opted in. It cannot work, because there is nothing there to check.

transaction? is not something an author opts into — it is the struct default for every create, update and destroy action (Ash.Resource.Actions.Create, .Update, .Destroy all set transaction?: true). So a mutation action carries true unless somebody explicitly turned it off, and an action written by an author who never thought about transactions is indistinguishable from one that asked for them. (Derivation compounds this — an action using manage_relationship gets the flag too — but the default alone is enough.) A verifier therefore fires on essentially every resource in the ecosystem: compiled against this repository's own suite it flagged four existing test resources — Comment, Device, Manager, Post — none of which mentions transaction? at all.

ash-project/ash#2895 makes the verifier unnecessary as well as unworkable. Ash.Resource.Transformers.SetActionTransactions now clears the flag on a resource whose data layer cannot transact, so the DSL says what will actually happen instead of naming a transaction that never opens. Since Spark runs every transformer before any verifier, that also closes off the last escape hatch: explicit and defaulted transaction? true are both already false by the time a verifier could look.

This branch requires ash >= 3.32.1 for it, and test/transaction_test.exs pins the behaviour: Ash.Resource.Info.action(Account, :create).transaction? is false, and true on TransactionalAccount. Discoverability is handled in the guide.

Note

mix test.check_migrations reports 3 pending files on main as well as on this branch — pre-existing, unrelated to this change.

…tions?`

`can?(:transact)` has returned false since this data layer was split out of
ash_postgres, and the transactions guide explains why: SQLite allows one write
lock at a time, and a write attempted while another transaction holds that lock
fails immediately rather than queueing. That is a reason to make transactions
opt in, not a reason to leave them unimplemented. SQLite is fully ACID, and
without a transaction a create whose `after_action` hook fails leaves its row
behind with nothing to undo it.

Adds `write_transactions?` to the `sqlite` section, defaulting to false so
nothing changes for existing resources, and implements the callbacks it enables:

  * `transaction/4` opens write transactions as `BEGIN IMMEDIATE`. This is what
    makes `busy_timeout` effective. A deferred transaction takes no lock until
    its first write, so a read-then-write has to upgrade partway through -- and
    SQLite cannot make an upgrade wait, because the snapshot already read from
    may be stale by the time the lock frees. It fails immediately regardless of
    `busy_timeout`. `BEGIN IMMEDIATE` has nothing to upgrade. Read-only
    transactions stay deferred, since they never take the write lock.

  * `in_transaction?/1` answers rather than raising when the repo has no running
    process. `Ecto.Repo.in_transaction?/0` resolves the current dynamic repo
    through the registry and raises when it is absent, which happens whenever
    the repo was reached through `put_dynamic_repo/1` and started under no name
    of its own. Ash asks this before opening a transaction, so it has to be an
    answer.

  * `prefer_transaction_for_atomic_updates?/1` is false. An atomic update is a
    single statement and so already atomic; wrapping it would hold the one write
    lock across the surrounding work and buy nothing.

Refs ash-project#91, and supersedes the work in ash-project#95.

Two notes on what is deliberately not here.

The option composes with functional repos rather than replacing them: the
read/write pool split discussed in ash-project#91 is `repo fn _, :mutate -> WriteRepo; _,
:read -> ReadRepo end` plus this flag on the write side, and the guide documents
the pair.

There is no verifier rejecting `transaction? true` on a resource that has not
opted in, which ash-project#91 proposed and ash-project#95 implemented. It cannot work as specified:
Ash *derives* `transaction? true` from the action rather than only taking it
from the author -- an action using `manage_relationship` gets it automatically --
so the check fires on resources nobody annotated. Compiling it against this
suite flags four existing test resources (Comment, Device, Manager, Post), none
of which mentions `transaction?` at all, and the error asks the author to change
an action they did not write. On a released version it would break existing
applications at compile time on upgrade. Discoverability is handled in the guide
instead.
@zachdaniel

Copy link
Copy Markdown
Contributor

@C-Sinclair I think we can fix this in Ash core by only setting transaction? true when the data layer supports it instead of always. Since up til now its just been ignored when test to true.

@zachdaniel

Copy link
Copy Markdown
Contributor

Like the bit about not supporting the verifier.

@zachdaniel

Copy link
Copy Markdown
Contributor

Latest version of ash is released, so you can update the dependency on Ash and leverage that transformer change 🙇

…they get

Ash now clears the derived `transaction? true` on a resource whose data
layer cannot transact, so a resource without `write_transactions?` reflects
`transaction? false` instead of naming a transaction that never opens.
@zachdaniel

Copy link
Copy Markdown
Contributor

Having more thoughts on this: given that the place that you configure this is actually on the repo by configuring a busy wait to a high enough value (equal to or higher than pool config allows running a query?) then I think we should make this configuration also live on the repo). That way you don't need to configure this all across your repos, and in practice it's a repo/connection-pool level configuration. Then can(:transact) can look at the repo module to determine if the resource can do so.

Thoughts?

`write_transactions?` shipped in the previous commit as an option in the `sqlite`
section. Configuring it per resource is the wrong cut: everything that makes a
transaction safe on SQLite is repo configuration -- `pool_size`, `busy_timeout`,
whether the connection is read only -- and there is no per-resource knob that
changes any of it. A resource-level flag could therefore only ever agree or
disagree with the connection it runs on, and had to be repeated across every
resource sharing one repo.

It becomes a callback on `AshSqlite.Repo`, defaulting to false, alongside
`installed_extensions/0` and `min_pg_version/0`:

    defmodule MyApp.Repo do
      use AshSqlite.Repo, otp_app: :my_app

      def write_transactions?, do: true
    end

`can?(:transact)` resolves the `:mutate` repo and asks it, so the read/write pool
split falls out for free: `repo &MyApp.Routing.repo/2` sends the question to the
write repo, and the read-only repo never has to answer.

To keep one action out of a transaction, set `transaction? false` on the action.
That was always the finer-grained control; the resource-level flag duplicated it
badly.

One case needs care. Spark compiles an inline `fn` given to `repo` into a function
on the resource module itself, and `Ash.Resource.Transformers.SetActionTransactions`
asks about `:transact` while that module is still being compiled, so there is
nothing to call yet. Answering `false` there is not an option: it would clear
`transaction?` on every action, and the runtime check is
`action.transaction? and can?(:transact)`, so transactions would be off for good
rather than merely mislabelled. Such a repo answers `true` and lets the runtime
check against the real repo decide -- no transaction is opened against a repo that
has not opted in, but `Ash.Resource.Info.action(Post, :create).transaction?` reads
`true` on those resources whether or not one will be opened. Capturing a named
function resolves at compile time and keeps that introspection honest, so the guide
recommends it. `AshSqlite.Test.InlineFnRepoAccount` and
`AshSqlite.Test.NamedFnRepoAccount` cover both halves, and the suite would not
compile if the inline case raised.

The transactions guide also gets its knobs section rewritten, because the previous
version misplaced the emphasis:

  * `pool_size` decides where write contention is resolved, rather than whether it
    is survivable. The adapter's default of 5 lets connections race for the lock and
    retry under `busy_timeout`; `pool_size: 1` makes them queue in `DBConnection`
    instead, trading read concurrency for predictability.
  * `busy_timeout` does nothing within a single node at `pool_size: 1` -- one
    connection cannot contend with itself, and contention surfaces as a checkout
    timeout rather than `SQLITE_BUSY`. It covers a second node, another OS process,
    or a `litestream` against the same file.
  * It defaults to 2000 rather than 0, so the old advice to "set this to a non-zero
    value" described a problem that does not exist.
  * Raising it means raising the transaction timeout with it. The driver's busy
    handler blocks the connection while it waits, so the ordering has an upper
    bound as well as a lower one: a `busy_timeout` at or above the caller's
    `:timeout` means the caller dies waiting, having gained nothing.
  * `default_transaction_mode: :immediate` is no longer needed for Ash's
    transactions, since `transaction/4` passes `mode: :immediate` itself. It still
    applies to `Repo.transaction/2` calls made directly.
@C-Sinclair
C-Sinclair force-pushed the feat/write-transactions branch from e5a3582 to 089d618 Compare August 25, 2026 15:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants