Serialized access to instance of resource for `AshPostgres`

Pursuant to the discussion in #general , I'd like to create a macro which uses the changes hook to ensure that change actions to an instance (row) of a resource are serialized. For now I'm only looking at Postgres support, but if the lock primitive comes into play, happy to update. Primary changes are the addition of a definitely-not-working locking statement and or changeset.type == :read to not block read actions.
defmodule AshPostgresUtil.SerializedChanges do
defmacro __using__(opts \\ []) do
changes do
change fn changeset, _ ->
unless changeset.context[:locked][changeset.data.id] or changeset.type == :read do
Ash.Changeset.before_action(changeset, fn changeset ->
Ash.set_context(%{locked: %{changeset.data.id => true}})

# something like this
unquote(__CALLER__.module)
|> lock("FOR UPDATE")
|> unquote(opts[:repo]).get!(changeset.data.id)

changeset
end)
|> Ash.Changeset.after_action(fn changeset, result ->
Ash.set_context(%{locked: %{changeset.data.id => false}})
{:ok, result}
end)
else
changeset
end
end
end
end
end
defmodule AshPostgresUtil.SerializedChanges do
defmacro __using__(opts \\ []) do
changes do
change fn changeset, _ ->
unless changeset.context[:locked][changeset.data.id] or changeset.type == :read do
Ash.Changeset.before_action(changeset, fn changeset ->
Ash.set_context(%{locked: %{changeset.data.id => true}})

# something like this
unquote(__CALLER__.module)
|> lock("FOR UPDATE")
|> unquote(opts[:repo]).get!(changeset.data.id)

changeset
end)
|> Ash.Changeset.after_action(fn changeset, result ->
Ash.set_context(%{locked: %{changeset.data.id => false}})
{:ok, result}
end)
else
changeset
end
end
end
end
end
Usage:
defmodule Bookstore.EnrollmentFlow do
use Ash.Resource, data_layer: AshPostgres.DataLayer
use AshPostgresUtil.SerializedChanges, repo: Bookstore.Repo

# ...
end
defmodule Bookstore.EnrollmentFlow do
use Ash.Resource, data_layer: AshPostgres.DataLayer
use AshPostgresUtil.SerializedChanges, repo: Bookstore.Repo

# ...
end
Two things: - This relies on the changeset and its children happening inside a transaction. Is that the case? - I'm using the resource as if it's an Ecto schema. This is...maybe not legit. The Ash changeset (or parts of it) will end up as an Ecto changeset at some point, though. Is there an escape hatch I can use here to inject this or raw SQL into the changeset?
39 Replies
ZachDaniel
ZachDaniel3y ago
I don’t think the macro is necessary honestly. You can define a module and refer to it. change SerializeAccess And then use Ash.Resource.Change In that module.
\ ឵឵឵
\ ឵឵឵OP3y ago
Sure, I'm happy to do it that way as well.
ZachDaniel
ZachDaniel3y ago
Some notes/answers: 1. Changesets will never have an action type of :read. Queries are used for that, and go through a different flow. 2. By default global changes only happen on create/update. To include destroy, do change Module, on: [:create, :update, :destroy] 3. By default all create/update/destroy actions happen in a transaction. Anything nested is therefore, by default, also in a transaction. 4. You can’t really inject raw sql into the changeset, but you can make the action “manual” which gives you an Ash changeset and leaves you to your own devices on how to actually do the action (all hooks/changes still happen around it though)
\ ឵឵឵
\ ឵឵឵OP3y ago
1. Makes sense. 2. Good note, will do. 3. Great! 4. Alright, so I guess this means implementing Manual{Create,Update,Destroy} to do the locking? Does this mix with non-manual things inside a single transaction? Can I just do:
create :enter_email do
argument :email, :string
manual SerializeChanges
code = generate_code()
# send an email with the code
change set_attribute(:email, arg(:email))
change set_attribute(:code, code)
change set_attribute(:state, :awaiting_code)
end
create :enter_email do
argument :email, :string
manual SerializeChanges
code = generate_code()
# send an email with the code
change set_attribute(:email, arg(:email))
change set_attribute(:code, code)
change set_attribute(:state, :awaiting_code)
end
ZachDaniel
ZachDaniel3y ago
well yes and no a couple things You'll get an Ash changeset with all of those attributes set, yes in your manual action but you can't do what you're doing there with code = generate_code() that block of code is all happening at compile time You'd need to do something like change SetCode and generate code in the change function
\ ឵឵឵
\ ឵឵឵OP3y ago
Right, ok. Same for SendEmail.
ZachDaniel
ZachDaniel3y ago
So is the enter_email action actually a create? like its creating a new user or something like that?
\ ឵឵឵
\ ឵឵឵OP3y ago
Although...how do I give the code to SendEmail? It's an entrypoint into that persistent flow, so it's a create.
ZachDaniel
ZachDaniel3y ago
So what do you need to serialize in that case? There won't be anything in the db to lock
\ ឵឵឵
\ ឵឵឵OP3y ago
Ok sure, for the following steps. verify_code, enter_password, etc.
ZachDaniel
ZachDaniel3y ago
Yeah. Anyway, if you wanted to do something like first generate a code, and then send an email with that code (FYI we have ash_authentication if you are building an authentication system)
change fn changeset, _ ->
set_code(changeset)
end

change fn changeset, _ ->
Ash.Changeset.get_attribute(changeset, :code) |> send_code()

changeset
end
change fn changeset, _ ->
set_code(changeset)
end

change fn changeset, _ ->
Ash.Changeset.get_attribute(changeset, :code) |> send_code()

