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