Confused by Ecto.Multi Error

Hi everyone, I’m running into this error I absolutely can’t make sense of while working with Ecto.Multi (I’m relatively new but feel like I have a decent grasp) and so far Google searches haven’t yielded anything.

Here is the error:

expected Ecto.Multi callback named `:relationship` to return either {:ok, value} or {:error, value}, got: {:error, :relationship, "not found", %{}}

Now I am testing a failure case, but I am expecting an error to get passed through so that the view can display an error instead of an exception. The weird thing is I have inspected the value returned from the anonymous function in and the value is {:error, :not_found} which based on this error message I think should be fine?

Here is the function that is running into this error:

def update_relationship(%SocialUser{} = user_a, %SocialUser{} = user_b, %{} = info_attrs, with_changeset) do
    |>, fn _repo, _changes -> get_relationship(user_a, user_b) end)
    |> Multi.update(:update, fn %{relationship: %{info: info}} -> with_changeset.(info, info_attrs) end)
    |> Repo.transaction()

Here is the code for get_relationship/2:

  def get_relationship(%SocialUser{} = user_a, %SocialUser{} = user_b) do
    Repo.get_one(from r in Relationship, where: r.user_a_id == ^ and r.user_b_id == ^, preload: [:info])

And finally Repo.get_one is a function I’ve written to make it easier to work with in situations like this where you need a tuple:

  def get_one(query) do
     case one(query) do
       nil -> { :error, :not_found }
       row -> { :ok, row }

Am I doing something stupid? I can’t for the life of me figure out where this transformation of {:error, :not_found} to {:error, :relationship, "not found", %{}} is happening, as far as I can tell I’m following what the documentation says

Hello and Welcome !

Ecto.Multi can return two different tuple:

  • {:ok, %{relationship: %{}, update: %{}}} where the second argument is a map of each part of your Multi, named after what name you gave them (so :relationship and :update).
  • {:error, :relationship, "not found", %{}} where the second argument is the Multi call who failed (in your case :relationship), the second would be the failed value and the last one would contain the changes that have been successfully done before the failed attempt (but reverted by the transaction if it was a database call), so empty in your case since it’s the first call that failed. That allow you to know exactly where it failed.

It’s specified in two places that i know of in the documentation:
The Repo.transaction() function ->
Examples on the Multi doc ->


Hi shad, thanks for the warm welcome

I am aware of the possible return values for Ecto.Multi calls, I’ve used it in a couple other places in my project. Maybe I’m incorrect but my understanding was that if one operation fails in a chain, it should not call the future operations and just return the error. It’s not that I’m failing to pattern match on the result of the Ecto.Multi call outside of this function, it’s that (according to the error message) I’m not returning a correct value from the anonymous function in :relationship, even though I am?

In fact I have a very similar usage here where an error occurring in :receiver is being passed through to the caller correctly:

  def send_request(%SocialUser{} = user_a, user_b_username) do
    |>, fn _repo, _changes -> get_by_username(user_b_username) end)
    |>, fn _repo, %{receiver: receiver} -> send_request(user_a, receiver) end)
    |> Repo.transaction()

Where get_by_username/1 is defined as:

def get_by_username(username) do
    Repo.get_one from u in Status.Identity.User,
      where: u.username == ^username,
      join: s in SocialUser,
      on: == s.user_id,
      select: s

Oh ! Completely misread the original post, sorry about that.

A provides you in the first argument with the current Repo, which i’m pretty sure is “tagged” with the current transaction. But in the underlying function, you are using the default one (probably through an alias made in the module). So if i understand correctly (someone will correct me if i’m not :blush:), you’re basically getting out of the transaction, and calling a database query outside of it. Could you try to use the actual repo being provided and see if that fixes the issue ?

No worries.

That’s a good idea, but doesn’t seem to be the issue in this case (although I do wonder if by not using that there are other bugs that can occur, something to look into).

Curiously, when I change update_relationship/4 to the following:
    |>, fn _repo, _changes -> {:error, :not_found} end)
    |> Repo.transaction()

I get this error:

expected Ecto.Multi callback named `:relationship` to return either {:ok, value} or {:error, value}, got: {:error, :relationship_a, :not_found, %{}}

Notice how it’s saying that the "Ecto.Multi callback named :relationship" even though I changed the name of the operation!

