How to handle pattern matching inside enumerators

There are complex multi-phase scenarios when you need to create / update a record in your DB after an external API request succeeds or fails – think Stripe payment for example (in your case it might be to fire up search indexing after successfully inserting all records). In that case you absolutely would want to do something like this:

# ...your Ecto.Multi here...
|> Multi.run(:api_payment_intent, &capture_stripe_payment_intent/1)
|> Multi.update(:db_payment_intent, attrs_or_record)
|> # ...go further...

In this example, there might be further operations down the pipeline (like failing to actually do the final payment) so we might need to rollback the changes to the payment intent which e.g. might have had a state of :pending, we updated it to :captured but since a down-stream operation has failed we want to roll it back to :pending again. In this case, using a generic key and not :db_payment_intent might lose you the record that you want to rollback. (Imagine you used :payment_intent for both operations.)

Though that’s kind of a moot point in this particular example because putting the whole thing in a Repo.transaction will do the rollback anyway BUT you might want to inform your event system (Sentry, AppSignal etc.) that things almost went through but this or that failed at the end and you also might want to show extra details like the exact fields of the payment intent struct. Or you might want to send metrics for your marketing team where they’ll see something like “one more sale where the user bailed at the end” or some such.

Basically: using unique keys in Ecto.Multi operations is useful for having “changes so far” data when something fails and you might want to act on some of those changes [that have succeeded so far].

Sorry I can’t be more specific, just started my work for the day and my head isn’t entirely here. But that’s what I managed to cook up and reminisce from previous projects.

This is honestly a non-issue and I feel it’s over-preparing for doomsday scenarios that won’t ever happen.

If you need to get rid of Ecto you’ll have a plethora of other technical problems to solve. Abstracting away your multi-phase operations away from Ecto will be one of the smaller problems to tackle.

Thank you for your thorough example. Why not add metrics/logging between each call to solve issues you mentioned?

For example:

with {:ok, :payment} <- create_pending_payment(...),
       {:ok, transaction} <- create_stripe_transaction(payment),
       Sentry.log("transaction #{transaction.id} status was #{transaction.status}") # pseudo-code here,
       Payment.update(payment, %{status: :settled} # returns {:ok, payment}
end

As I understand the difference compared to your example here is the fact that the logging will be in one abstract place someplace above in the stack compared to my example, but then again my example is more flexible.

But I guess that in my particular case I would handle the situation like this above in some Phoenix action:

result = Repo.in_transaction(fn ->
  with  {:ok, :payment} <- create_pending_payment(...),
          {:ok, payment} <- create_stripe_transaction(payment),
          Payment.update(payment, %{status: :settled} # returns {:ok, payment}
end)

case result do
  {:ok, _whatever} -> result
  other -> Sentry.log("Payment failed with #{inspect(other)}")
end

Of course all this with statement can be extracted away into some service or what-not.

But yeah, I understand that there might be definitely some edge-case where fine-grained control might be necessary as stated by you and for these cases nothing prevents to expose Ecto.Multi API in that particular module/function.

Agree that this thing might not ever happen, but I still prefer reading mostly about business logic code and not about some framework/library code specific code. Introducing Ecto.Multi statements into your business logic makes the code less readable since you always have this overhead of Ecto.Multi, which does not add any extra value to the business logic itself and is just an extra package, which should be hidden away to some lower level just with any other framework/library (or even your own modules).

I went with this solution in the end and removed try/rescue from in_transaction/1. It looks pretty okay in the end. Thank you for suggesting this.

1 Like