CargoSense/fable

Return errors when an event cannot be emitted

benwilson512 opened this issue · 7 comments

Hey @LostKobrakai, shamefully I'm only now upgrading our main app to take advantage of some of the changes you made last year. In the transition from Repo.serial to Events.emit I'm finding it difficult to return errors to the user, and I'm curious what you're doing.

For example today I have code like this:

def edit_comment(shipment, comment_data) do
  Repo.serial(shipment, fn shipment ->
    if comment = find_comment(comment_data) do
       Events.emit(shipment, %CommentEdited{...}) # etc
    else
      {:error, "comment not found"}
    end
  end)
end

It isn't clear how I can get the same functionality anymore. Specifically I need two things:
A) Blocking serialization around the shipment for the entire duration of the logic flow so that I don't need to worry about serialization errors on commit
B) The ability to return non event types

The api around Events.emit is slick, but as far as I can tell, the return type of the function you pass to Events.emit/3 is event.t | [event.t]. so it isn't a drop in replacement for Repo.serial. I could do:

def edit_comment(shipment, comment_data) do
  if comment = find_comment(comment_data) do
     Events.emit(shipment, fn shipment -> %CommentEdited{...} end) # etc
  else
    {:error, "comment not found"}
  end
end

However if I do this I've lost serialization guarantees.

Obviously I can add a Repo.serial back to my own Repo module, but I'm struggling to see how Fable can be used properly without it.

Notably, this isn't mitigated by the use of Multi, the same issues persist.

def edit_comment(shipment, comment_data) do
  shipment
  |> Events.emit(fn shipment, repo, _ -> 
    if comment = find_comment(comment_data) do
       %CommentEdited{...} 
   else
      repo.rollback("comment not found")
    end
  end)
  |> Repo.transaction()
end

Sorry for the briefness. Will comment again later today.

To explain it: Events.emit does wrap the function you're providing into a function executing the fable specific stuff as well. This wrapping function is then returned and executed by Repo.transaction. So everything within the provided function is executed within a transaction and you have all the error handling capabilities of Repo.transaction(fun).

Gotcha, that makes sense. I wonder if we could support a return type from the fun that is event | [event] | {:error, reason} and then repo.rollback(reason) within the def emit function.

Our code style uses with quite a lot eg:

with {:ok, comment} <- fetch_comment(shipment, args),
:authorized <- validate_rights(user, comment, :edit) do
  %Event{}
end

and adding a boilerplate else \n {:error, reason} -> Repo.rollback(reason) to all of these is going to be annoying. Thoughts?

It could be done (added here). I don't really like mixing tagged and untagged return values, which is why I didn't do so.

Agreed, I generally don't either. I just don't like repo.rollback(reason) very much because it's a non local return, and our error handling is all functional to date except for explicit ! functions that happy path or raise.

Let me take some time and use the approach you've outlined today and see how it goes.

I don't mind it much here, as it's likely to not be the last function to be called, but I see your argument. Maybe event | [event] | {:error, reason} is the best of both worlds, because I would hate to have {:ok, event | [event]} | {:error, reason}

@LostKobrakai agreed, it's not like there's any ambiguity between whether the return value is an event or error, a wrapping {:ok} tuple wouldn't really add anything. It's even clear enough for dialyzer to tell the difference since the options structs are always the success case, and an error tuple is always the error case, and everything else is a fail.