Skip to content

Commit e263f43

Browse files
committed
improvement: Add flag to enable transactions for write actions.
1 parent 89eb898 commit e263f43

File tree

13 files changed

+402
-13
lines changed

13 files changed

+402
-13
lines changed

.formatter.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ spark_locals_without_parens = [
33
code?: 1,
44
deferrable: 1,
55
down: 1,
6+
enable_write_transactions?: 1,
67
exclusion_constraint_names: 1,
78
foreign_key_names: 1,
89
identity_index_names: 1,

documentation/dsls/DSL:-AshSqlite.DataLayer.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ end
3535

3636
| Name | Type | Default | Docs |
3737
|------|------|---------|------|
38-
| [`repo`](#sqlite-repo){: #sqlite-repo .spark-required} | `atom` | | The repo that will be used to fetch your data. See the `AshSqlite.Repo` documentation for more |
38+
| [`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 |
3939
| [`migrate?`](#sqlite-migrate?){: #sqlite-migrate? } | `boolean` | `true` | Whether or not to include this resource in the generated migrations with `mix ash.generate_migrations` |
4040
| [`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. |
4141
| [`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\\"`. |
@@ -48,6 +48,7 @@ end
4848
| [`migration_ignore_attributes`](#sqlite-migration_ignore_attributes){: #sqlite-migration_ignore_attributes } | `list(atom)` | `[]` | A list of attributes that will be ignored when generating migrations. |
4949
| [`table`](#sqlite-table){: #sqlite-table } | `String.t` | | The table to store and read the resource from. If this is changed, the migration generator will not remove the old table. |
5050
| [`polymorphic?`](#sqlite-polymorphic?){: #sqlite-polymorphic? } | `boolean` | `false` | Declares this resource as polymorphic. See the [polymorphic resources guide](/documentation/topics/resources/polymorphic-resources.md) for more. |
51+
| [`enable_write_transactions?`](#sqlite-enable_write_transactions?){: #sqlite-enable_write_transactions? } | `boolean` | `false` | Enable write transactions for this resource. See the [SQLite transaction guide](/documentation/topics/about-as-sqlite/transactions.md) for more. |
5152

5253

5354
## sqlite.custom_indexes
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
# Transactions and SQLite
2+
3+
By default SQLite3 allows only one write transaction to be running at a time. Any attempt to commit a transaction while another is running will result in an error. Because Elixir is a highly concurrent environment and [Ecto](https://hexdocs.pm/ecto) uses a connection pool by default, AshSqlite disables transactions by default. This can lead to some surprising behaviours if you're used to working with AshPostgres - for example after action hooks which fail will leave records behind, but the action which ran them will return an error. This document discusses some strategies for working around this constraint.
4+
5+
## Replacing transactions with Reactor sagas
6+
7+
A saga is a way of making transaction-like behaviour by explicitly telling the system to undo any changes it has made up until the point of failure. This works well for remote resources such as web APIs, but also for working with Ash data layers that do not support transactions. As a general rule; anything you could model with action lifecycle hooks can also be modelled with Reactor, with the addition of a bit more ceremony.
8+
9+
For our example, we'll use the idea of a system where posting a comment needs to increment an engagement score. Here's how you could naively implement it:
10+
11+
```elixir
12+
defmodule MyApp.Blog.Comment do
13+
use Ash.Resource,
14+
data_layer: AshSqlite.DataLayer,
15+
domain: MyApp.Blog
16+
17+
attributes do
18+
uuid_primary_key :id
19+
20+
attribute :body, :string, allow_nil?: false, public?: true
21+
22+
create_timestamp :inserted_at
23+
end
24+
25+
26+
actions do
27+
defaults [:read, :destroy, update: :*]
28+
29+
create :create do
30+
argument :post_id, :uuid, allow_nil?: false, public?: true
31+
32+
primary? true
33+
34+
change manage_relationsip(:post_id, :post, type: :append)
35+
change relate_to_actor(:author)
36+
37+
change after_action(fn _changeset, record, context ->
38+
context.actor
39+
|> Ash.Changeset.for_update(:increment_engagement)
40+
|> Ash.update(actor: context.actor)
41+
|> case do
42+
{:ok, _} -> {:ok, record}
43+
{:error, reason} -> {:error, reason}
44+
end
45+
end)
46+
end
47+
end
48+
49+
relationships do
50+
belongs_to :author, MyApp.Blog.User, public?: true, writable?: true
51+
belongs_to :post, MyApp.Blog.Post, public?: true, writable?: true
52+
end
53+
end
54+
55+
defmodule MyApp.Blog.User do
56+
use Ash.Resource,
57+
data_layer: AshSqlite.DataLayer,
58+
domain: MyApp.Blog
59+
60+
attributes do
61+
uuid_primary_key :id
62+
63+
attribute :name, :string, allow_nil?: false, public?: true
64+
attribute :engagement_level, :integer, allow_nil?: false, default: 0, public?: true
65+
66+
create_timestamp :inserted_at
67+
update_timestamp :updated_at
68+
end
69+
70+
actions do
71+
defaults [:read, :destroy, create: :*, update: :*]
72+
73+
update :increment_engagement do
74+
public? true
75+
change increment(:engagement_level, amount: 1)
76+
end
77+
78+
update :decrement_engagement do
79+
public? true
80+
change increment(:engagement_level, amount: -1)
81+
end
82+
end
83+
84+
relationships do
85+
has_many :posts, MyApp.Blog.Post, public?: true, destination_attribute: :author_id
86+
has_many :comments, MyApp.Blog.Comment, public?: true, destination_attribute: :author_id
87+
end
88+
end
89+
```
90+
91+
This would work as expected - as long as everything goes according to plan - if, however, there is a transient failure, or some kind of validation error in one of the hooks could cause the comment to be created, but the create action to still return an error.
92+
93+
Converting the create into a Reactor requires us to be explicit about how our steps are composed and what the dependencies between them are:
94+
95+
```elixir
96+
defmodule MyApp.Blog.Comment do
97+
# ...
98+
99+
actions do
100+
defaults [:read, :destroy, update: :*, create: :*]
101+
102+
action :post_comment, :struct do
103+
constraints instance_of: __MODULE__
104+
argument :body, :string, allow_nil?: false, public?: true
105+
argument :post_id, :uuid, allow_nil?: false, public?: true
106+
argument :author_id, :uuid, allow_nil?: false, public?: true
107+
run MyApp.Blog.PostCommentReactor
108+
end
109+
end
110+
111+
# ...
112+
end
113+
114+
defmodule MyApp.Blog.PostCommentReactor do
115+
use Reactor, extensions: [Ash.Reactor]
116+
117+
input :body
118+
input :post_id
119+
input :author_id
120+
121+
read_one :get_author, MyApp.Blog.User, :get_by_id do
122+
inputs %{id: input(:author_id)}
123+
fail_on_not_found? true
124+
authorize? false
125+
end
126+
127+
create :create_comment, MyApp.Blog.Comment, :create do
128+
inputs %{
129+
body: input(:body),
130+
post_id: input(:post_id),
131+
author_id: input(:author_id)
132+
}
133+
134+
actor result(:get_author)
135+
undo :always
136+
undo_action :destroy
137+
end
138+
139+
update :update_author_engagement, MyApp.Blog.User, :increment_engagement do
140+
initial result(:get_author)
141+
actor result(:get_author)
142+
undo :always
143+
undo_action :decrement_engagement
144+
end
145+
146+
return :create_comment
147+
end
148+
```
149+
150+
> {: .neutral}
151+
> Note that the examples above are edited for brevity and will not run with without modification
152+
153+
## Enabling transactions
154+
155+
Sometimes you really just want to be able to use a database transaction, in which case you can set `enable_write_transactions?` to `true` in the `sqlite` DSL block:
156+
157+
```elixir
158+
defmodule MyApp.Blog.Post do
159+
use Ash.Resource,
160+
data_layer: AshSqlite.DataLayer,
161+
domain: MyApp.Blog
162+
163+
sqlite do
164+
repo MyApp.Repo
165+
enable_write_transactions? true
166+
end
167+
end
168+
```
169+
170+
This will allow you to set `transaction? true` on actions. Doing this needs very careful management to ensure that all transactions are serialised.
171+
172+
Strategies for serialising transactions include:
173+
174+
- Running all writes through a single `GenServer`.
175+
- Using a separate write repo with a pool size of 1.

lib/data_layer.ex

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ defmodule AshSqlite.DataLayer do
194194
],
195195
schema: [
196196
repo: [
197-
type: :atom,
197+
type: {:or, [{:behaviour, Ecto.Repo}, {:fun, 2}]},
198198
required: true,
199199
doc:
200200
"The repo that will be used to fetch your data. See the `AshSqlite.Repo` documentation for more"
@@ -278,6 +278,13 @@ defmodule AshSqlite.DataLayer do
278278
doc: """
279279
Declares this resource as polymorphic. See the [polymorphic resources guide](/documentation/topics/resources/polymorphic-resources.md) for more.
280280
"""
281+
],
282+
enable_write_transactions?: [
283+
type: :boolean,
284+
default: false,
285+
doc: """
286+
Enable write transactions for this resource. See the [SQLite transaction guide](/documentation/topics/about-as-sqlite/transactions.md) for more.
287+
"""
281288
]
282289
]
283290
}
@@ -296,13 +303,40 @@ defmodule AshSqlite.DataLayer do
296303
AshSqlite.Transformers.ValidateReferences,
297304
AshSqlite.Transformers.VerifyRepo,
298305
AshSqlite.Transformers.EnsureTableOrPolymorphic
306+
],
307+
verifiers: [
308+
AshSqlite.Verifiers.VerifyTransactions
299309
]
300310

301311
def migrate(args) do
302312
# TODO: take args that we care about
303313
Mix.Task.run("ash_sqlite.migrate", args)
304314
end
305315

316+
@impl true
317+
def transaction(resource, func, timeout \\ nil, reason \\ %{type: :custom, metadata: %{}}) do
318+
repo =
319+
case reason[:data_layer_context] do
320+
%{repo: repo} when not is_nil(repo) ->
321+
repo
322+
323+
_ ->
324+
AshSqlite.DataLayer.Info.repo(resource, :read)
325+
end
326+
327+
func = fn ->
328+
repo.on_transaction_begin(reason)
329+
330+
func.()
331+
end
332+
333+
if timeout do
334+
repo.transaction(func, timeout: timeout)
335+
else
336+
repo.transaction(func)
337+
end
338+
end
339+
306340
def rollback(args) do
307341
repos = AshSqlite.Mix.Helpers.repos!([], args)
308342

@@ -385,7 +419,10 @@ defmodule AshSqlite.DataLayer do
385419
def can?(_, :bulk_create), do: true
386420
def can?(_, {:lock, _}), do: false
387421

388-
def can?(_, :transact), do: false
422+
def can?(resource, :transact) do
423+
Spark.Dsl.Extension.get_opt(resource, [:sqlite], :enable_write_transactions?, false)
424+
end
425+
389426
def can?(_, :composite_primary_key), do: true
390427
def can?(_, {:atomic, :update}), do: true
391428
def can?(_, {:atomic, :upsert}), do: true
@@ -446,6 +483,11 @@ defmodule AshSqlite.DataLayer do
446483
def can?(_, {:sort, _}), do: true
447484
def can?(_, _), do: false
448485

486+
@impl true
487+
def in_transaction?(resource) do
488+
AshSqlite.DataLayer.Info.repo(resource, :mutate).in_transaction?()
489+
end
490+
449491
@impl true
450492
def limit(query, nil, _), do: {:ok, query}
451493

lib/data_layer/info.ex

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

66
@doc "The configured repo for a resource"
7-
def repo(resource) do
8-
Extension.get_opt(resource, [:sqlite], :repo, nil, true)
7+
def repo(resource, type \\ :mutate) do
8+
case Extension.get_opt(resource, [:sqlite], :repo, nil, true) do
9+
fun when is_function(fun, 2) ->
10+
fun.(resource, type)
11+
12+
repo ->
13+
repo
14+
end
915
end
1016

1117
@doc "The configured table for a resource"

lib/repo.ex

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,9 @@ defmodule AshSqlite.Repo do
1212
@doc "Use this to inform the data layer about what extensions are installed"
1313
@callback installed_extensions() :: [String.t()]
1414

15-
@doc """
16-
Use this to inform the data layer about the oldest potential sqlite version it will be run on.
1715

18-
Must be an integer greater than or equal to 13.
19-
"""
20-
@callback min_pg_version() :: integer()
16+
@doc "Called when a transaction starts"
17+
@callback on_transaction_begin(reason :: Ash.DataLayer.transaction_reason()) :: term
2118

2219
@doc "The path where your migrations are stored"
2320
@callback migrations_path() :: String.t() | nil
@@ -39,7 +36,6 @@ defmodule AshSqlite.Repo do
3936
def installed_extensions, do: []
4037
def migrations_path, do: nil
4138
def override_migration_type(type), do: type
42-
def min_pg_version, do: 10
4339

4440
def init(_, config) do
4541
new_config =
@@ -51,6 +47,8 @@ defmodule AshSqlite.Repo do
5147
{:ok, new_config}
5248
end
5349

50+
def on_transaction_begin(_reason), do: :ok
51+
5452
def insert(struct_or_changeset, opts \\ []) do
5553
struct_or_changeset
5654
|> to_ecto()
@@ -82,6 +80,13 @@ defmodule AshSqlite.Repo do
8280
end)
8381
|> from_ecto()
8482
end
83+
84+
def transaction!(fun) do
85+
case fun.() do
86+
{:ok, value} -> value
87+
{:error, error} -> raise Ash.Error.to_error_class(error)
88+
end
89+
end
8590

8691
def from_ecto({:ok, result}), do: {:ok, from_ecto(result)}
8792
def from_ecto({:error, _} = other), do: other
@@ -148,8 +153,8 @@ defmodule AshSqlite.Repo do
148153

149154
defoverridable init: 2,
150155
installed_extensions: 0,
151-
override_migration_type: 1,
152-
min_pg_version: 0
156+
on_transaction_begin: 1,
157+
override_migration_type: 1
153158
end
154159
end
155160
end

0 commit comments

Comments
 (0)