Atomic validations when used with postgres are not race-condition-free due to Postgres’ transaction semantics (i.e not all transactions are serializable). This means that even if you used an atomic validation like the one below, referencing related data can still incur a race condition unless you configure transaction isolation levels in postgres to be serializable (generally considered far too heavy handed and not ideal)
defmodule MachineIsArchivable do
use Ash.Resource.Validation
def atomic(changeset, _, _) do
{:atomic, [], expr(exists(machine_types, not(is_nil(archived_at))))
end
end
How you solve this depends on your application, what other actions you have, etc, but in general it will always involve some kind of locking. Here is an example of how I might implement a reusable locking set up for resources that use AshPostgres
defmodule WIthAdvisoryLock do
use Ash.Resource.Change
def change(changeset, opts, _) do
Ash.Changeset.around_action(changeset, fn changeset, callback ->
lock_id =
opts[:name]
|> to_string()
|> :erlang.phash2()
changeset =
case MyApp.Repo.query!("SELECT pg_advisory_lock($1)", [lock_id]) do
%Postgrex.Result{rows: [[true]]} ->
changeset
%Postgrex.Result{rows: [[false]]} ->
error = Ash.Error.Invalid.Unavailable.exception(
resource: changeset.resource,
reason: "#{opts[:name]} unavailable."
)
Ash.Changeset.add_error(changeset, error)
end
result = callback.(changeset)
# release the lock if
if !match?(result, {:error, _}), do: MyApp.Repo.query!("SELECT pg_advisory_unlock($1)", [lock_id])
result
end)
end
end
Then you could use something like this in your action
update :unarchive do
# make sure that anything that conflicts with each other uses the same `name`
change {WIthAdvisoryLock, name: :unarchival}
end
There may be some fixes/adjustments to the code I posted above required, its just there to serve as an example/idea of how it might be done