improvement: allow write transactions per resource via write_transactions? - #223
improvement: allow write transactions per resource via write_transactions?#223C-Sinclair wants to merge 5 commits into
write_transactions?#223Conversation
…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.
|
@C-Sinclair I think we can fix this in Ash core by only setting |
|
Like the bit about not supporting the verifier. |
|
Latest version of |
…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.
|
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 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.
e5a3582 to
089d618
Compare
Closes the implementation half of #91. Supersedes #95, which started this and was closed as stale.
What
can?(:transact)has returnedfalsesince this data layer was split out ofash_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_actionhook fails leaves its row behind with nothing to undo it.This adds
write_transactions?to thesqlitesection, defaulting tofalseso nothing changes for existing resources: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 endplus this flag on the write side, and the guide documents the pair.The callbacks
transaction/4opens write transactions asBEGIN 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 forbusy_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 IMMEDIATEhas 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?/1answers rather than raising when the repo was reached throughput_dynamic_repo/1and started under no name of its own.Ecto.Repo.in_transaction?/0resolves 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?/1isfalse. 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? trueon 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,.Destroyall settransaction?: true). So a mutation action carriestrueunless 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 usingmanage_relationshipgets 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 mentionstransaction?at all.ash-project/ash#2895 makes the verifier unnecessary as well as unworkable.
Ash.Resource.Transformers.SetActionTransactionsnow 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 defaultedtransaction? trueare both alreadyfalseby the time a verifier could look.This branch requires
ash >= 3.32.1for it, andtest/transaction_test.exspins the behaviour:Ash.Resource.Info.action(Account, :create).transaction?isfalse, andtrueonTransactionalAccount. Discoverability is handled in the guide.Note
mix test.check_migrationsreports 3 pending files onmainas well as on this branch — pre-existing, unrelated to this change.