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.

First 8 of 8 Posts Switch mode

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

stjefim
Hello! Suppose you are building workflow (order / task / payment) processing system with the following requirements: Each workflow con...
New
jonnycharles
I’m in search of an Elixir library that offers PDF generation capabilities similar to Ruby’s Prawn. While there have been discussions abo...
New
spammy
I’m looking to build a personal workflow to quickly deploy web applications written in elixir/phoenix, for local consumption (ie not on t...
New
dli
Before I dive in myself, did anyone successfully sprinkle Hologram into their existing LiveView app? Looking for hints regarding: Addi...
New
roeland
Kia ora, We have been using elixir-google-api to connect to Google Drive. However, with the updates to Tesla due to CVEs this is now bro...
New
bottlenecked
Hi all, I wanted to ask how the community is dealing with post-release steps. Today we have Ecto migrations, which make sure that the db...
New
rahultumpala
Hello, I have an Elixir backend that implements a custom protocol over TCP. I want to load test the backend and assess the performance o...
New

Other Trending Topics Top

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
jimsynz
Beam Bots (or just BB for short) is a framework for building fault-tolerant robotics applications in Elixir using familiar OTP patterns. ...
New
Damirados
Hello everyone. After busy few months I am happy to announce v0.1.0 of Emerge & Solve. They are GUI (Emerge) and State management (S...
New
netoum
Corex is an accessible, unstyled UI component library for Phoenix that integrates Zag.js state machines using Vanilla JavaScript and Live...
New
ausimian
Emily is an Elixir library that runs Nx computations on Apple’s MLX. Install it as the default Nx backend and Nx, defn, Axon, Nx.Serving,...
New
juhalehtonen
There has been a thread to discuss the Stack Overflow Developer Survey on this forum every year since 2018, so here’s yet another one for...
New

We're in Beta

About us Mission Statement