Powered by AppSignal & Oban Pro

Agent Heartbeats

heartbeats.livemd

Agent Heartbeats

This tutorial uses Jido.Plugin.Heartbeat to send an Agent a Signal on a fixed interval, so that periodic work runs as a normal Turn.

The Plugin only produces Signals. It adds no Agent state and does not detect missed beats or report liveness. For cron rules or one-shot delays, use Schedules.

Install Jido

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

Start a Jido instance

The Plugin runs a supervised timer process, so the Agent must start through an instance.

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

defmodule HeartbeatGuide.Wait do
  def until(check, attempts \\ 200) do
    cond do
      check.() ->
        :ok

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

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

Receive the default tick

Declare the Plugin and add a route for its Signal. Without :signal_type, each tick is a Jido.Plugin.Heartbeat.Signal.Tick with the type "jido.agent.heartbeat" and the source "/plugin/heartbeat". The value of :signal_data becomes the input of the routed Action.

defmodule HeartbeatGuide.Monitor do
  use Jido.Agent,
    name: "heartbeat_guide_monitor",
    plugins: [{Jido.Plugin.Heartbeat, %{interval: 20, signal_data: %{check: "disk"}}}]

  state do
    schema Jido.Joi.object(%{
             beats: Jido.Joi.integer() |> Jido.Joi.default(0),
             last: Jido.Joi.map() |> Jido.Joi.default(%{})
           })
  end

  routes do
    route "jido.agent.heartbeat" do
      action input, context: context do
        state = context.agent_state

        {:ok,
         %{
           state
           | beats: state.beats + 1,
             last: %{type: context.signal.type, source: context.signal.source, input: input}
         }}
      end
    end
  end
end

{:ok, monitor} = Jido.start_agent(HeartbeatGuide.Jido, HeartbeatGuide.Monitor, id: "monitor-1")
:ok = Jido.AgentServer.await_ready(monitor)

The timer starts with the Plugin, and the first tick arrives one interval later. Wait for a few beats and read the state:

:ok = HeartbeatGuide.Wait.until(fn -> Jido.AgentServer.agent(monitor).state.beats >= 3 end)

Jido.AgentServer.agent(monitor).state.last
# => %{type: "jido.agent.heartbeat", source: "/plugin/heartbeat", input: %{check: "disk"}}

The timer stops with the Agent:

:ok = Jido.stop_agent(HeartbeatGuide.Jido, monitor)

Send your own Signal type

Set :signal_type and :source when the tick should read as a domain event. The route then matches your type.

defmodule HeartbeatGuide.InvoiceSweeper do
  use Jido.Agent,
    name: "heartbeat_guide_invoice_sweeper",
    plugins: [
      {Jido.Plugin.Heartbeat,
       %{
         interval: 60_000,
         signal_type: "invoice.sweep_due",
         signal_data: %{batch_size: 50},
         source: "/billing/sweeper"
       }}
    ]

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

  routes do
    route "invoice.sweep_due" do
      action %{batch_size: batch_size},
        schema: Jido.Joi.object(%{batch_size: Jido.Joi.integer()}),
        context: context do
        {:ok, %{context.agent_state | sweeps: context.agent_state.sweeps ++ [batch_size]}}
      end
    end
  end
end

Change the interval at start

The Heartbeat options belong to the AgentServer side of the Plugin, so you can override them per start with :plugin_runtime_options. This keeps a production interval in the definition and a short one in a test.

{:ok, sweeper} =
  Jido.start_agent(HeartbeatGuide.Jido, HeartbeatGuide.InvoiceSweeper,
    id: "sweeper-1",
    plugin_runtime_options: [{Jido.Plugin.Heartbeat, [interval: 20]}]
  )

:ok = Jido.AgentServer.await_ready(sweeper)
:ok = HeartbeatGuide.Wait.until(fn -> length(Jido.AgentServer.agent(sweeper).state.sweeps) >= 2 end)

Jido.AgentServer.agent(sweeper).state.sweeps |> Enum.take(2)
# => [50, 50]
:ok = Jido.stop_agent(HeartbeatGuide.Jido, sweeper)

Options

Option Default Accepted values
:interval 5_000 Positive integer, in milliseconds.
:signal_type "jido.agent.heartbeat" Non-empty, valid UTF-8 string.
:signal_data %{} Plain map, not a struct.
:source "/plugin/heartbeat" Non-empty URI reference.

An invalid value fails the Agent definition with the message "Heartbeat Plugin options are invalid". An unknown option key is rejected as well.

Delivery behavior

  • The Plugin casts each tick to the Agent, so it does not wait for the Turn. A Turn longer than the interval leaves ticks queued behind it. Excess casts are dropped and logged at the postponed Turn limit.
  • The next tick is scheduled after the previous one is sent. The interval is a delay between ticks, not a wall-clock alignment. Use Schedules for work that must land on a clock slot.
  • A tick with no matching route fails its Turn at every interval. Under the default :log_only error policy, each failure logs a warning and raises error_count in Jido.AgentServer.status/2. Under :stop_on_error, the first one stops the Agent.
  • Each tick is a fresh Signal with a new ID.
  • The timer holds nothing durable. After a restart it starts over, and ticks that fell in the gap are not sent.

Next steps