Powered by AppSignal & Oban Pro

Managed Sensors

managed-sensors.livemd

Managed Sensors

This tutorial uses Jido.Plugin.SensorManager to run input processes that an Agent starts and stops from its own Actions. A sensor turns outside events into Signals. The Plugin keeps the set of running sensors equal to the set in Agent state.

Jido has no sensor behaviour to implement. A sensor is any OTP child that accepts a Jido.Plugin.SensorManager.Init value and casts Signals to its Agent. For a fixed set of input processes that the Agent never changes, see Supervised Resources.

Install Jido

Mix.install([
  {:jido, github: "mikehostetler/new_jido", branch: "main"}
])

Start a Jido instance

{:ok, _instance} = Jido.start_link(name: SensorGuide.Jido)

defmodule SensorGuide.Wait do
  def until(check, attempts \\ 200) do
    cond do
      value = check.() ->
        value

      attempts == 0 ->
        raise "condition was not met in time"

      true ->
        Process.sleep(10)
        until(check, attempts - 1)
    end
  end
end

Write a sensor

This sensor receives pushed mailbox events and casts each one to the Agent as a Signal. It stands in for a process that would read from a webhook, a queue, or a socket. start_link/1 receives the Init value. The global name lets this tutorial find the current process after a restart.

The stub fails to start while the :mailbox_online setting is false, which a later step uses.

defmodule SensorGuide.MailboxSensor do
  use GenServer

  alias Jido.Plugin.SensorManager.Init

  def start_link(%Init{} = init) do
    if Application.get_env(:sensor_guide, :mailbox_online, true) do
      GenServer.start_link(__MODULE__, init, name: {:global, name(init.agent_id, init.tag)})
    else
      {:error, :mailbox_offline}
    end
  end

  def whereis(agent_id, tag) do
    case :global.whereis_name(name(agent_id, tag)) do
      :undefined -> nil
      pid -> pid
    end
  end

  def push(sensor, event), do: GenServer.cast(sensor, {:push, event})

  @impl true
  def init(%Init{} = init), do: {:ok, init}

  @impl true
  def handle_cast({:push, event}, %Init{} = init) do
    signal = Jido.Signal.new!("mail.received", event, source: init.config.source)
    Jido.AgentServer.cast(init.agent_server, signal)
    {:noreply, init}
  end

  defp name(agent_id, tag), do: {__MODULE__, agent_id, tag}
end

GenServer already provides the child_spec/1 that the Plugin requires. The Plugin overrides the child ID and sets restart: :temporary, because it does the restarting itself.

The Init value has these fields:

Field Meaning
agent_server PID of the owning AgentServer. Cast Signals here.
agent_id ID of the owning Agent.
tag The tag from the Directive.
module The sensor module.
config The config map from the Directive.
jido The Jido instance, when there is one.
partition The Agent's partition, when there is one.

Add the Plugin and manage sensors from an Action

Jido.Plugin.SensorManager.start/3 and Jido.Plugin.SensorManager.stop/1 build Directives. Return them from an Action like any other Directive. :retry_delay_ms is the wait before retrying a failed start or restarting a crashed sensor. It defaults to 1_000, and it must be an integer from 1 through 4,294,967,295.

defmodule SensorGuide.ManageMailbox do
  use Jido.Action,
    name: "sensor_guide_manage_mailbox",
    schema:
      Jido.Joi.object(%{
        operation: Jido.Joi.enum([:start, :stop]),
        mailbox: Jido.Joi.string()
      })

  alias Jido.Plugin.SensorManager

  @impl Jido.Action
  def run(%{operation: :start, mailbox: mailbox}, context) do
    directive =
      SensorManager.start(mailbox, SensorGuide.MailboxSensor, %{source: "/mail/#{mailbox}"})

    {:ok, context.agent_state, [directive]}
  end

  def run(%{operation: :stop, mailbox: mailbox}, context) do
    {:ok, context.agent_state, [SensorManager.stop(mailbox)]}
  end
end

defmodule SensorGuide.Inbox do
  use Jido.Agent,
    name: "sensor_guide_inbox",
    plugins: [{Jido.Plugin.SensorManager, %{retry_delay_ms: 20}}]

  state do
    schema Jido.Joi.object(%{message_ids: Jido.Joi.list(Jido.Joi.string()) |> Jido.Joi.default([])})
  end

  routes do
    route "mailbox.manage", SensorGuide.ManageMailbox

    route "mail.received" do
      action %{message_id: message_id},
        schema: Jido.Joi.object(%{message_id: Jido.Joi.string()}),
        context: context do
        state = context.agent_state

        if message_id in state.message_ids do
          {:ok, state}
        else
          {:ok, %{state | message_ids: state.message_ids ++ [message_id]}}
        end
      end
    end
  end
end

{:ok, inbox} = Jido.start_agent(SensorGuide.Jido, SensorGuide.Inbox, id: "inbox-1")
:ok = Jido.AgentServer.await_ready(inbox)

manage = fn operation, mailbox ->
  Jido.Signal.new!("mailbox.manage", %{operation: operation, mailbox: mailbox}, source: "/mail/admin")