changeset
end
I used anonymous functions in that example just for brevity but you can do that or modules You can also set context on the changeset and pick it up in subsequent changes Ash.Changeset.set_context/2 and changeset.context...
\ ឵឵឵
\ ឵឵឵OP3y ago
Right, with you so far.
ZachDaniel
ZachDaniel3y ago
Other than that, I don't think you necessarily need a manual action for your locking You don't want to lock when you do the update anyway, you want to lock before that, right? So if you have a change, that locks the row before_action, then you ought to be good to go.
\ ឵឵឵
\ ឵឵឵OP3y ago
Under a lot of circumstances, I'd agree, but the goal is to be able to use this for creating an FSM. Without select ... for update multiple transactions will be able to run over each other. Postgres won't enforce the FSM invariants without a little bit of help.
ZachDaniel
ZachDaniel3y ago
Yeah but you can issue that select in a before action hook Or are you planning on doing the create action thing we mentioned to simulate it Where everything is a manual create action
\ ឵឵឵
\ ឵឵឵OP3y ago
No, hopefully not! I guess that's what I was trying to do at the top, no?
ZachDaniel
ZachDaniel3y ago
But the manual action is like…taking over the actual write to the db It replaces our insert statement
\ ឵឵឵
\ ឵឵឵OP3y ago
I think the piece missing is how to do a select ... for update with Ash. I know how to do it with Ecto, but I didn't see an equivalent in the Ash docs yet.
ZachDaniel
ZachDaniel3y ago
Yeah, there really isn’t one You should just use ecto for that
\ ឵឵឵
\ ឵឵឵OP3y ago
Given the conversation before about lock I figured it wasn't there. Ok, so a before_action hook would be more appropriate for this than a manual? How does one issue an Ecto query in a before_action hook? 😁
ZachDaniel
ZachDaniel3y ago
You can just run an ecto query as normal Like in the before action hook do a query with a lock as if you were just using ecto
\ ឵឵឵
\ ឵឵឵OP3y ago
I think that's what I was doing up top. Could probably get rid of __CALLER__.module and repo: if this is somewhere in the context/changeset passed to before_action.
ZachDaniel
ZachDaniel3y ago
Yeah, I don’t think I had any problem with that, was just advising against using a macro for it Because any change can add a before action and after action hook So the macro isn’t really helping
\ ឵឵឵
\ ឵឵឵OP3y ago
Ok, so the strategy makes sense, but I can replace the macro with a change that adds a before_action hook?
ZachDaniel
ZachDaniel3y ago
changes do
change SerializeAccess, on: [:update, :destroy]
end
changes do
change SerializeAccess, on: [:update, :destroy]
end
So if you had that (no need to serialize a create).
defmodule SerializeAccess do
use Ash.Resource.Change

def change(changeset, _, _) do
Ash.Changeset.before_action(changeset, …
end
end
defmodule SerializeAccess do
use Ash.Resource.Change

def change(changeset, _, _) do
Ash.Changeset.before_action(changeset, …
end
end
Didn’t write the whole thing out cuz I’m on my phone now 😂
\ ឵឵឵
\ ឵឵឵OP3y ago
No worries, mate, I appreciate the time. Can I just stick this in there, though?
# something like this
unquote(__CALLER__.module)
|> lock("FOR UPDATE")
|> unquote(opts[:repo]).get!(changeset.data.id)
# something like this
unquote(__CALLER__.module)
|> lock("FOR UPDATE")
|> unquote(opts[:repo]).get!(changeset.data.id)
Is an Ash.Resource also an Ecto.Schema? Forget the unquote stuff ofc. Or where in the changeset can I extract the Ecto.Schema and Repo?
ZachDaniel
ZachDaniel3y ago
repo = AshPostgres.DataLayer.Info.repo(changeset.resource)

changeset.resource
|> Ecto.Query.lock("FOR UPDATE")
|> repo.get!(changeset.adata.id)
repo = AshPostgres.DataLayer.Info.repo(changeset.resource)

changeset.resource
|> Ecto.Query.lock("FOR UPDATE")
|> repo.get!(changeset.adata.id)
\ ឵឵឵
\ ឵឵឵OP3y ago
Gold.
ZachDaniel
ZachDaniel3y ago
I forget if thats how you actually do ecto locking but otherwies that should work
\ ឵឵឵
\ ឵឵឵OP3y ago
It's right, but yeah need the prefix.
ZachDaniel
ZachDaniel3y ago
are you doing multitenant stuff? or jsut storing your data in the non-public schema
\ ឵឵឵
\ ឵឵឵OP3y ago
Multitenant in nature, yes, but can't use the multitenancy in Ash because users can cross tenant boundaries.
ZachDaniel
ZachDaniel3y ago
in what way? Just users specifically? Or like in general you'll be mixing tenant data?
\ ឵឵឵
\ ឵឵឵OP3y ago
Think GH; there are organizations whose data is siloed, which would make a lot of sense to use at least attribute-based MT, but user accounts have data attached which is outside the siloes and can be members of multiple orgs.
ZachDaniel
ZachDaniel3y ago
You can generally mix and match tenant and non-tenant data but thats a conversation for another time 🙂 I'm going to hit the sack Well, I guess is that still an unanswered question for you? Like how to set the query prefix?
\ ឵឵឵
\ ឵឵឵OP3y ago
Oh no, I was just saying you need Ecto.Query. before lock because it's not going to be in scope in that context.
ZachDaniel
ZachDaniel3y ago
oh haha okay gotcha
\ ឵឵឵
\ ឵឵឵OP3y ago
Thanks a lot for all the answers mate, have a good rest!
ZachDaniel
ZachDaniel3y ago
My pleasure. Have a good one!

Did you find this page helpful?