defmodule DoubleDown.Contract.Dispatch do @moduledoc """ Dispatch resolution for DoubleDown contracts. Three dispatch paths are available, selected at compile time by the `:test_dispatch?` and `:static_dispatch?` options on `use DoubleDown.ContractFacade`: ### `call/4` — test-aware dispatch (default in non-prod) 1. **Test handler** — process-scoped via `NimbleOwnership` (only checked if the ownership server is running) 2. **Application config** — `Application.get_env(otp_app, contract)[:impl]` 3. **Raise** — no handler configured ### `call_config/4` — config-only dispatch 1. **Application config** — `Application.get_env(otp_app, contract)[:impl]` 2. **Raise** — no handler configured No `NimbleOwnership` code is referenced in the generated facade functions, eliminating the `GenServer.whereis` lookup entirely. ### Static dispatch (default in prod when config available) The implementation module is resolved at compile time and the generated facade calls it directly — no `NimbleOwnership`, no `Application.get_env`. Zero dispatch overhead. Falls back to `call_config/4` if the config is not available at compile time. """ @ownership_server __MODULE__.Ownership @doc """ Dispatch a port operation to the resolved implementation. Called by generated facade functions when `test_dispatch?: true` (the default in non-production environments). Checks for a process-scoped test handler via `NimbleOwnership` before falling back to application config. """ @spec call(atom() | nil, module(), atom(), [term()]) :: term() def call(otp_app, contract, operation, args) do case resolve_test_handler(contract) do {:ok, owner_pid, handler} -> result = invoke_handler(handler, owner_pid, contract, operation, args) maybe_log(owner_pid, contract, operation, args, result) result :none -> invoke_from_config(otp_app, contract, operation, args) end end @doc """ Dispatch a port operation directly from application config. Called by generated facade functions when `test_dispatch?: false` (the default in production). Skips the `NimbleOwnership` test handler lookup entirely — zero overhead beyond `Application.get_env`. """ @spec call_config(atom() | nil, module(), atom(), [term()]) :: term() def call_config(otp_app, contract, operation, args) do invoke_from_config(otp_app, contract, operation, args) end @doc """ Build a canonical key for test stub matching. Keys are normalized so that map/keyword argument order doesn't affect matching. """ @spec key(module(), atom(), [term()]) :: term() def key(contract, operation, args) do {contract, operation, normalize_args(args)} end @doc """ Read the current stateful handler state for a contract. Returns the domain state — for Double-managed handlers this is the `fallback_state` field; for raw stateful handlers this is the entire state value. Used to snapshot state before a transaction. """ @spec get_state(module()) :: term() def get_state(contract) do state_key = Module.concat(DoubleDown.State, contract) case NimbleOwnership.get_owned(@ownership_server, self()) do %{^state_key => %{fallback_state: fallback_state}} -> fallback_state %{^state_key => state} -> state _ -> nil end end @doc """ Restore a single contract's stateful handler state. Replaces the state for the given contract in NimbleOwnership, leaving the handler function and all other contracts' state untouched. Used by transaction rollback to restore the pre- transaction snapshot. Handles both Double-managed handlers (restores the `fallback_state` field within the Double's internal map) and raw stateful handlers installed via `set_stateful_handler` (replaces the entire state). """ @spec restore_state(module(), pid(), term()) :: :ok def restore_state(contract, owner_pid, snapshot) do state_key = Module.concat(DoubleDown.State, contract) NimbleOwnership.get_and_update(@ownership_server, owner_pid, state_key, fn state -> new_state = case state do %{fallback_state: _} -> # Double-managed: restore only the fallback_state %{state | fallback_state: snapshot} _ -> # Raw stateful handler: replace entire state snapshot end {:ok, new_state} end) :ok end # -- Test handler resolution -- @doc false # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity def resolve_test_handler(contract) do case GenServer.whereis(@ownership_server) do nil -> :none _pid -> callers = [self() | Process.get(:"$callers", [])] case NimbleOwnership.fetch_owner(@ownership_server, callers, contract) do {:ok, owner_pid} -> case NimbleOwnership.get_owned(@ownership_server, owner_pid) do %{^contract => handler_meta} -> {:ok, owner_pid, handler_meta} _ -> :none end {:shared_owner, owner_pid} -> case NimbleOwnership.get_owned(@ownership_server, owner_pid) do %{^contract => handler_meta} -> {:ok, owner_pid, handler_meta} _ -> :none end :error -> :none end end end # -- Handler invocation -- @doc false def invoke_handler(%{type: :module, impl: impl}, _owner_pid, _contract, operation, args) do case apply(impl, operation, args) do %DoubleDown.Contract.Dispatch.Defer{fn: deferred_fn} -> deferred_fn.() result -> result end end def invoke_handler(%{type: :fn, fun: fun}, _owner_pid, _contract, operation, args) do case fun.(operation, args) do %DoubleDown.Contract.Dispatch.Defer{fn: deferred_fn} -> deferred_fn.() result -> result end end def invoke_handler( %{type: :stateful, fun: fun, state_key: state_key}, owner_pid, contract, operation, args ) do # Atomically read state, call handler, update state. # Must use owner_pid so allowed child processes can update state. # # If the handler returns %DoubleDown.Contract.Dispatch.Defer{fn: deferred_fn}, we skip # the state update and call deferred_fn outside the lock. This supports # operations like `transact` whose body re-enters the dispatch system # (which would otherwise deadlock on the NimbleOwnership GenServer). # # IMPORTANT: The handler function runs inside NimbleOwnership.get_and_update, # which executes in the NimbleOwnership GenServer's handle_call. If the # handler raises (e.g. a module fallback hits a dead Ecto sandbox connection # during test teardown), it would crash the GenServer — a named singleton # that lives for the entire test run. We rescue any exception and wrap it # in a %Defer{} so it re-raises in the calling process (outside the lock), # where ExUnit can handle it normally. # # 5-arity handlers receive a read-only snapshot of all contract states # as the 5th argument. This is fetched before entering get_and_update # to avoid re-entrant GenServer calls. all_states = if is_function(fun, 5) do build_global_state(owner_pid) else nil end {:ok, result} = NimbleOwnership.get_and_update(@ownership_server, owner_pid, state_key, fn state -> try do handler_result = if all_states do fun.(contract, operation, args, state, all_states) else fun.(contract, operation, args, state) end case handler_result do {%DoubleDown.Contract.Dispatch.Defer{} = defer, new_state} -> validate_not_global_state!(new_state) {defer, new_state} {result, new_state} -> validate_not_global_state!(new_state) {result, new_state} end rescue exception -> stacktrace = __STACKTRACE__ {%DoubleDown.Contract.Dispatch.Defer{fn: fn -> reraise exception, stacktrace end}, state} catch :throw, value -> {%DoubleDown.Contract.Dispatch.Defer{fn: fn -> throw(value) end}, state} :exit, reason -> {%DoubleDown.Contract.Dispatch.Defer{fn: fn -> exit(reason) end}, state} end end) case result do %DoubleDown.Contract.Dispatch.Defer{fn: deferred_fn} -> deferred_fn.() result -> result end end # -- Global state for 4-arity handlers -- @global_state_sentinel DoubleDown.Contract.GlobalState # Build a read-only snapshot of all contract states for the given owner. # Keyed by contract module, with a sentinel key to detect accidental return. # Internal keys (handler metadata, state refs, log keys) are filtered out. defp build_global_state(owner_pid) do owned = NimbleOwnership.get_owned(@ownership_server, owner_pid) # Find all stateful handlers and map contract => state. # Seed with sentinel key so accidental return of global map is detectable. # # When a contract is managed by DoubleDown.Double, the NimbleOwnership # state is the Double's internal wrapper (%{expects:, stubs:, fallback:, # fallback_state:, ...}). For 4-arity handlers, we unwrap this and # expose the fallback_state — the user's actual domain state. owned |> Enum.reduce(%{@global_state_sentinel => true}, fn {contract, %{type: :stateful, state_key: state_key}}, acc -> case Map.get(owned, state_key) do nil -> acc %{fallback_state: fallback_state} -> # Double-managed: expose the user's fallback state Map.put(acc, contract, fallback_state) state -> # Direct set_stateful_handler: expose raw state Map.put(acc, contract, state) end _, acc -> acc end) end # Raise if the handler accidentally returned the global state map. defp validate_not_global_state!(new_state) when is_map(new_state) do if Map.has_key?(new_state, @global_state_sentinel) do raise ArgumentError, """ Stateful handler returned the global state map instead of its own contract state. A 4-arity handler receives (operation, args, contract_state, all_states). The return value must be {result, new_contract_state} — not {result, all_states}. """ end end defp validate_not_global_state!(_new_state), do: :ok # -- Dispatch logging -- @doc false def maybe_log(owner_pid, contract, operation, args, result) do log_key = Module.concat(DoubleDown.Log, contract) # Only log if the owner has logging enabled (owns the log key). # get_owned returns all keys owned by this pid — check if log_key is present. case NimbleOwnership.get_owned(@ownership_server, owner_pid) do %{^log_key => _} -> NimbleOwnership.get_and_update(@ownership_server, owner_pid, log_key, fn log -> {:ok, [{contract, operation, args, result} | log]} end) _ -> :ok end end # -- Config resolution -- defp resolve_impl(otp_app, contract) do case Application.get_env(otp_app, contract) do nil -> :error config when is_list(config) -> case Keyword.get(config, :impl) do nil -> :error impl -> {:ok, impl} end impl when is_atom(impl) -> {:ok, impl} end end defp invoke_from_config(otp_app, contract, operation, args) do case resolve_impl(otp_app, contract) do {:ok, impl} -> apply(impl, operation, args) :error -> raise_no_impl(otp_app, contract) end end defp raise_no_impl(otp_app, contract) do if testing?() do raise """ No test handler set for #{inspect(contract)}. In your test setup, call one of: DoubleDown.Testing.set_handler(#{inspect(contract)}, MyImpl) DoubleDown.Testing.set_fn_handler(#{inspect(contract)}, fn operation, args -> ... end) DoubleDown.Testing.set_stateful_handler(#{inspect(contract)}, handler_fn, initial_state) If you want to use the production implementation in this test: DoubleDown.Testing.set_handler(#{inspect(contract)}, MyProductionImpl) """ else config_example = if otp_app do "config #{inspect(otp_app)}, #{inspect(contract)}, impl: MyImpl" else ~s'use DoubleDown.ContractFacade, contract: #{inspect(contract)}, otp_app: :my_app\n' <> " then: config :my_app, #{inspect(contract)}, impl: MyImpl" end raise """ No implementation configured for #{inspect(contract)}. Add to your config: #{config_example} """ end end defp testing? do GenServer.whereis(@ownership_server) != nil end # -- Key normalization -- defp normalize_args(args) do Enum.map(args, &normalize_arg/1) end defp normalize_arg(arg) when is_map(arg) and not is_struct(arg) do arg |> Enum.sort() |> Enum.map(fn {k, v} -> {k, normalize_arg(v)} end) end defp normalize_arg(arg) when is_list(arg) do if Keyword.keyword?(arg) do arg |> Enum.sort() |> Enum.map(fn {k, v} -> {k, normalize_arg(v)} end) else Enum.map(arg, &normalize_arg/1) end end defp normalize_arg(arg) when is_tuple(arg) do arg |> Tuple.to_list() |> Enum.map(&normalize_arg/1) |> List.to_tuple() end defp normalize_arg(arg), do: arg end