end

Start a sensor

The Turn commits the desired sensor to the :sensors key of Agent state. After the commit, the Plugin starts the process.

{:ok, committed, _directives} = Jido.AgentServer.call(inbox, manage.(:start, "support"))

committed.state.sensors.desired
# => %{"support" => %{config: %{source: "/mail/support"}, module: SensorGuide.MailboxSensor}}

Push events into the sensor. They arrive through the normal Signal path, so each one is a Turn. A repeated event ID changes nothing, because the handler checks it.

sensor = SensorGuide.Wait.until(fn -> SensorGuide.MailboxSensor.whereis("inbox-1", "support") end)

for message_id <- ["msg-1", "msg-2", "msg-2"] do
  SensorGuide.MailboxSensor.push(sensor, %{message_id: message_id})
end

SensorGuide.Wait.until(fn -> length(Jido.AgentServer.agent(inbox).state.message_ids) == 2 end)
Jido.AgentServer.agent(inbox).state.message_ids
# => ["msg-1", "msg-2"]

Recover from a crash

The Plugin monitors each sensor and restarts it while its tag stays desired. Kill the sensor and wait for a replacement:

Process.exit(sensor, :kill)

replacement =
  SensorGuide.Wait.until(fn ->
    case SensorGuide.MailboxSensor.whereis("inbox-1", "support") do
      pid when is_pid(pid) and pid != sensor -> pid
      _other -> nil
    end
  end)

SensorGuide.MailboxSensor.push(replacement, %{message_id: "msg-3"})

SensorGuide.Wait.until(fn -> "msg-3" in Jido.AgentServer.agent(inbox).state.message_ids end)
Jido.AgentServer.agent(inbox).state.message_ids
# => ["msg-1", "msg-2", "msg-3"]

A sensor that crashes restarts after :retry_delay_ms, the same wait as a failed start. A sensor that keeps crashing therefore starts at most once per delay, and it keeps restarting while its tag stays desired. The delay is fixed. For a longer backoff, handle the fault inside the sensor.

Change a sensor

Sending start again with the same tag and the same module and config keeps the running process. A different module or config replaces it.

{:ok, _agent, _directives} = Jido.AgentServer.call(inbox, manage.(:start, "support"))
SensorGuide.MailboxSensor.whereis("inbox-1", "support") == replacement
# => true

Handle a failed start

The Turn commits before the sensor starts, so a start failure does not fail the Turn. The call returns {:ok, ...}, the tag stays desired, and the Plugin retries after :retry_delay_ms. Read the failure from last_effect_outcome in Jido.AgentServer.status/2. The call can return before that field updates, so poll it.

Application.put_env(:sensor_guide, :mailbox_online, false)

{:ok, _agent, _directives} = Jido.AgentServer.call(inbox, manage.(:start, "billing"))

outcome =
  SensorGuide.Wait.until(fn ->
    case Jido.AgentServer.status(inbox).last_effect_outcome do
      %{status: :failed} = outcome -> outcome
      _other -> nil
    end
  end)

%{status: :failed, stage: :directive} = outcome
Map.keys(Jido.AgentServer.agent(inbox).state.sensors.desired)
# => ["billing", "support"]

Bring the dependency back. The retry starts the sensor with no new Directive.

Application.put_env(:sensor_guide, :mailbox_online, true)

billing = SensorGuide.Wait.until(fn -> SensorGuide.MailboxSensor.whereis("inbox-1", "billing") end)
is_pid(billing)
# => true

Reject invalid Directives

The tag must be a portable term and not nil. The sensor must be a loaded module with child_spec/1, and the config must be a portable map. An invalid Directive fails its Turn before commit, so neither state nor sensors change.

Jido.Plugin.SensorManager.Start.validate(Jido.Plugin.SensorManager.start(nil, SensorGuide.MailboxSensor))
|> elem(1)
|> Exception.message()
# => "Sensor tag must not be nil"
Jido.Plugin.SensorManager.Start.validate(
  Jido.Plugin.SensorManager.start("support", SensorGuide.MailboxSensor, %{owner: self()})
)
|> elem(1)
|> Exception.message()
# => "Sensor config must contain portable data"

Keep live values out of config. Pass names or identifiers that the sensor can resolve when it starts.

Stop sensors

stop/1 removes the tag from desired state, and the Plugin terminates the process. All remaining sensors stop with the Agent.

{:ok, stopped, _directives} = Jido.AgentServer.call(inbox, manage.(:stop, "support"))
Map.keys(stopped.state.sensors.desired)
# => ["billing"]
SensorGuide.Wait.until(fn -> SensorGuide.MailboxSensor.whereis("inbox-1", "support") == nil end)

monitor = Process.monitor(billing)
:ok = Jido.stop_agent(SensorGuide.Jido, inbox)

receive do
  {:DOWN, ^monitor, :process, ^billing, reason} -> reason
after
  1_000 -> raise "the sensor did not stop"
end
# => :shutdown

Desired sensors live in Agent state. When a durable Agent restores, the Plugin starts them again from that state. In-memory work inside a sensor does not survive, so a sensor must read its position from its source or from config.

Next steps