# Ecto transaction handler for the Transaction effect.
#
# Wraps computations in real database transactions via an Ecto Repo,
# with env state rollback on rollback/sentinel and savepoints for
# nested transactions.
#
# ## Usage
#
# computation
# |> Transaction.Ecto.with_handler(MyApp.Repo)
# |> Comp.run!()
#
# ## Options
#
# All options except `:preserve_state_on_rollback` are passed through to
# `Repo.transaction/2` (e.g. `:timeout`, `:isolation`).
if Code.ensure_loaded?(Ecto) do
defmodule Skuld.Effects.Transaction.Ecto do
@moduledoc false
@behaviour Skuld.Comp.IHandle
@behaviour Skuld.Comp.IInstall
alias Skuld.Comp
alias Skuld.Comp.Env
alias Skuld.Comp.ISentinel
alias Skuld.Comp.Types
alias Skuld.Effects.Transaction
@state_key "Elixir.Skuld.Effects.Transaction.Ecto::config"
#############################################################################
## Handler Installation
#############################################################################
@doc """
Install a scoped Transaction Ecto handler for a computation.
Handles `transact` and `rollback` operations using real Ecto transactions.
## Options
All options except `:preserve_state_on_rollback` are passed through to
`Repo.transaction/2` (e.g. `:timeout`, `:isolation`).
* `:preserve_state_on_rollback` - list of `env.state` keys whose values
should be kept from the post-transaction env even when the transaction
rolls back. By default, **all** env state accumulated inside a rolled-back
transaction is discarded (restored to pre-transaction values). Use this
to opt specific effects out of rollback — e.g. error counters or metrics
that should survive.
## Example
computation
|> Transaction.Ecto.with_handler(MyApp.Repo)
|> Comp.run!()
# With transaction options
computation
|> Transaction.Ecto.with_handler(MyApp.Repo, timeout: 15_000)
|> Comp.run!()
# Preserve specific state keys on rollback
computation
|> Transaction.Ecto.with_handler(MyApp.Repo,
preserve_state_on_rollback: [Writer.state_key(:metrics)]
)
|> Comp.run!()
"""
@spec with_handler(Types.computation(), module(), keyword()) :: Types.computation()
def with_handler(comp, repo, opts \\ []) do
{preserve_keys, ecto_opts} = Keyword.pop(opts, :preserve_state_on_rollback, [])
config = %{
repo: repo,
ecto_opts: ecto_opts,
preserve_state_on_rollback: preserve_keys
}
comp
|> Comp.scoped(fn env ->
previous = Env.get_state(env, @state_key)
modified = Env.put_state(env, @state_key, config)
finally_k = fn v, e ->
restored_env =
case previous do
nil -> %{e | state: Map.delete(e.state, @state_key)}
val -> Env.put_state(e, @state_key, val)
end
{v, restored_env}
end
{modified, finally_k}
end)
|> Comp.with_handler(Transaction.sig(), &__MODULE__.handle/3)
end
@doc """
Install Ecto handler via catch clause syntax.
catch
Transaction.Ecto -> MyApp.Repo
Transaction.Ecto -> {MyApp.Repo, timeout: 5000}
Transaction.Ecto -> {MyApp.Repo, preserve_state_on_rollback: [key]}
"""
@impl Skuld.Comp.IInstall
def __handle__(comp, {repo, opts}) when is_atom(repo) and is_list(opts) do
with_handler(comp, repo, opts)
end
def __handle__(comp, repo) when is_atom(repo) do
with_handler(comp, repo, [])
end
#############################################################################
## IHandle
#############################################################################
@transact_op Transaction.transact_op()
@rollback_op Transaction.rollback_op()
@impl Skuld.Comp.IHandle
def handle({@transact_op, inner_comp}, env, k) do
config = get_config!(env)
handle_transact(inner_comp, config, env, k)
end
@impl Skuld.Comp.IHandle
def handle({@rollback_op, _reason}, _env, _k) do
raise ArgumentError, """
Transaction.rollback/1 called outside of a transaction.
rollback/1 must be called within a Transaction.transact/1 block:
comp do
result <- Transaction.transact(comp do
# ... do work ...
_ <- Transaction.rollback(:some_reason)
end)
result
end
"""
end
# Catch-all for unknown ops
def handle(op, _env, _k) do
raise ArgumentError,
"Transaction.Ecto handler received an unknown operation: #{inspect(op)}"
end
#############################################################################
## Private Helpers
#############################################################################
defp get_config!(env) do
Env.get_state!(env, @state_key)
end
# Marker struct for explicit rollback (not a sentinel — just a result value
# that execute_in_transaction detects to trigger repo.rollback)
defmodule RollbackMarker do
@moduledoc false
defstruct [:reason]
end
# Handle the transact operation — run inner comp in Ecto transaction
defp handle_transact(inner_comp, config, env, k) do
%{repo: repo, ecto_opts: ecto_opts} = config
result =
repo.transaction(
fn -> execute_in_transaction(inner_comp, config, env) end,
ecto_opts
)
handle_result(result, config, env, k)
end
# Run the computation inside the Ecto transaction
defp execute_in_transaction(comp, config, env) do
%{repo: repo} = config
# Install a handler that overrides rollback and nested transact
wrapped =
comp
|> Comp.with_handler(Transaction.sig(), fn
{@rollback_op, reason}, e, _inner_k ->
# Return a marker instead of calling repo.rollback directly,
# because call_handler's catch clause would intercept the throw.
# execute_in_transaction will detect this marker and call repo.rollback.
{%RollbackMarker{reason: reason}, e}
{@transact_op, nested_comp}, e, nested_k ->
# Nested transact creates a savepoint via Ecto.
handle_transact(nested_comp, config, e, nested_k)
op, e, inner_k ->
# Unknown operations — delegate to outer handler (shouldn't happen
# since Transaction only has transact and rollback)
handle(op, e, inner_k)
end)
# Run the computation with identity continuation
{result, final_env} = Comp.call(wrapped, env, &Comp.identity_k/2)
# Check what happened
case result do
%RollbackMarker{reason: reason} ->
# Explicit rollback — exit the transaction
repo.rollback({:rollback, reason, final_env})
result ->
if ISentinel.sentinel?(result) do
# Sentinel (Throw, Suspend, etc.) — rollback and propagate
repo.rollback({:sentinel, result, final_env})
else
{:ok, result, final_env}
end
end
end
# Restore env state to pre-transaction values on rollback, keeping only
# the preserve_state_on_rollback keys from the post-transaction env.
defp restore_state_on_rollback(pre_tx_env, final_env, preserve_keys) do
preserved_state =
Enum.reduce(preserve_keys, %{}, fn key, acc ->
case Map.fetch(final_env.state, key) do
{:ok, val} -> Map.put(acc, key, val)
:error -> acc
end
end)
%{pre_tx_env | state: Map.merge(pre_tx_env.state, preserved_state)}
end
# Handle the transaction result
defp handle_result({:ok, {:ok, value, final_env}}, _config, _env, k) do
# Transaction committed successfully — use post-transaction env as-is
k.(value, final_env)
end
defp handle_result(
{:error, {:sentinel, sentinel, final_env}},
config,
env,
_k
) do
# Sentinel (Throw, Suspend, etc.) — transaction rolled back.
# Restore pre-transaction env state (except preserved keys).
restored_env =
restore_state_on_rollback(env, final_env, config.preserve_state_on_rollback)
{sentinel, restored_env}
end
defp handle_result(
{:error, {:rollback, reason, final_env}},
config,
env,
k
) do
# Explicit rollback — restore pre-transaction env state (except preserved keys).
restored_env =
restore_state_on_rollback(env, final_env, config.preserve_state_on_rollback)
k.({:rolled_back, reason}, restored_env)
end
end
end