Skip to main content

lib/skuld/effects/transaction/ecto.ex

# 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