Powered by AppSignal & Oban Pro

Persistence Adapters

persistence-adapters.livemd

Persistence Adapters

This tutorial starts a Jido instance with a persistence adapter, saves an Agent, restores it, and then moves to a different adapter without changing the Agent. It runs with no external service.

Install Jido

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

Define an Agent

The Agent knows nothing about storage. It holds a paid amount and adds to it on each payment Signal.

defmodule AdaptersGuide.ApplyPayment do
  use Jido.Action,
    name: "adapters_guide_apply_payment",
    schema: Jido.Joi.object(%{cents: Jido.Joi.integer()})

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

defmodule AdaptersGuide.Invoice do
  use Jido.Agent, name: "adapters_guide_invoice"

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

  routes do
    route "invoice.pay", AdaptersGuide.ApplyPayment
  end
end

payment = Jido.Signal.new!("invoice.pay", %{cents: 250}, source: "/billing")
payment.type

The cell returns "invoice.pay".

Check an adapter declaration

An adapter declaration is a module or a {module, options} tuple. Jido.Persistence.resolve_config/2 checks the declaration without starting anything. It requires the get/2 and compare_and_swap/4 callbacks and runs the adapter's option validation.

results = %{
  ets: Jido.Persistence.resolve_config({Jido.Persistence.ETS, table: :adapters_guide}, nil),
  file_without_path: Jido.Persistence.resolve_config(Jido.Persistence.File, nil),
  disabled: Jido.Persistence.resolve_config(false, nil)
}

{:ok, {Jido.Persistence.ETS, [table: :adapters_guide]}} = results.ets

{:error, {:invalid_persistence_options, Jido.Persistence.File, ":path must be a non-empty string"}} =
  results.file_without_path

{:ok, nil} = results.disabled

results

The file adapter needs :path, and false or nil disables persistence. A successful result does not prove the storage is durable or shared across nodes.

Start an instance with ETS

Configure the adapter on the instance. A persistent Agent also needs a stable namespace, because the namespace is part of its durable key.

{:ok, _instance} =
  Jido.start_link(
    name: AdaptersGuide.EtsJido,
    namespace: "billing",
    persistence: {Jido.Persistence.ETS, table: :adapters_guide}
  )

{:ok, invoice} =
  Jido.start_agent(AdaptersGuide.EtsJido, AdaptersGuide.Invoice,
    id: "inv-1001",
    persistence: :inherit
  )

:ok = Jido.AgentServer.await_ready(invoice)

{:ok, agent, _directives} = Jido.AgentServer.call(invoice, payment)
snapshot = Jido.AgentServer.snapshot(invoice)

{agent.state, snapshot.state_version}

The cell returns {%{paid_cents: 250}, 1}. Revision zero was written before await_ready/1 returned. The successful Turn wrote revision one.

Restore from storage

Read the record, hibernate the Agent, and thaw it. Jido.thaw/4 starts a new process that requires the saved record.

{:ok, stored, 1} =
  Jido.Persistence.load_agent_with_revision(
    AdaptersGuide.EtsJido,
    AdaptersGuide.Invoice,
    "inv-1001"
  )

:ok = Jido.hibernate(AdaptersGuide.EtsJido, invoice)

{:ok, thawed} = Jido.thaw(AdaptersGuide.EtsJido, AdaptersGuide.Invoice, "inv-1001")
:ok = Jido.AgentServer.await_ready(thawed)

{stored.state, Jido.AgentServer.agent(thawed).state, Jido.AgentServer.snapshot(thawed).state_version}

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

Passing the instance name as the source makes Jido.Persistence use that instance's adapter and namespace. You can also pass {adapter, options} with :namespace to read the same record without an instance.

Stop the instance

Stop the Agent and the instance. The next section starts a new instance with the same namespace.

:ok = Jido.stop_agent(AdaptersGuide.EtsJido, thawed)
Supervisor.stop(AdaptersGuide.EtsJido)

ETS data survives a stopped instance because a supervised process in the Jido application inherits the table. It does not survive a BEAM restart.

