Skip to content

Commit a7cfd17

Browse files
committed
wip: Start working on transaction documentation and features.
1 parent 89eb898 commit a7cfd17

File tree

2 files changed

+182
-0
lines changed

2 files changed

+182
-0
lines changed
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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}

0 commit comments

Comments
 (0)