defmodule Harlock.Cmd do
@moduledoc """
Side-effect descriptors returned from `init/1` and `update/2`.
An app's `update/2` can return `{new_model, cmd}` to request side-effects.
The runtime dispatches the cmd to a `Task.Supervisor` and re-enters the
TEA loop; results arrive later as ordinary `{:harlock_event, result}`
messages that `update/2` handles like any other event.
def update(:fetch, model) do
cmd = Cmd.from(fn -> HTTPoison.get!("https://example.com") end)
{model, cmd}
end
def update({:ok, %{body: body}}, model), do: {%{model | body: body}, Cmd.none()}
Constructors:
* `none/0` — no side-effect. Equivalent to returning just the model.
* `from/1` — run a 0-arity function in a task; deliver its return value.
* `batch/1` — dispatch a list of cmds concurrently; no ordering guarantee.
* `map/2` — tag/transform the result of an inner cmd before delivery.
Task lifecycle: cmd tasks are supervised by `Harlock.App.TaskSupervisor`,
itself a child of the app's supervisor positioned after `Runtime`. A
Runtime exit terminates the task supervisor and all in-flight tasks
(rest_for_one). A task crash is caught at the task body and delivered
as `{:cmd_error, reason}`; it never propagates to the runtime.
"""
require Logger
@typedoc "An opaque cmd descriptor. Build one via the constructors."
@opaque t ::
:none
| {:fun, (-> any())}
| {:batch, [t()]}
| {:map, t(), (any() -> any())}
@spec none() :: t()
def none, do: :none
@spec from((-> any())) :: t()
def from(fun) when is_function(fun, 0), do: {:fun, fun}
@spec batch([t()]) :: t()
def batch(cmds) when is_list(cmds), do: {:batch, cmds}
@spec map(t(), (any() -> any())) :: t()
def map(cmd, fun) when is_function(fun, 1), do: {:map, cmd, fun}
@doc false
@spec dispatch(t(), pid(), atom() | pid()) :: :ok
def dispatch(cmd, runtime, task_sup), do: dispatch_with(cmd, runtime, task_sup, [])
defp dispatch_with(:none, _runtime, _sup, _mappers), do: :ok
defp dispatch_with({:fun, fun}, runtime, sup, mappers) do
{:ok, _pid} =
Task.Supervisor.start_child(sup, fn ->
result = run_safely(fun)
tagged = apply_mappers(result, mappers)
send(runtime, {:harlock_event, tagged})
end)
:ok
end
defp dispatch_with({:batch, cmds}, runtime, sup, mappers) do
Enum.each(cmds, &dispatch_with(&1, runtime, sup, mappers))
end
defp dispatch_with({:map, cmd, mapper}, runtime, sup, mappers) do
dispatch_with(cmd, runtime, sup, [mapper | mappers])
end
defp run_safely(fun) do
fun.()
rescue
e ->
Logger.error("Harlock cmd crashed: #{Exception.format(:error, e, __STACKTRACE__)}")
{:cmd_error, {:exception, e}}
catch
kind, reason ->
Logger.error("Harlock cmd crashed (#{kind}): #{inspect(reason)}")
{:cmd_error, {kind, reason}}
end
defp apply_mappers(result, []), do: result
defp apply_mappers(result, mappers) do
Enum.reduce(mappers, result, fn fun, acc -> fun.(acc) end)
end
end