Swap to files

Change the declaration and nothing else. The Agent and its Signals stay the same.

path = Path.join(System.tmp_dir!(), "adapters-guide-#{System.unique_integer([:positive])}")
file_store = {Jido.Persistence.File, path: path}

{:ok, _instance} =
  Jido.start_link(name: AdaptersGuide.FileJido, namespace: "billing", persistence: file_store)

{:ok, invoice} =
  Jido.start_agent(AdaptersGuide.FileJido, AdaptersGuide.Invoice,
    id: "inv-2001",
    persistence: :inherit
  )

:ok = Jido.AgentServer.await_ready(invoice)
{:ok, _agent, _directives} = Jido.AgentServer.call(invoice, payment)
:ok = Jido.stop_agent(AdaptersGuide.FileJido, invoice)
Supervisor.stop(AdaptersGuide.FileJido)

path |> Path.join("records") |> File.ls!() |> length()

The cell returns 1, the one file for this Agent. Its name is a hash of the storage key.

The instance is gone, but the record remains on disk. A new instance with the same namespace can read it:

{:ok, _instance} =
  Jido.start_link(name: AdaptersGuide.RestartedJido, namespace: "billing", persistence: file_store)

{:ok, restored} = Jido.thaw(AdaptersGuide.RestartedJido, AdaptersGuide.Invoice, "inv-2001")
:ok = Jido.AgentServer.await_ready(restored)
state = Jido.AgentServer.agent(restored).state

:ok = Jido.stop_agent(AdaptersGuide.RestartedJido, restored)
Supervisor.stop(AdaptersGuide.RestartedJido)
File.rm_rf!(path)

state

The cell returns %{paid_cents: 250}. The instance name changed and the record still loaded, because the key uses the namespace.

Override per Agent

:persistence on Jido.start_agent/3 also accepts a declaration, which replaces the instance default for that Agent. The Agent still takes its namespace from the instance.

{:ok, _instance} =
  Jido.start_link(name: AdaptersGuide.MixedJido, namespace: "billing")

{:ok, invoice} =
  Jido.start_agent(AdaptersGuide.MixedJido, AdaptersGuide.Invoice,
    id: "inv-3001",
    persistence: {Jido.Persistence.ETS, table: :adapters_guide_override}
  )

:ok = Jido.AgentServer.await_ready(invoice)

{:ok, %{id: "inv-3001"}} =
  Jido.Persistence.load_agent(
    {Jido.Persistence.ETS, table: :adapters_guide_override},
    AdaptersGuide.Invoice,
    "inv-3001",
    namespace: "billing"
  )

:ok = Jido.stop_agent(AdaptersGuide.MixedJido, invoice)
Supervisor.stop(AdaptersGuide.MixedJido)

The cell returns :ok.

Choose an adapter

Jido ships these adapters. All of them implement Jido.Persistence.Store.

Adapter Storage Survives a BEAM restart Shared across BEAM instances Extra dependency Guide
Jido.Persistence.ETS Memory No No None Local Stores
Jido.Persistence.File One file per key Yes No. One BEAM must own the directory None Local Stores
Jido.Persistence.Mnesia An application-owned Mnesia table Depends on your table copies Depends on your table copies :mnesia in extra_applications Local Stores
Jido.Persistence.Ecto One SQL table on PostgreSQL or SQLite Yes Yes on PostgreSQL :ecto_sql and a driver Ecto Storage
Jido.Persistence.Redis Redis keys, through your client function Depends on your Redis setup Yes A Redis client Redis and S3 Stores
Jido.Persistence.S3 One object per key, through your request function Yes Yes An S3 client Redis and S3 Stores
Jido.Persistence.Bedrock A Bedrock cluster Depends on your cluster's durability profile Yes :bedrock and :bedrock_raft Bedrock and Raft Storage

For anything else, see Custom Adapters. No adapter gives you exclusive ownership of an Agent across a cluster. Revision checks reject stale writes, and they do not elect a single owner.

Next steps