AshAdmin and catching illegal state transitions with after_transaction?

I’ve been trying to set up a resource with AshStateMachine and testing it out in AshAdmin.

If anyone has any idea about these questions I would be most grateful.

  • Is the after_transaction change in the basic state machine example in the documentation functional?
  • How is AshAdmin supposed to handle errors in the case of illegal state changes anyway?

Regarding admin I merely se the name of the resource shift a little as the empty error message tags are patched in place.

Steps to reproduce

The code, without any Phoenix or admin however, is available in this repository.

Get dependencies and fire up an IEX session.

mix deps.get
iex -S mix

Create a ticket.

iex(1)> ticket = Statepoc.Support.Ticket |> Ash.Changeset.for_create(:create) |> Statepoc.Support.create!()
#Statepoc.Support.Ticket<
  __meta__: #Ecto.Schema.Metadata<:loaded>,
  id: "61868f6d-1dc0-42d5-92c4-01eee81d6d29",
  error: nil,
  error_state: nil,
  state: :pending,
  aggregates: %{},
  calculations: %{},
  ...
>

Now the Ticket resource has a change that will hook in to after_transaction/1 and pass through any successful updates but set the attributes :errorand :error_state if receiving changeset, {:error, error}.

Both paths call IO.inspect/2.

changes do
  change after_transaction(fn
            changeset, {:ok, result} ->
              IO.inspect("Got {:ok, result}", label: "after_transaction")
              {:ok, result}

            changeset, {:error, error} ->
              message = Exception.message(error)
              IO.inspect(message, label: "after_transaction")

              changeset.data
              |> Ash.Changeset.for_update(:error, %{
                error: message,
                error_state: changeset.data.state
              })
              |> Statepoc.Support.update()
          end),
          on: [:update]
end

Now trigger a legal state change and see the IO.inspect/2 output.

iex(2)> ticket = ticket |> Ash.Changeset.for_update(:confirm) |> Statepoc.Support.update!()
after_transaction: "Got {:ok, result}"
#Statepoc.Support.Ticket<
  __meta__: #Ecto.Schema.Metadata<:loaded>,
  id: "61868f6d-1dc0-42d5-92c4-01eee81d6d29",
  error: nil,
  error_state: nil,
  state: :confirmed,
  aggregates: %{},
  calculations: %{},
  ...
>

Great, it says Got {:ok, result} and the state attribute is updated.

Let’s trigger an illegal state change to spice things up.

iex(3)> ticket |> Ash.Changeset.for_update(:package_arrived) |> Statepoc.Support.update()
{:error,
 %Ash.Error.Invalid{
   errors: [
     %AshStateMachine.Errors.NoMatchingTransition{
       action: :package_arrived,
       target: :arrived,
       old_state: :confirmed,
       changeset: nil,
       query: nil,
       error_context: [],
       vars: [],
       path: [],
       stacktrace: #Stacktrace<>,
       class: :invalid
     }
   ],
   stacktraces?: true,
   changeset: #Ash.Changeset<
     action_type: :update,
     action: :package_arrived,
     attributes: %{},
     relationships: %{},
     errors: [
       %AshStateMachine.Errors.NoMatchingTransition{
         action: :package_arrived,
         target: :arrived,
         old_state: :confirmed,
         changeset: nil,
         query: nil,
         error_context: [],
         vars: [],
         path: [],
         stacktrace: #Stacktrace<>,
         class: :invalid
       }
     ],
     data: #Statepoc.Support.Ticket<
       __meta__: #Ecto.Schema.Metadata<:loaded>,
       id: "61868f6d-1dc0-42d5-92c4-01eee81d6d29",
       error: nil,
       error_state: nil,
       state: :confirmed,
       aggregates: %{},
       calculations: %{},
       ...
     >,
     context: %{actor: nil, authorize?: false},
     valid?: false
   >,
   query: nil,
   error_context: [nil],
   vars: [],
   path: [],
   stacktrace: #Stacktrace<>,
   class: :invalid
 }}

Great, we correctly get an error. However, the after_transaction doesn’t appear to have been triggered since no message was logged to the terminal.

Querying all Tickets also reveals that the record hasn’t been updated with the error as intended.

iex(4)> require Ash.Query
Ash.Query
iex(5)> Statepoc.Support.Ticket |> Statepoc.Support.read()
{:ok,
 [
   #Statepoc.Support.Ticket<
     __meta__: #Ecto.Schema.Metadata<:loaded>,
     id: "61868f6d-1dc0-42d5-92c4-01eee81d6d29",
     error: nil,
     error_state: nil,
     state: :confirmed,
     aggregates: %{},
     calculations: %{},
     ...
   >
 ]}

The attributes error and error_state are nil and no logging was triggered.

Do note that I think that the message field passed in the update error changeset in the documentation example should be error to match the resource attributes.

The reason that the after_action isn’t getting triggered is actually because, in a way, the action is never really being called. We’re returning early from the action with the invalid changeset. However, this to me looks like a bug since after_transaction hooks are meant to be run on both success and failure. What this means is that we need to address our create/update/destroy code that is returning early for changesets w/ errors to properly invoke the after_transaction hook.

If you have a chance, please open a bug for this on ash.

Separately, the errors not showing in AshAdmin is also, in a way, a bug. What is happening there is that we only show errors (currently) in ash_admin that implement the AshPhoenix.Form.Error protocol. That protocol exists to avoid showing sensitive errors on the client. We implement the protocol by default for common/standard errors, and then for anything out of the usual we expect users to implement it themselves (i.e to show custom errors and/or surface other kinds of errors that don’t have an obvious “externally safe” message). However, AshAdmin is an admin tool, and so we should do the following:

  1. by default, if there is an exception that isn’t implemented, we show a message like “Got N messages that could not be rendered, please check the server logs for more” and then log the errors.

  2. allow a configuration that will show external messages that defaults to off.

While this could be seen as a bug because we don’t show an error you’d expect to see, it is probably more accurate to call it a feature request. If you have a chance, please also open this as an issue on the ash_admin repo.

1 Like

Thank you very much for having a look and getting back regrading this.

Of course. I’ll make sure to do that today.

I’ll do that as well :saluting_face: