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_onlyerror policy, each failure logs a warning and raiseserror_countinJido.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.