Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,13 @@ spark_locals_without_parens = [
statement: 2,
strict?: 1,
table: 1,
tenant_binder: 1,
unique: 1,
unique_index_names: 1,
up: 1,
using: 1,
where: 1
where: 1,
write_transactions?: 1
]

[
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,8 @@ test/test.db-wal
test/dev_test.db
test/dev_test.db-shm
test/dev_test.db-wal

test/tenant_shared.db
test/tenant_shared.db-shm
test/tenant_shared.db-wal
notes/
11 changes: 11 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ end
if Mix.env() == :test do
config :ash, :validate_domain_resource_inclusion?, false
config :ash, :validate_domain_config_inclusion?, false
config :ash, :warn_on_transaction_hooks?, false

config :ash_sqlite, AshSqlite.TestRepo,
database: Path.join(__DIR__, "../test/test.db"),
Expand All @@ -39,6 +40,16 @@ if Mix.env() == :test do
pool: Ecto.Adapters.SQL.Sandbox,
migration_primary_key: [name: :id, type: :binary_id]

# A real database, and started under its own name in `test_helper.exs`. This is the
# database a `global? true` resource on this module uses: one copy of its rows,
# reached without a tenant binding.
config :ash_sqlite, AshSqlite.TenantRepo,
database: Path.join(__DIR__, "../test/tenant_shared.db"),
pool: DBConnection.ConnectionPool,
pool_size: 1,
migration_lock: false,
migration_primary_key: [name: :id, type: :binary_id]

config :ash_sqlite,
ecto_repos: [AshSqlite.TestRepo, AshSqlite.DevTestRepo],
ash_domains: [
Expand Down
2 changes: 2 additions & 0 deletions documentation/dsls/DSL-AshSqlite.DataLayer.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ end
| Name | Type | Default | Docs |
|------|------|---------|------|
| [`repo`](#sqlite-repo){: #sqlite-repo .spark-required} | `module \| (any, any -> any)` | | The repo that will be used to fetch your data. See the `AshSqlite.Repo` documentation for more. Can also be a function that takes a resource and a type `:read \| :mutate` and returns the repo. |
| [`write_transactions?`](#sqlite-write_transactions?){: #sqlite-write_transactions? } | `boolean` | `false` | Whether Ash may wrap this resource's write actions in a transaction. Off by default. SQLite allows a single write lock at a time and a contended write fails immediately rather than queueing, so transactions are only safe once the repo is configured for them. See the [transactions guide](/documentation/topics/about-ash-sqlite/transactions.md). With this on, write transactions are opened as `BEGIN IMMEDIATE` so that `busy_timeout` can do its job. |
| [`tenant_binder`](#sqlite-tenant_binder){: #sqlite-tenant_binder } | `module` | | A module that selects the connection a tenanted statement runs on. Required for database-per-tenant layouts, where the tenant is a database file rather than a query prefix. See `AshSqlite.TenantBinder`. |
| [`migrate?`](#sqlite-migrate?){: #sqlite-migrate? } | `boolean` | `true` | Whether or not to include this resource in the generated migrations with `mix ash.generate_migrations` |
| [`migration_types`](#sqlite-migration_types){: #sqlite-migration_types } | `keyword` | `[]` | A keyword list of attribute names to the ecto migration type that should be used for that attribute. Only necessary if you need to override the defaults. |
| [`migration_defaults`](#sqlite-migration_defaults){: #sqlite-migration_defaults } | `keyword` | `[]` | A keyword list of attribute names to the ecto migration default that should be used for that attribute. The string you use will be placed verbatim in the migration. Use fragments like `fragment(\\"now()\\")`, or for `nil`, use `\\"nil\\"`. |
Expand Down
46 changes: 46 additions & 0 deletions documentation/topics/about-ash-sqlite/transactions.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,52 @@ Because of this, **AshSqlite disables transaction support by default**
(`can?(:transact)` returns `false`). Without extra configuration, Ash will not
wrap actions in transactions when using the SQLite data layer.

## Enabling Transactions

Transactions are opt in per resource, via `write_transactions?` in the `sqlite`
block:

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

Turning this on is worth it wherever an action does more than one thing. Without
a transaction, a create whose `after_action` hook fails leaves its record behind:
the insert already committed on its own, and there is nothing to undo it. With
one, the failure rolls the insert back.

Ash derives `transaction? true` on create, update and destroy actions, and then
clears it again on a resource whose data layer cannot transact. So on a resource
that has not opted in, `Ash.Resource.Info.action(MyApp.Post, :create).transaction?`
reads `false` and says what will really happen, rather than naming a transaction
the data layer was never going to open. Turning `write_transactions?` on is what
lets that default stand.

Read it as a statement about the *repo*, not just the resource — a resource only
transacts safely once the repo underneath it is configured as below. Leaving it
off is not a bug, and it stays the default so that existing applications are
unaffected.

> ### Transactions are opened as IMMEDIATE {: .info}
>
> When a write transaction is opened, AshSqlite issues `BEGIN IMMEDIATE` rather
> than letting it default to deferred, whatever `default_transaction_mode` is set
> to. This is what makes `busy_timeout` effective for transactions that read
> before they write.
>
> A deferred transaction takes no lock until its first write, so a
> read-then-write has to *upgrade* to the write lock partway through. SQLite
> cannot make an upgrade wait: the snapshot the transaction already read from may
> be stale by the time the lock frees, so it fails immediately no matter how long
> `busy_timeout` is. `BEGIN IMMEDIATE` takes the lock up front, and has nothing to
> upgrade.
>
> Read-only transactions stay deferred, since they never take the write lock.

## Enabling Reliable Concurrent Writes

`ecto_sqlite3` exposes two knobs that together make concurrent writes behave more
Expand Down
26 changes: 26 additions & 0 deletions lib/changes/carry_tenant.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# SPDX-FileCopyrightText: 2023 ash_sqlite contributors <https://github.com/ash-project/ash_sqlite/graphs/contributors>
#
# SPDX-License-Identifier: MIT

defmodule AshSqlite.Changes.CarryTenant do
@moduledoc """
Puts the tenant in `context[:data_layer]`, where `AshSqlite.DataLayer.transaction/4` can reach it.
"""
use Ash.Resource.Change

@impl true
def change(changeset, _opts, _context) do
changeset
|> put_tenant()
|> Ash.Changeset.before_transaction(&put_tenant/1)
end

defp put_tenant(%{tenant: nil} = changeset), do: changeset

defp put_tenant(changeset) do
Ash.Changeset.set_context(changeset, %{data_layer: %{tenant: changeset.tenant}})
end

@impl true
def atomic(changeset, _opts, _context), do: {:ok, changeset}
end
Loading