-
-
Notifications
You must be signed in to change notification settings - Fork 17
feat: allow transactions to be enabled on a resource-by-resource basis. #95
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
jimsynz
wants to merge
4
commits into
main
Choose a base branch
from
feat/fine-grained-transaction-control
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
882527c
improvement: Add flag to enable transactions for write actions.
jimsynz 39f8b26
chore(deps): Update spark to 2.2.35.
jimsynz 3cc167c
chore: Fix deprecation warning in sqlite installer.
jimsynz 766edf9
chore(deps): Update ash to 3.4.33.
jimsynz File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,175 @@ | ||
# Transactions and SQLite | ||
|
||
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. | ||
|
||
## Replacing transactions with Reactor sagas | ||
|
||
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. | ||
|
||
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: | ||
|
||
```elixir | ||
defmodule MyApp.Blog.Comment do | ||
use Ash.Resource, | ||
data_layer: AshSqlite.DataLayer, | ||
domain: MyApp.Blog | ||
|
||
attributes do | ||
uuid_primary_key :id | ||
|
||
attribute :body, :string, allow_nil?: false, public?: true | ||
|
||
create_timestamp :inserted_at | ||
end | ||
|
||
|
||
actions do | ||
defaults [:read, :destroy, update: :*] | ||
|
||
create :create do | ||
argument :post_id, :uuid, allow_nil?: false, public?: true | ||
|
||
primary? true | ||
|
||
change manage_relationsip(:post_id, :post, type: :append) | ||
change relate_to_actor(:author) | ||
|
||
change after_action(fn _changeset, record, context -> | ||
context.actor | ||
|> Ash.Changeset.for_update(:increment_engagement) | ||
|> Ash.update(actor: context.actor) | ||
|> case do | ||
{:ok, _} -> {:ok, record} | ||
{:error, reason} -> {:error, reason} | ||
end | ||
end) | ||
end | ||
end | ||
|
||
relationships do | ||
belongs_to :author, MyApp.Blog.User, public?: true, writable?: true | ||
belongs_to :post, MyApp.Blog.Post, public?: true, writable?: true | ||
end | ||
end | ||
|
||
defmodule MyApp.Blog.User do | ||
use Ash.Resource, | ||
data_layer: AshSqlite.DataLayer, | ||
domain: MyApp.Blog | ||
|
||
attributes do | ||
uuid_primary_key :id | ||
|
||
attribute :name, :string, allow_nil?: false, public?: true | ||
attribute :engagement_level, :integer, allow_nil?: false, default: 0, public?: true | ||
|
||
create_timestamp :inserted_at | ||
update_timestamp :updated_at | ||
end | ||
|
||
actions do | ||
defaults [:read, :destroy, create: :*, update: :*] | ||
|
||
update :increment_engagement do | ||
public? true | ||
change increment(:engagement_level, amount: 1) | ||
end | ||
|
||
update :decrement_engagement do | ||
public? true | ||
change increment(:engagement_level, amount: -1) | ||
end | ||
end | ||
|
||
relationships do | ||
has_many :posts, MyApp.Blog.Post, public?: true, destination_attribute: :author_id | ||
has_many :comments, MyApp.Blog.Comment, public?: true, destination_attribute: :author_id | ||
end | ||
end | ||
``` | ||
|
||
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. | ||
|
||
Converting the create into a Reactor requires us to be explicit about how our steps are composed and what the dependencies between them are: | ||
|
||
```elixir | ||
defmodule MyApp.Blog.Comment do | ||
# ... | ||
|
||
actions do | ||
defaults [:read, :destroy, update: :*, create: :*] | ||
|
||
action :post_comment, :struct do | ||
constraints instance_of: __MODULE__ | ||
argument :body, :string, allow_nil?: false, public?: true | ||
argument :post_id, :uuid, allow_nil?: false, public?: true | ||
argument :author_id, :uuid, allow_nil?: false, public?: true | ||
run MyApp.Blog.PostCommentReactor | ||
end | ||
end | ||
|
||
# ... | ||
end | ||
|
||
defmodule MyApp.Blog.PostCommentReactor do | ||
use Reactor, extensions: [Ash.Reactor] | ||
|
||
input :body | ||
input :post_id | ||
input :author_id | ||
|
||
read_one :get_author, MyApp.Blog.User, :get_by_id do | ||
inputs %{id: input(:author_id)} | ||
fail_on_not_found? true | ||
authorize? false | ||
end | ||
|
||
create :create_comment, MyApp.Blog.Comment, :create do | ||
inputs %{ | ||
body: input(:body), | ||
post_id: input(:post_id), | ||
author_id: input(:author_id) | ||
} | ||
|
||
actor result(:get_author) | ||
undo :always | ||
undo_action :destroy | ||
end | ||
|
||
update :update_author_engagement, MyApp.Blog.User, :increment_engagement do | ||
initial result(:get_author) | ||
actor result(:get_author) | ||
undo :always | ||
undo_action :decrement_engagement | ||
end | ||
|
||
return :create_comment | ||
end | ||
``` | ||
|
||
> {: .neutral} | ||
> Note that the examples above are edited for brevity and will not run with without modification | ||
|
||
## Enabling transactions | ||
|
||
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: | ||
|
||
```elixir | ||
defmodule MyApp.Blog.Post do | ||
use Ash.Resource, | ||
data_layer: AshSqlite.DataLayer, | ||
domain: MyApp.Blog | ||
|
||
sqlite do | ||
repo MyApp.Repo | ||
enable_write_transactions? true | ||
end | ||
end | ||
``` | ||
|
||
This will allow you to set `transaction? true` on actions. Doing this needs very careful management to ensure that all transactions are serialised. | ||
|
||
Strategies for serialising transactions include: | ||
|
||
- Running all writes through a single `GenServer`. | ||
- Using a separate write repo with a pool size of 1. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you mentioned that this wasn't working the way you expected it to. Have you confirmed that
in_transaction?/0
works with Sqlite in general?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
also don't forget to add a rollback callback.