I totally forgot that this whole thing was a part of another Ecto.Multi transaction higher up the stack (and it had the same :relationship name, thanks past me), and the stacktrace isn’t very helpful for Ecto as I think everything is obscured by macros.

So it makes total sense, the inner transaction was failing and then producing a normal Ecto.Multi/Repo.transaction error, but the parent Multi expects just {:error value}

There is probably a better way to do this, but the minimal way to fix this is to not use a transaction in the child multi and just return the multi, and then call Multi.merge in the parent:

  def update_relationship(%SocialUser{} = user_a, %SocialUser{} = user_b, %{} = info_attrs, with_changeset) do
    |>, fn _repo, _changes -> get_relationship(user_a, user_b) end)
    |> Multi.update(:update, fn %{relationship: %{info: info}} -> with_changeset.(info, info_attrs) end)
  def accept_request(%SocialUser{} = user_a, user_b_username) do
    |>, fn _repo, _changes -> get_by_username(user_b_username) end)
    |> Multi.merge(fn %{receiver: receiver} -> accept_request(user_a, receiver) end) # basically calls update_relationship/4
    # |>, fn _repo, %{receiver: receiver} -> accept_request(user_a, receiver) end)
    |> Repo.transaction()

Well, for sure that make better sense.

If you don’t use the provided repo, the side-effect you could get would be that an inner query failing would not trigger the proper rollback on the previous queries, since the other inner queries would have done their jobs successfully, but inside their own different transactions. That would be problematic for your data integrity (assuming your making a query that changes something).

Can you try by both having a different name on each operations and using the provided repo instead of the one you import ? I’m using a lot of Ecto.Multi in my apps, and following that pattern never failed me, so i’m curious if that’s really the issue, or if something else is happening.

It does seem to be wrapped in a transaction even without passing the repo; as in get_relationship/2 if I do IO.inspect Repo.in_transaction? it prints true.

if I modify get_relationship to take a repo like this:

  def get_relationship(%SocialUser{} = user_a, %SocialUser{} = user_b, opts \\ []) do
    repo = Keyword.merge([repo: Repo], opts)
    |> Keyword.get(:repo)
    IO.inspect repo.in_transaction?
    repo.get_one(from r in Relationship, where: r.user_a_id == ^ and r.user_b_id == ^, preload: [:info])

The resulting logs are the same, although I haven’t tried this yet with an operation that actually modifies the DB.

That said, I’m sure it passes the Repo for a reason and that I should be using it, since it sounds like you have used this in the past, do you have any good patterns for not littering every function with keyword list arguments? Maybe just having a function that returns a query and it’s up to the caller to actually run it inside of a Repo?

The value passed in the first argument of an callback is the name of the repo - if you want your multi to be 100% reusable you’d use it as:, :some_operation, fn repo, changes ->

The transaction is bound to the process, the value passed in repo is a plain ol’ atom.

Good to know for the transaction. The documentation isn’t really clear about this then, but should have gone to the code directly to make sure. Thanks :wink:

I don’t fully understand @ericgroom issue then. I’ve been using stuff similar to him without issue. Must be missing something. For avoiding littering my functions when i need to reuse them, i do what you said eric. I have a function who builds the query and returns it (usually, i go further and split it in multiple part to allow filtering, sorting…conditionally), and another one who’s job is to execute it, thus allowing to reuse it when needed.

Probably your function update_relationship is called from within another transaction. This causes an error because your function returns a 4-tuple instead of the expected 2-tuple.

1 Like

Well if the transaction is bound to the current process has @al2o3cr said, that shouldn’t impact and the transaction should be the same, unless spawning a different process to do the other Multi. Or i am misunderstanding something ?

:thinking: I’m just thinking loud and may be completely wrong but I would pass the Ecto.Multi -instance down to the different functions instead of recreating it there.

I think this would be equivalent. Either way it seems the function has to know that it is being called from another Multi, either by not performing a transaction and returning the multi, or adding onto a multi. The one pitfall is if you call transaction in the parent and child function, you will run into the same error.

I’m going to try having my contexts not make database calls at all and just be prewritten queries as seemingly this is the only way to truly be able to compose Multis cleanly. I think it makes sense because already the caller knows you are using a multi due to the 4-tuple error, and it allows the caller to decide whether they want,!, Repo.all, etc. This does mean having Repo calls in a controller, or a very thin context on top, but I think this way is more composable than what Phoenix suggests with a generated context.

Granted I haven’t tried this yet so curious to hear what others do.