Skip to main content

lib/skuld/effects/atomic_state.ex

defmodule Skuld.Effects.AtomicState do
  @moduledoc """
  AtomicState effect - thread-safe state for concurrent contexts.

  Unlike the regular State effect which stores state in `env.state` (copied when
  forking to new processes), AtomicState uses external storage (Agent) that can
  be safely accessed from multiple processes.

  Supports both simple single-state usage and multiple independent states via tags.

  ## Handlers

  - `AtomicState.Agent` - Agent-backed handler for production (true atomic ops)
  - `AtomicState.Sync` - State-backed handler for testing (no Agent processes)

  ## Production Usage (Agent handler)

      use Skuld.Syntax
      alias Skuld.Effects.AtomicState

      comp do
        _ <- AtomicState.put(0)
        _ <- AtomicState.modify(&(&1 + 1))
        value <- AtomicState.get()
        value
      end
      |> AtomicState.Agent.with_handler(0)
      |> Comp.run!()
      #=> 1

  ## Multiple States (explicit tags)

      comp do
        _ <- AtomicState.put(:counter, 0)
        _ <- AtomicState.modify(:counter, &(&1 + 1))
        count <- AtomicState.get(:counter)

        _ <- AtomicState.put(:cache, %{})
        _ <- AtomicState.modify(:cache, &Map.put(&1, :key, "value"))
        cache <- AtomicState.get(:cache)

        {count, cache}
      end
      |> AtomicState.Agent.with_handler(0, tag: :counter)
      |> AtomicState.Agent.with_handler(%{}, tag: :cache)
      |> Comp.run!()
      #=> {1, %{key: "value"}}

  ## Compare-and-Swap (CAS)

      comp do
        _ <- AtomicState.put(10)
        result1 <- AtomicState.cas(10, 20)  # succeeds
        result2 <- AtomicState.cas(10, 30)  # fails - current is 20, not 10
        {result1, result2}
      end
      |> AtomicState.Agent.with_handler(0)
      |> Comp.run!()
      #=> {:ok, {:conflict, 20}}

  ## Testing (Sync handler)

  For testing without spinning up Agents, use the Sync handler:

      comp do
        _ <- AtomicState.put(0)
        _ <- AtomicState.modify(&(&1 + 1))
        AtomicState.get()
      end
      |> AtomicState.Sync.with_handler(0)
      |> Comp.run!()
      #=> 1

  ## Per-tag dispatch

  Each tag gets its own handler sig (a module atom) and compact operation
  tuples. The tag is encoded in the sig, not in the operation args:

  - `get`          → `Comp.effect(sig(tag), AtomicState.Get)` — bare atom
  - `put(v)`       → `Comp.effect(sig(tag), {AtomicState.Put, v})` — 2-tuple
  - `modify(f)`    → `Comp.effect(sig(tag), {AtomicState.Modify, f})`
  - `atomic_state(f)` → `Comp.effect(sig(tag), {AtomicState.AtomicState, f})`
  - `cas(e, n)`    → `Comp.effect(sig(tag), {AtomicState.Cas, e, n})` — 3-tuple
  """

  @behaviour Skuld.Comp.IInstall

  use Skuld.Comp.DefTaggedOp

  defoverridable state_key: 1

  alias Skuld.Comp.Types

  @compile {:inline, agent_key: 1, sig: 1}

  #############################################################################
  ## Operations (generated by def_tagged_op)
  #############################################################################

  def_tagged_op get()
  def_tagged_op put(value)
  def_tagged_op modify(fun)
  def_tagged_op atomic_state(fun)
  def_tagged_op cas(expected, new)

  # Op atoms as module attributes for use in sub-handlers
  @get_op @__get_op__
  @put_op @__put_op__
  @modify_op @__modify_op__
  @atomic_state_op @__atomic_state_op__
  @cas_op @__cas_op__

  #############################################################################
  ## Public accessors for sub-handlers
  #############################################################################

  @doc false
  def get_op, do: @get_op
  @doc false
  def put_op, do: @put_op
  @doc false
  def modify_op, do: @modify_op
  @doc false
  def atomic_state_op, do: @atomic_state_op
  @doc false
  def cas_op, do: @cas_op

  #############################################################################
  ## Handler Installation (Delegating)
  #############################################################################

  @doc """
  Install an Agent-backed AtomicState handler.

  Delegates to `AtomicState.Agent.with_handler/3`.
  """
  @spec with_agent_handler(Types.computation(), term(), keyword()) :: Types.computation()
  defdelegate with_agent_handler(comp, initial, opts \\ []),
    to: __MODULE__.Agent,
    as: :with_handler

  @doc """
  Install a State-backed AtomicState handler for testing.

  Delegates to `AtomicState.Sync.with_handler/3`.
  """
  @spec with_state_handler(Types.computation(), term(), keyword()) :: Types.computation()
  defdelegate with_state_handler(comp, initial, opts \\ []),
    to: __MODULE__.Sync,
    as: :with_handler

  #############################################################################
  ## IInstall Implementation
  #############################################################################

  @doc """
  Install AtomicState handler via catch clause syntax.

  Config selects handler type:

      catch
        AtomicState -> {:agent, 0}                    # agent handler
        AtomicState -> {:agent, {0, tag: :counter}}   # agent with opts
        AtomicState -> {:sync, 0}                     # sync handler
        AtomicState -> {:sync, {0, tag: :counter}}    # sync with opts
  """
  @impl Skuld.Comp.IInstall
  def __handle__(comp, {:agent, {initial, opts}}) when is_list(opts),
    do: __MODULE__.Agent.with_handler(comp, initial, opts)

  def __handle__(comp, {:agent, initial}),
    do: __MODULE__.Agent.with_handler(comp, initial)

  def __handle__(comp, {:sync, {initial, opts}}) when is_list(opts),
    do: __MODULE__.Sync.with_handler(comp, initial, opts)

  def __handle__(comp, {:sync, initial}),
    do: __MODULE__.Sync.with_handler(comp, initial)

  #############################################################################
  ## Key Helpers
  #############################################################################

  @doc """
  Returns the env.state key used for storing the Agent pid for a given tag.
  """
  @spec agent_key(atom()) :: atom()
  def agent_key(tag), do: sig([:agent, tag])

  @doc """
  Returns the env.state key used for State-backed storage for a given tag.
  """
  @spec state_key(atom()) :: String.t()
  def state_key(tag), do: sig([:state, tag]) |> Atom.to_string()
end