Powered by AppSignal & Oban Pro

Make an Agent Durable

make-an-agent-durable.livemd

Make an Agent Durable

In this tutorial you save an invoice Agent to files, kill its server, stop the whole Jido instance, and restore the same state and revision. You need an Agent with a schema and a route. Define an Agent covers those.

Install Jido

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

Define the Agent

A durable Agent needs no special code. This one records payments against an invoice.

defmodule MakeDurableGuide.RecordPayment do
  use Jido.Action,
    name: "make_durable_record_payment",
    schema: Jido.Joi.object(%{cents: Jido.Joi.integer() |> Jido.Joi.min(1)})

  @impl Jido.Action
  def run(%{cents: cents}, %{agent_state: state}) do
    {:ok, %{state | paid_cents: state.paid_cents + cents}}
  end
end

defmodule MakeDurableGuide.Invoice do
  use Jido.Agent, name: "make_durable_invoice"

  state do
    schema Jido.Joi.object(%{paid_cents: Jido.Joi.integer() |> Jido.Joi.default(0)})
  end

  routes do
    signal_source "/guides/make_durable"
    route "invoice.record_payment", MakeDurableGuide.RecordPayment, as: :record_payment
  end
end

Start an instance with a store

A persistent Agent needs two instance settings. :namespace is the stable part of the record key, and :persistence selects the adapter. Jido.Persistence.File writes one file per record, and one BEAM must own its directory.

store_path = Path.join(System.tmp_dir!(), "jido-make-durable-guide")
File.rm_rf!(store_path)

instance_options = [
  name: MakeDurableGuide.Jido,
  namespace: "guides/make-durable",
  persistence: {Jido.Persistence.File, path: store_path}
]

{:ok, _} = Jido.start_link(instance_options)

Start the Agent with persistence

Pass persistence: :inherit to use the instance adapter. An adapter tuple also works here and overrides the instance default.

The server writes revision zero before it reports ready, so wait with Jido.AgentServer.await_ready/2 before you send work.

alias Jido.AgentServer, as: Server
alias MakeDurableGuide.Invoice

{:ok, server} =
  Jido.start_agent(MakeDurableGuide.Jido, Invoice, id: "invoice-42", persistence: :inherit)

:ok = Server.await_ready(server)

%{state_version: 0} = Server.snapshot(server)
store_path |> Path.join("**/*") |> Path.wildcard() |> Enum.count(&File.regular?/1)

The cell returns 1, the one file that holds this Agent's record.

Commit a Turn

A successful Turn saves the new state before the call returns. If the save fails, the caller gets an error and the activation stops, so an {:ok, agent, directives} result means the record holds that state.

alias Jido.AgentServer, as: Server
alias MakeDurableGuide.Invoice

{:ok, signal} = Invoice.record_payment_signal(%{cents: 1_500})
{:ok, agent, []} = Server.call(server, signal)

{agent.state, Server.snapshot(server).state_version}

The cell returns {%{paid_cents: 1500}, 1}.

Kill the server

The activation supervisor starts a replacement. The replacement reads the record, so it has the same state and revision. Look it up by ID, because the old PID is gone.

alias Jido.AgentServer, as: Server
alias MakeDurableGuide.Invoice

monitor = Process.monitor(server)
Process.exit(server, :kill)

receive do
  {:DOWN, ^monitor, :process, ^server, :killed} -> :ok
end

replacement =
  Enum.find_value(1..200, fn _attempt ->
    case Jido.whereis_agent(MakeDurableGuide.Jido, "invoice-42") do
      pid when is_pid(pid) ->
        pid

      nil ->
        Process.sleep(10)
        nil
    end
  end)

:ok = Server.await_ready(replacement)

{replacement != server, Server.agent(replacement).state, Server.snapshot(replacement).state_version}

The cell returns {true, %{paid_cents: 1500}, 1}.

Stop everything and restore

Now remove every live process. Jido.stop/1 stops the instance and its Agents. A normal stop does not change the record, because each commit already saved it.

Start a new instance with the same namespace and store. Jido.thaw/4 requires an existing record and restores it.

alias Jido.AgentServer, as: Server
alias MakeDurableGuide.Invoice

:ok = Jido.stop(MakeDurableGuide.Jido)
nil = Process.whereis(MakeDurableGuide.Jido)

{:ok, _} = Jido.start_link(instance_options)
{:ok, restored} = Jido.thaw(MakeDurableGuide.Jido, Invoice, "invoice-42")
:ok = Server.await_ready(restored)

{Server.agent(restored).state, Server.snapshot(restored).state_version}

The cell returns {%{paid_cents: 1500}, 1}. A new BEAM follows the same steps. It needs the same namespace, the same store, and compatible code. Loading the record does not rebuild the definition from source, so changed code needs an explicit upgrade. Checkpoints and Upgrades shows how.

Continue working

The restored Agent takes new Turns from the saved revision.

alias Jido.AgentServer, as: Server
alias MakeDurableGuide.Invoice

{:ok, signal} = Invoice.record_payment_signal(%{cents: 500})
{:ok, agent, []} = Server.call(restored, signal)

{agent.state, Server.snapshot(restored).state_version}

The cell returns {%{paid_cents: 2000}, 2}.

Read the record without a server

Jido.Persistence.load_agent_with_revision/4 reads and restores the record as a value and starts no process. Pass the instance as the source to use its adapter and namespace.

alias Jido.AgentServer, as: Server
alias MakeDurableGuide.Invoice

:ok = Server.stop(restored)

{:ok, saved, revision} =
  Jido.Persistence.load_agent_with_revision(MakeDurableGuide.Jido, Invoice, "invoice-42")

{saved.state, revision}

The cell returns {%{paid_cents: 2000}, 2}.

Choose a start mode

Jido.start_agent/3 defaults to persistence_mode: :create, which fails if the record exists. The server starts, reads the record, and stops with {:shutdown, :conflict}. Jido.AgentServer.await_ready/2 returns that reason as {:error, :conflict}. A fast failure can make the start itself return the error, so the code handles both forms.

alias Jido.AgentServer, as: Server
alias MakeDurableGuide.Invoice

result =
  case Jido.start_agent(MakeDurableGuide.Jido, Invoice, id: "invoice-42", persistence: :inherit) do
    {:ok, pid} -> Server.await_ready(pid)
    {:error, reason} -> {:error, reason}
  end

true = match?({:error, _reason}, result)
{:ok, _agent, 2} = Jido.Persistence.load_agent_with_revision(MakeDurableGuide.Jido, Invoice, "invoice-42")

result

The existing record stays at revision 2. For an Agent that may or may not exist yet, use Jido.activate_agent/4.

Clean up

:ok = Jido.stop(MakeDurableGuide.Jido)
File.rm_rf!(store_path)

Next steps