Complex Custom Actions

How do you usually model complex operations over a model? I have a model that needs to do some processing over their inputs, it handles everything related to subtitles, and after creation it goes over several stages of analysis of the context and such and I'm struggling to fit it inside of the Ash framework. I was thinking to model it in two ways: * Have an Oban job that contains that logic and operates over a :update or :analyzed action. * The problem in this case is that I cannot use AshOban to handle the scheduling, I'd need to do it manually * Implement a manual action using Ash.Resource.ManualUpdate, but in that case I'm a bit unsure how to handle everything, what are the best practices around ManualUpdate and what am I giving up in exchange. I still have a bit of trouble reasoning about that. * In that case do I need to deal with ecto directly? Or should I use other more generic actions as part of the manual action? Do you have an example of a ManualUpdate I could take a look at?
Solution:
before_action hook or before_transaction hook depending on how costly the analyze_content() function is
Jump to solution
13 Replies
mcoll
mcollOP2w ago
To give a bit more context, I have this Content resource. And so far I've added
actions
update :analyze do
manual Katarineko.Learning.AnalyzeContent
end
end

oban do
triggers do
trigger :analyze do
action :analyze
queue :default
where expr(state == :pending)
on_error :failed
scheduler_module_name __MODULE__.Jobs.Scheduler
worker_module_name __MODULE__.Jobs.Worker
end
end
end
actions
update :analyze do
manual Katarineko.Learning.AnalyzeContent
end
end

oban do
triggers do
trigger :analyze do
action :analyze
queue :default
where expr(state == :pending)
on_error :failed
scheduler_module_name __MODULE__.Jobs.Scheduler
worker_module_name __MODULE__.Jobs.Worker
end
end
end
The AnalyzeContent basically does
@impl true
def update(changeset, _opts, _context) do
word_frequencies = analyze_content(changeset.get_attribute(:srt_content))

record = changeset
|> Ash.Changeset.force_change_attribute(:word_frequencies, word_frequencies)
|> Ash.Changeset.force_change_attribute(:state, :analyzed)
|> Ash.update!()

{:ok, record}
end
@impl true
def update(changeset, _opts, _context) do
word_frequencies = analyze_content(changeset.get_attribute(:srt_content))

record = changeset
|> Ash.Changeset.force_change_attribute(:word_frequencies, word_frequencies)
|> Ash.Changeset.force_change_attribute(:state, :analyzed)
|> Ash.update!()

{:ok, record}
end
Around this I have a couple of questions: * if from the changeset I use change_attribute it complains that it cannot do that since it has already been validated for the :analyze action. What is the proper way to do this? * Should I just create a new changeset? * Do I give up on anything (apart from having to write things myself) by doing a custom action? I see internally the set_attribute change also does force_change_attribute so I guess it's expected? https://github.com/ash-project/ash/blob/v3.5.10/lib/ash/resource/change/set_attribute.ex#L71-L75 So, like this it worked properly
@impl true
def update(changeset, _module_opts, _ctx) do

word_frequencies = changeset
|> Ash.Changeset.get_attribute(:srt_content)
|> analyze_content()

changeset.data
|> Ash.Changeset.for_update(:update, %{word_frequencies: word_frequencies, state: :analyzed})
|> Ash.update()
end
@impl true
def update(changeset, _module_opts, _ctx) do

word_frequencies = changeset
|> Ash.Changeset.get_attribute(:srt_content)
|> analyze_content()

