moxley

moxley

I have a nested set of action calls, like this:

Action A → Action B

When the change function of Action B adds an error to its changeset, it somehow results in an uncaught throw in the call to Action B. This means that Action A can’t act on the return value from Action B when Action B returns an error.

Here’s approximately what the Action A change function might look like:

defmodule ResourceA.ActionA do
  def change(changeset, _context) do
    Ash.Changeset.before_action(changeset, fn changeset ->
      result = ResourceB.action_b()
        
      # If action_b() adds an error to its changeset,
      # the result can't be acted upon here, because there is a throw generated internally.
        
      changeset
    end)
  end
end

Here is an example repo that demonstrates the issue: hairbnb2/test/hairbnb/cart_test.exs at main · moxley/hairbnb2 · GitHub

I’ve tried using before_action, after_action, and manual, and all three techniques have the same issue.

Showing Posts 1 to 8

zachdaniel

zachdaniel

Creator of Ash

Yeah, so any errors that happen inside of action hooks will incur a rollback (the throwing behavior you’re describing), and the primary reason for this is that this is actually a situation that can be forced by some of the errors that can come back from a data layer. So for example, if you violate a unique constraint in the db, the transaction has to be aborted (and so you’d want an after_transaction hook in place on the resource that started the transaction to handle/correct that.

If you are aware that an action you’re calling may fail in a hook, you can use the rollback_on_error?: false option when calling the action.


With all of that said, an argument could be made that that is not the best design/doesn’t follow the principle of least surprise. We could introduce a backwards compatibility config that will modify the default of that option to false instead relatively easily, which would make it such that only specific errors going wrong would rollback the transaction.

moxley

moxley OP

I didn’t know about rollback_on_error?: false. Here’s the documentation, if anyone’s curious.

The final problem I’m encountering is that when Action B fails, it has an after_transaction callback that writes the error to the database. When calling Action B directly, this works as expected. If instead I call Action A, which calls Action B, then the error written to the database gets rolled back, because the after_transaction is itself inside an Action A’s transaction, which gets rolled back (this is typical SQL behavior– not just Ash). I can pass rollback_on_error?: false in Action A’s call to Action B, but other unexpected things happen: records that should get rolled back in Action B no longer get rolled back.

I think that primarily because unexpected things will happen otherwise, the rollback_on_error?: true default is the right choice.

The fact that my application writes an error to the database may seem strange, but it’s because the execution flow is very asynchronous (User synchronous interaction with the API + Stripe WebHook callback + User WebSocket listening for updates based on the WebHook handling). The solution might be to delegate the error write to a separate process, which will be outside of the transaction of Action A and Action B.

zachdaniel

zachdaniel

Creator of Ash

Yeah, so whenever you have something like this:

Action A (transaction?: true)
  Action B (after_transaction hook)

it typically represents something you want to rethink. Most often you’d do that by handling the error in an after_transaction hook on the calling action.

moxley

moxley OP

Actually, this still isn’t working.

It’s fine that all the nested SQL transactions get rolled back when Action B returns an error. The problem is that I need the code execution to continue without a throw– just as if I were calling Action B directly. Simply nesting an action call inside another shouldn’t change the behavior of the error case. Should I attempt to catch the throw, so that execution can continue?

zachdaniel

zachdaniel

Creator of Ash

The problem is that you are in an invalid transaction. If you try to do another database operation in that rolled back transaction it will fail. So when something rolls back the transaction, it goes all the way back up to the place that started the transaction, and only that error logic gets a chance to run.

moxley

moxley OP

Understood. I’m not trying to do another database operation– not anymore. But what I want is the behavior of Action B to be the same whether it’s called directly or called from inside the transaction of Action A. Action B needs to handle errors it encounters, and it can’t do that if there is a throw coming from somewhere inside a call it’s making– a throw that only started happening when Action B was wrapped in Action A’s transaction.

What seems to work for now is to add a try+catch around the call that Action B is making. If Action B is wrapped in Action A’s transaction, the the catch will execute, and it can handle the error. If Action B is called directly, it handle the error in a more traditional way.

moxley

moxley OP

Here’s the approximate solution in Action B’s change module:

defp after_action_upsert_registration(changeset, cart, member) do
  Ash.Changeset.after_action(changeset, fn changeset, order ->
    try do
      case upsert_registration(cart, member, payment_method, transaction) do
        {:ok, _registration} ->
          {:ok, order}

        {:error, changeset} ->
          broadcast_error({:error, changeset}, cart.id)
          {:error, changeset}
      end
    catch
      error ->
        case error do
          {_module, _ref, %Ash.Changeset{} = changeset} ->
            broadcast_error({:error, changeset}, cart.id)

          _error ->
            nil
        end

        throw(error)
    end
  end)
end

defp broadcast_error({:error, changeset}, cart_id) do
  error_message = extract_error_message(changeset)

  Phoenix.PubSub.broadcast!(
    MyApp.PubSub,
    "cart_internal:#{cart_id}",
    {:order_failed_from_cart, error_message}
  )
end

zachdaniel

zachdaniel

Creator of Ash

I get what you’re saying, the fundamental issue here is that Ash doesn’t know what the wrapping transaction is going to try to do after the error occurs. The fundamental issue here is around how after_transaction hooks work. If you didn’t start the transaction, then your after_transactions won’t be the one to compensate for errors that your action returns from within the scope of what would be its own transaction. Remember that there is no such thing as a nested transaction (there are savepoints but that’s a separate conversation).

But this is the point of the rollback_on_error? option. You should be able to specify rollback_on_error?: false and then handle whatever error occurs from your action, assuming the error was not an error directly from the data layer (in which case we always roll back).

— All posts loaded —

Where Next? Top

Trending in Questions Top

katta
I having some trouble figuring out if I have set myself too strict of standards for my production server. Currently I can handle 75% of r...
New
brecabral
Documentation While reading the Scoped Routes section, I noticed that the documentation currently refers to a problem without explainin...
New
nseaSeb
Hello, I know there is an approach for handling lists that allows for optimized traversal, but I can’t recall the specific method (somet...
New
kpanic
Hi everyone, I am toying with the idea of building a “match maker” for giving personal help to people that wants to start coding. I sta...
New
velrest
So my question is quite simple and i have found no conclusive answer on forum, google or AI. Should we use :erlang.float for Integer to ...
New
asweet-confluent
I recently noticed that Elixir’s Logger defaults its primary log level to :debug when no :logger, :level application configuration is pre...
New
apz
I’m new to elixir and just tried to install the elixirLS extension for VScode(ium) and it is throwing some errors that I would like help ...
New

Other Trending Topics Top

GenericJam
Edit: 2026 May 15 - This post is archived. Mob is alive!! Main docs: mob v0.7.11 — Documentation A bit of explanation for the slightly c...
New
JesseHerrick
Hey, I’m Jesse and I’m the main contributor behind Dexter, a full-featured, lightning-fast Elixir LSP optimized for large codebases. It s...
New
mudasobwa
I am happy to introduce the very α version of the new programming language compiled to BEAM. Welcome Cure. It has literally three kille...
New
garrison
Hobbes is a low-level distributed database for the Elixir programming language. Hobbes provides a simple, safe, and scalable storage lay...
New
mhanberg
Hi everyone! The first release candidate for the Expert language server project is now available! We’ve published a press release detai...
New
budgie
A little off-topic, but I feel like people here have a good head on their shoulders. I used to be quite good at making software. Was luc...
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews