Breaking action chain in a before_action?

Problem Statement: My webhook receives at-least-once messaging from an external producer, and I want to persist these Events -- ids generated by the producer-- as an Ash Resource, such that only one instance is saved in the database (postgres), and an Oban job is only enqueued once. I've created a custom :add action with the upsert? feature, and that works great for the Event persistence -- idempotent no-ops on subsequent calls. Issue: However, it doesn't look like there is any information in the Ash.Changeset.after_action record to determine if it was a fresh record or an existing record, and thus, I don't see a way to conditionally enqueue an Oban job. Possible solutions? 1. Add a nonce to Event, have a before action populate the changeset with a random number; followed by an after action that compares the returned record vs the changeset nonce... and enqueue Oban job if they match... This would be a probabilistic approach 2. Or, probably better, is if there a way for a before_action to load a possibly already existing Event with that given id-producer pair; if exists then shortcut stop the action, returning that Event as the result of the action? Or is this an around_action? -- knowing that these steps all live in the same db transaction...
2 Replies
ZachDaniel
ZachDaniel2y ago
You have a few options. Here are some details that might help: There is Ash.Changeset.set_result which will set the result of the action and avoid the initial insert. At some point in the future, I'm going to add metadata on the object that is returned that tells you wether or not it was created or updated as a result of the operation, which is something that people usually need. Until then, the general trick is to add timestamps and compare them i.e if inserted_at == updated_at then you just created the record otherwise you just updated it
Ben RMX
Ben RMXOP2y ago
I've been messing around and just came up with the following:
defmodule MyApp.MyContext.Changes.EventDuplicationBarrier do
use Ash.Resource.Change

@impl true
def change(changeset, _opts, _context) do
Ash.Changeset.around_action(changeset, fn changeset, callback ->
case load_existing_event(changeset) do
{:ok, result} -> {:ok, result, changeset, %{notifications: []}}
{:error, _} -> callback.(changeset)
end
end)
end

defp load_existing_event(changeset) do
MyApp.MyContext.Event.get_by_id(changeset.attributes[:id], tenant: changeset.tenant)
end
end
defmodule MyApp.MyContext.Changes.EventDuplicationBarrier do
use Ash.Resource.Change

@impl true
def change(changeset, _opts, _context) do
Ash.Changeset.around_action(changeset, fn changeset, callback ->
case load_existing_event(changeset) do
{:ok, result} -> {:ok, result, changeset, %{notifications: []}}
{:error, _} -> callback.(changeset)
end
end)
end

defp load_existing_event(changeset) do
MyApp.MyContext.Event.get_by_id(changeset.attributes[:id], tenant: changeset.tenant)
end
end
and
actions do
defaults [:read]

create :add do
description "Adds an Event to the data store, or no-op if the event already exists. No fields are actually UPSERTed."
upsert? true
upsert_fields [:id]

# Changes for this action, and ORDER MATTERS.
change MyApp.MyContext.Changes.EventDuplicationBarrier
change MyApp.MyContext.Changes.ObanScheduleEventWorker
end

read :get_by_id do
argument :id, :uuid

filter expr(id: arg(:id))
end
actions do
defaults [:read]

create :add do
description "Adds an Event to the data store, or no-op if the event already exists. No fields are actually UPSERTed."
upsert? true
upsert_fields [:id]

# Changes for this action, and ORDER MATTERS.
change MyApp.MyContext.Changes.EventDuplicationBarrier
change MyApp.MyContext.Changes.ObanScheduleEventWorker
end

read :get_by_id do
argument :id, :uuid

filter expr(id: arg(:id))
end
The above seems to work as I expected... But now that you mentioned Ash.Changeset.set_result , I'm going to try using that in a before_action ... since that around_action piece looks a bit hairy to me scratch that... i think you're right... created_at and updated_at comparisons in the after_action looks to be more clear.

Did you find this page helpful?