Enforcing constraints based on other resources without introducing race conditions

What is the idiomatic way to enforce constraints based on other resources?

Say I have a Machine resource that belongs to MachineType. I want archive and unarchive actions for both Machine and MachineType. archive action for MachineType should be prohibited if there are any non-archived Machines belonging to that MachineType. And unarchive should be prohibited for a Machine belonging to an archived MachineType.

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 :slight_smile:

Thanks! I will give this approach a try