defmodule Skuld.PageMachine.Spindle do
@moduledoc """
Named concurrent sub-computations (spindles) that run as fibers within
a FiberPool. Each spindle is identified by an atom key and communicates
results through auto-tagged yields.
## Usage
use Skuld.Syntax
comp do
checkout <- Spindle.fork(:checkout, MyApp.CheckoutFlow.flow(product))
# Main spindle continues...
filters <- Yield.yield(:search)
{:ok, results} <- MyApp.ProductCatalog.search(filters)
Yield.yield({:results, results})
end
|> Spindle.with_handler()
|> FiberYield.with_handler()
|> FiberPool.with_handler()
|> Comp.run()
"""
alias Skuld.Comp
alias Skuld.Comp.Env
alias Skuld.Comp.Types
alias Skuld.Effects.FiberPool
alias Skuld.Effects.FiberYield
@sig __MODULE__
defmodule Mappings do
@moduledoc false
defstruct fiber_ids_by_spindle_key: %{}, spindle_keys_by_fiber_id: %{}
@typedoc false
@type t :: %__MODULE__{
fiber_ids_by_spindle_key: %{atom() => term()},
spindle_keys_by_fiber_id: %{term() => atom()}
}
@doc "Register a spindle key -> fiber_id pair in both maps."
@spec register(t(), atom(), term()) :: t()
def register(mappings, spindle_key, fiber_id) do
%{
mappings
| fiber_ids_by_spindle_key:
Map.put(mappings.fiber_ids_by_spindle_key, spindle_key, fiber_id),
spindle_keys_by_fiber_id:
Map.put(mappings.spindle_keys_by_fiber_id, fiber_id, spindle_key)
}
end
@doc "Look up the spindle key for a given fiber ID."
@spec spindle_key(t(), term()) :: atom() | nil
def spindle_key(mappings, fiber_id) do
Map.get(mappings.spindle_keys_by_fiber_id, fiber_id)
end
@doc "Look up the fiber ID for a given spindle key."
@spec fiber_id(t(), atom()) :: reference() | nil
def fiber_id(mappings, spindle_key) do
Map.get(mappings.fiber_ids_by_spindle_key, spindle_key)
end
@doc "Merge another Mappings into this one."
@spec merge(t(), t()) :: t()
def merge(m1, m2) do
%{
m1
| fiber_ids_by_spindle_key:
Map.merge(m1.fiber_ids_by_spindle_key, m2.fiber_ids_by_spindle_key),
spindle_keys_by_fiber_id:
Map.merge(m1.spindle_keys_by_fiber_id, m2.spindle_keys_by_fiber_id)
}
end
end
@env_key Module.concat(__MODULE__, Mappings)
@doc "Environment state key for %Spindle.Mappings{}."
def env_key, do: @env_key
#############################################################################
## Public API
#############################################################################
@doc """
Fork a named spindle as a FiberPool fiber.
Returns a Handle that can be used with `FiberPool.await!/1`.
"""
@spec fork(atom(), Types.computation()) :: Types.computation()
def fork(key, computation) when is_atom(key) do
Comp.effect(@sig, {:fork, key, computation})
end
@doc """
Fire-and-forget notification to the PageMachine caller.
Surfaces `value` to the caller (e.g., the LiveView) without pausing
the spindle. The fiber continues immediately on the next scheduler
round. Returns `nil` on resume.
## Example
comp do
Spindle.notify(:purchase_selected)
# spindle continues immediately, no Yield.yield pause
{:ok, results} <- MyApp.Catalog.search(filters)
...
end
"""
@spec notify(term()) :: Types.computation()
def notify(value) do
FiberYield.notify(value)
end
@doc """
Install the Spindle handler. Must be installed outside `FiberPool.with_handler/1`
in the handler chain.
"""
@spec with_handler(Types.computation()) :: Types.computation()
def with_handler(computation) do
Comp.with_handler(computation, @sig, &handle/3)
end
#############################################################################
## Handler Implementation
#############################################################################
@doc false
def handle({:fork, key, computation}, env, k) do
wrapped_k = fn handle, fiber_env ->
mappings = Env.get_state(fiber_env, @env_key, %Mappings{})
mappings = Mappings.register(mappings, key, handle.id)
k.(handle, Env.put_state(fiber_env, @env_key, mappings))
end
fiber_effect = FiberPool.fiber(computation)
Comp.call(fiber_effect, env, wrapped_k)
end
end