Skip to content

Commit cf33460

Browse files
committed
chore: better functional repo support, add guide
1 parent 9052dc8 commit cf33460

5 files changed

Lines changed: 127 additions & 10 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Welcome! `AshSqlite` is the SQLite data layer for [Ash Framework](https://hexdoc
2424
## Topics
2525

2626
- [What is AshSqlite?](documentation/topics/about-ash-sqlite/what-is-ash-sqlite.md)
27+
- [Transactions](documentation/topics/about-ash-sqlite/transactions.md)
2728

2829
### Resources
2930

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<!--
2+
SPDX-FileCopyrightText: 2020 Zach Daniel
3+
4+
SPDX-License-Identifier: MIT
5+
-->
6+
7+
# Transactions
8+
9+
## SQLite's Write Lock Limitation
10+
11+
SQLite allows only one write lock at a time. Any attempt to write while another
12+
transaction already holds the write lock will immediately fail—there is no waiting
13+
or queuing built in. This is fundamentally different from PostgreSQL, where
14+
conflicting transactions queue up and proceed in order.
15+
16+
Because of this, **AshSqlite disables transaction support by default**
17+
(`can?(:transact)` returns `false`). Without extra configuration, Ash will not
18+
wrap actions in transactions when using the SQLite data layer.
19+
20+
## Enabling Reliable Concurrent Writes
21+
22+
`ecto_sqlite3` exposes two knobs that together make concurrent writes behave more
23+
like you would expect:
24+
25+
- **`default_transaction_mode: :immediate`** — SQLite acquires the exclusive
26+
write lock at the *start* of each transaction instead of at the first write
27+
statement. This prevents the scenario where two transactions both start in
28+
deferred mode, both read successfully, and then race to upgrade to a write lock,
29+
causing one to fail.
30+
31+
- **`busy_timeout`** — SQLite will retry acquiring the write lock for up to this
32+
many milliseconds before returning an error. Set this to a non-zero value so
33+
that a brief contention window does not immediately surface as an error to your
34+
users.
35+
36+
Example repo configuration:
37+
38+
```elixir
39+
# config/config.exs
40+
config :my_app, MyApp.Repo,
41+
database: "path/to/my_app.db",
42+
pool_size: 1,
43+
default_transaction_mode: :immediate,
44+
busy_timeout: 5000
45+
```
46+
47+
> ### Keep pool_size: 1 for writes {: .warning}
48+
>
49+
> SQLite does not support parallel writes, so a write pool larger than 1 will only
50+
> cause contention. Set `pool_size: 1` on any repo that performs writes.
51+
52+
## Separate Read and Write Repos
53+
54+
For applications that need read concurrency, you can configure a dedicated
55+
read-only repo alongside a write repo. The write repo uses `pool_size: 1` and
56+
immediate transactions; the read repo opens multiple read-only connections.
57+
58+
```elixir
59+
# config/config.exs
60+
config :my_app, MyApp.Repo,
61+
database: "path/to/my_app.db",
62+
pool_size: 1,
63+
default_transaction_mode: :immediate,
64+
busy_timeout: 5000
65+
66+
config :my_app, MyApp.Repo.ReadOnly,
67+
database: "path/to/my_app.db",
68+
pool_size: 10,
69+
read_only: true
70+
```
71+
72+
```elixir
73+
# lib/my_app/repo.ex
74+
defmodule MyApp.Repo do
75+
use AshSqlite.Repo, otp_app: :my_app
76+
end
77+
78+
defmodule MyApp.Repo.ReadOnly do
79+
use AshSqlite.Repo, otp_app: :my_app
80+
end
81+
```
82+
83+
Start both repos in your application supervision tree:
84+
85+
```elixir
86+
# lib/my_app/application.ex
87+
children = [
88+
MyApp.Repo,
89+
MyApp.Repo.ReadOnly,
90+
...
91+
]
92+
```
93+
94+
Then route reads and writes to the appropriate repo using a function in the
95+
`repo` DSL option:
96+
97+
```elixir
98+
sqlite do
99+
repo fn _resource, type ->
100+
case type do
101+
:mutate -> MyApp.Repo
102+
:read -> MyApp.Repo.ReadOnly
103+
end
104+
end
105+
table "posts"
106+
end
107+
```
108+
109+
The function receives the resource module and either `:read` or `:mutate` as
110+
arguments and must return a repo module.

lib/data_layer.ex

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -198,10 +198,10 @@ defmodule AshSqlite.DataLayer do
198198
],
199199
schema: [
200200
repo: [
201-
type: :atom,
201+
type: {:or, [{:behaviour, Ecto.Repo}, {:fun, 2}]},
202202
required: true,
203203
doc:
204-
"The repo that will be used to fetch your data. See the `AshSqlite.Repo` documentation for more"
204+
"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."
205205
],
206206
migrate?: [
207207
type: :boolean,
@@ -2016,7 +2016,7 @@ defmodule AshSqlite.DataLayer do
20162016

20172017
@impl true
20182018
def rollback(resource, term) do
2019-
AshSqlite.DataLayer.Info.repo(resource).rollback(term)
2019+
AshSqlite.DataLayer.Info.repo(resource, :mutate).rollback(term)
20202020
end
20212021

20222022
defp table(resource, changeset) do
@@ -2039,15 +2039,15 @@ defmodule AshSqlite.DataLayer do
20392039
end
20402040

20412041
defp dynamic_repo(resource, %{__ash_bindings__: %{context: %{data_layer: %{repo: repo}}}}) do
2042-
repo || AshSqlite.DataLayer.Info.repo(resource)
2042+
repo || AshSqlite.DataLayer.Info.repo(resource, :read)
20432043
end
20442044

20452045
defp dynamic_repo(resource, %{context: %{data_layer: %{repo: repo}}}) do
2046-
repo || AshSqlite.DataLayer.Info.repo(resource)
2046+
repo || AshSqlite.DataLayer.Info.repo(resource, :read)
20472047
end
20482048

20492049
defp dynamic_repo(resource, _) do
2050-
AshSqlite.DataLayer.Info.repo(resource)
2050+
AshSqlite.DataLayer.Info.repo(resource, :read)
20512051
end
20522052

20532053
defp resolve_source(resource, changeset) do

lib/data_layer/info.ex

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,14 @@ defmodule AshSqlite.DataLayer.Info do
88
alias Spark.Dsl.Extension
99

1010
@doc "The configured repo for a resource"
11-
def repo(resource) do
12-
Extension.get_opt(resource, [:sqlite], :repo, nil, true)
11+
def repo(resource, type \\ :mutate) do
12+
case Extension.get_opt(resource, [:sqlite], :repo, nil, true) do
13+
fun when is_function(fun, 2) ->
14+
fun.(resource, type)
15+
16+
repo ->
17+
repo
18+
end
1319
end
1420

1521
@doc "The configured table for a resource"

lib/sql_implementation.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -335,8 +335,8 @@ defmodule AshSqlite.SqlImplementation do
335335
end
336336

337337
@impl true
338-
def repo(resource, _kind) do
339-
AshSqlite.DataLayer.Info.repo(resource)
338+
def repo(resource, kind) do
339+
AshSqlite.DataLayer.Info.repo(resource, kind)
340340
end
341341

342342
@impl true

0 commit comments

Comments
 (0)