changeset.data
|> Ash.Changeset.for_update(:update, %{word_frequencies: word_frequencies, state: :analyzed})
|> Ash.update()
end
So one thing I discovered is that if I use the same changeset, then there is infinite recursion because when I call Ash.update it seems like it calls itself, because it is ready for itself. (through for_update(:analyze)) The end result was that this function was being called indefinitely. So I need to create a new Changeset, however for some reason if I did
changeset.data
|> Ash.Changeset.new
|> Ash.Changeset.change_attribute(:word_frequencies, word_frequencies)
|> Ash.update()
changeset.data
|> Ash.Changeset.new
|> Ash.Changeset.change_attribute(:word_frequencies, word_frequencies)
|> Ash.update()
It didn't work, and I'm unsure why. I had to use the for_update for it to work. Another question I have is, can I not use other changes when I use a manual action? I am using state machine, and tried to do
update :analyze do
manual Katarineko.Learning.AnalyzeContent
change transition_state(:analyzed)
end
update :analyze do
manual Katarineko.Learning.AnalyzeContent
change transition_state(:analyzed)
end
This never does the transition_state and it doesn't complain either, I guess I'd expect one or the other.
Chaz Watkins
Chaz Watkins2w ago
For the last question, you're looking for run and a change module. Using the manual opt says you are delgating to your own Update action implementation, so no other changes are applied. While run takes an anon function or change module to run a change on the changeset, so other changes steps can be applied when using run
barnabasj
barnabasj2w ago
why do you think you need a ManualUpdate?
Chaz Watkins
Chaz Watkins2w ago
As for the Ash.Changeset.new vs Ash.Changeset.for_update, the typical pattern is to use for_update and the name of the update action you are applying.
mcoll
mcollOP2w ago
I do not think I need a Manual Update, my question is "do I need a Manual Update?", what other ways could I have modeled this that are the most Ash-y
barnabasj
barnabasj2w ago
The warning you see because of change_attribute is only to warn you that the validtion aren't run against the changes you make to the changes at this point in the lifecycle
Solution
barnabasj
barnabasj2w ago
before_action hook or before_transaction hook depending on how costly the analyze_content() function is
barnabasj
barnabasj2w ago
and why do you think you can't do the scheduling with AshOban?
mcoll
mcollOP2w ago
hmmm, it's quite costly. So you would model it as a beforeaction hook for the :analyze action? With triggers I couldn't find a way to trigger a job I wanted instead of a job that wrapped an action, maybe I missed something aha! I hadn't seen the whole hooks stuff yet, I was wondering how to run arbitrary code in the action ```elixir defmodule AshChangesetLifeCycleExample do def change(changeset, , _) do changeset # execute code both before and after the transaction |> Ash.Changeset.around_transaction(fn changeset, callback -> callback.(changeset) end) # execute code before the transaction is started. Use for things like external calls |> Ash.Changeset.before_transaction(fn changeset -> changeset end) # execute code in the transaction, before and after the data layer is called |> Ash.Changeset.around_action(fn changeset, callback -> callback.(changeset) end) # execute code in the transaction, before the data layer is called |> Ash.Changeset.before_action(fn changeset -> changeset end) # execute code in the transaction, after the data layer is called, only if the action is successful |> Ash.Changeset.after_action(fn changeset, result -> {:ok, result} end) # execute code after the transaction, both in success and error cases |> Ash.Changeset.after_transaction(fn changeset, success_or_error_result -> success_or_error_result end end end ``` this makes it clearer thanks @barnabasj and @Chaz Watkins ! does this work well with bulk actions? I've been burned before by hooks in Rails, does it share the same kind of footguns?
Chaz Watkins
Chaz Watkins2w ago
You’ll need to add ‘batch_change/3’ callback and apply the change function to all changesets.
ZachDaniel
ZachDaniel2w ago
@Chaz Watkins we gotta get that complex actions guide done 😂
mcoll
mcollOP2w ago
Okay, the end result is something like this, this is a lot more manageable, thanks 😄
update :analyze do
change before_transaction(fn changeset, _ctx ->
word_frequencies = changeset
|> Ash.Changeset.get_attribute(:srt_content)
|> Katarineko.Learning.ContentAnalyzer.analyze_content()

changeset
|> Ash.Changeset.force_change_attribute(:word_frequencies, word_frequencies)
end)

require_atomic? false
change transition_state(:analyzed)
end
update :analyze do
change before_transaction(fn changeset, _ctx ->
word_frequencies = changeset
|> Ash.Changeset.get_attribute(:srt_content)
|> Katarineko.Learning.ContentAnalyzer.analyze_content()

changeset
|> Ash.Changeset.force_change_attribute(:word_frequencies, word_frequencies)
end)

require_atomic? false
change transition_state(:analyzed)
end
Chaz Watkins
Chaz Watkins2w ago
😅

Did you find this page helpful?