How Checkpoint and Restore Work for Agent Persistence in Jido: A Complete Guide
Jido's checkpoint and restore mechanism captures agent state as serializable maps, externalizes plugin data via callbacks, and rehydrates threads during restoration to enable durable agent persistence across system restarts.
The agentjido/jido repository implements a robust persistence layer that allows Elixir agents to survive process restarts and system crashes. Understanding how checkpoint and restore work for agent persistence in Jido is essential for building reliable long-running applications that maintain state integrity across hibernation cycles.
Understanding the Checkpoint and Restore Architecture
Jido orchestrates persistence through three core modules that separate concerns between agent logic, storage operations, and plugin lifecycle management.
Core Modules Involved
Jido.Agent– Defines thecheckpoint/2andrestore/2callbacks and supplies default implementations via code generation.Jido.Persist– The persistence layer that orchestrates checkpoint creation, storage adapter interaction, and agent rehydration including thread re-attachment.Jido.Plugin– Provides the behaviour contract foron_checkpoint/2andon_restore/2, allowing plugins to externalize large state objects or drop ephemeral data.
How Checkpoint Creation Works in Jido
When an agent is hibernated, Jido constructs a checkpoint through a coordinated sequence of agent callbacks, plugin externalization, and storage adapter writes.
The Agent Callback and Default Implementation
The Persist.hibernate/2 function initiates checkpoint creation by calling create_checkpoint/3, which checks for a custom checkpoint/2 implementation before falling back to the default.
# lib/jido/persist.ex – create_checkpoint/3
defp create_checkpoint(agent_module, agent, thread) do
ctx = %{}
result =
if function_exported?(agent_module, :checkpoint, 2) do
agent_module.checkpoint(agent, ctx) # custom logic
else
{:ok, default_checkpoint(agent_module, agent, thread)} # default
end
# ...
end
The default_checkpoint/3 function in lib/jido/persist.ex constructs a versioned map containing the agent module, ID, state (with the thread removed), and a lightweight thread pointer:
# lib/jido/persist.ex – default_checkpoint/3
defp default_checkpoint(agent_module, agent, thread) do
thread_pointer =
case thread do
nil -> nil
%Thread{id: id, rev: rev} -> %{id: id, rev: rev}
end
%{
version: 1,
agent_module: agent_module,
id: agent.id,
state: Map.delete(agent.state, :__thread__),
thread: thread_pointer
}
end
Plugin State Externalization
During checkpoint creation, the macro-generated checkpoint/2 implementation in lib/jido/agent.ex iterates over all plugin instances, allowing each to decide its persistence strategy:
# lib/jido/agent.ex – quoted checkpoint implementation
def checkpoint(agent, ctx) do
{state, externalized, externalized_keys} =
Enum.reduce(@plugin_instances, {agent.state, %{}, %{}}, fn instance,
{state_acc, ext_acc,
keys_acc} ->
plugin_state = Map.get(state_acc, instance.state_key)
config = instance.config || %{}
case instance.module.on_checkpoint(plugin_state,
Map.put(ctx, :config, config)) do
{:externalize, key, pointer} ->
{Map.delete(state_acc, instance.state_key),
Map.put(ext_acc, key, pointer),
Map.put(keys_acc, key, instance.state_key)}
:drop -> {Map.delete(state_acc, instance.state_key), ext_acc, keys_acc}
:keep -> {state_acc, ext_acc, keys_acc}
end
end)
base = %{version: 1, agent_module: __MODULE__, id: agent.id, state: state}
base = if externalized_keys == %{}, do: base, else: Map.put(base, :externalized_keys, externalized_keys)
{:ok, Map.merge(base, externalized)}
end
Plugins return one of three directives:
:keep– State remains in the checkpoint map.:drop– State is excluded from persistence (useful for ephemeral data).{:externalize, key, pointer}– State is removed and replaced with a lightweight pointer, enabling zero-copy persistence for large objects like threads.
Storage Adapter Integration
After constructing the checkpoint map, Persist.hibernate/2 writes it via Storage.put_checkpoint/3. The default Jido.Storage.File adapter performs atomic writes to disk, while Jido.Storage.ETS provides in-memory storage for testing.
How Agent Restoration Works in Jido
Restoration reverses the checkpoint process: fetching serialized data, rebuilding the agent, re-attaching threads, and restoring plugin state.
Fetching and Rebuilding the Agent
Persist.thaw/3 retrieves the checkpoint using the storage adapter and initiates restoration:
# lib/jido/persist.ex – thaw flow (excerpt)
checkpoint_key = make_checkpoint_key(agent_module, key)
case Jido.Storage.fetch_checkpoint(adapter, checkpoint_key, opts) do
{:ok, checkpoint} -> restore_from_checkpoint(adapter, opts, agent_module, checkpoint)
...
end
The restore_agent/3 function checks for a custom restore/2 callback before applying the default restoration logic:
# lib/jido/persist.ex – restore_agent/3
if function_exported?(agent_module, :restore, 2) do
agent_module.restore(checkpoint, ctx)
else
default_restore(agent_module, checkpoint)
end
The default_restore/2 function creates a fresh agent via new/1 and merges the persisted state:
# lib/jido/persist.ex – default_restore/2
defp default_restore(agent_module, checkpoint) do
case agent_module.new(id: checkpoint.id) do
{:ok, agent} ->
merged_state = Map.merge(agent.state, checkpoint.state || %{})
{:ok, %{agent | state: merged_state}}
# also handles the case where new/1 returns a struct directly
agent when is_struct(agent) ->
merged_state = Map.merge(agent.state, checkpoint.state || %{})
{:ok, %{agent | state: merged_state}}
end
end
Thread Rehydration
If the checkpoint contains a thread pointer, Persist.rehydrate_thread/4 loads the full thread from storage and calls Agent.attach_thread/2. A revision mismatch between the pointer and the stored thread aborts the restoration to prevent stale state corruption.
Plugin State Restoration
After rebuilding the base agent, Persist.restore_from_checkpoint/4 iterates over plugin instances to restore externalized state:
# lib/jido/agent.ex – restore/2 default implementation (calls Persist)
# (the actual iteration lives in Persist.restore_from_checkpoint)
# See lines 96‑123 in persist.ex for the loop:
Enum.reduce_while(@plugin_instances, {:ok, agent}, fn instance, {:ok, acc} ->
ext_key = Enum.find_value(externalized_keys, fn {k, v} -> if v == instance.state_key, do: k end)
pointer = if ext_key, do: data[ext_key]
if pointer do
case instance.module.on_restore(pointer, restore_ctx) do
{:ok, nil} -> {:cont, {:ok, acc}} # externalised plugin will rehydrate later
{:ok, restored_state} ->
{:cont, {:ok, %{acc | state: Map.put(acc.state, instance.state_key, restored_state)}}}
{:error, reason} -> {:halt, {:error, reason}}
end
else
{:cont, {:ok, acc}}
end
end)
Plugins receive their stored pointer via on_restore/2 and return:
{:ok, nil}– Indicates the plugin will rehydrate independently (e.g., threads loaded separately byPersist).{:ok, restored_state}– Returns the actual state to merge into the agent.{:error, reason}– Aborts the entire restoration.
Complete Implementation Example
Here is a practical implementation demonstrating custom checkpoint and restore logic with plugin externalization:
defmodule MyApp.CounterAgent do
use Jido.Agent, plugins: [MyApp.ThreadPlugin]
# ---- custom checkpoint -------------------------------------------------
@impl true
def checkpoint(agent, _ctx) do
# keep normal state but externalise the heavy thread plugin
{:ok,
%{
version: 1,
agent_module: __MODULE__,
id: agent.id,
state: agent.state,
externalized_keys: %{thread: :thread_state}
}
|> Map.merge(%{thread: %{id: agent.state.thread.id, rev: agent.state.thread.rev}})
}
end
# ---- custom restore ----------------------------------------------------
@impl true
def restore(data, _ctx) do
# Re‑create a fresh agent and merge persisted state
with {:ok, agent} <- new(id: data.id) do
merged = Map.merge(agent.state, data.state || %{})
{:ok, %{agent | state: merged}}
end
end
end
defmodule MyApp.ThreadPlugin do
use Jido.Plugin
@impl true
def on_checkpoint(_state, _ctx) do
# Store only a lightweight pointer; Persist will later reattach the full thread
{:externalize, :thread, %{id: :my_thread, rev: 42}}
end
@impl true
def on_restore(pointer, _ctx) do
# Persist will load the thread using the pointer; we just signal “OK”
{:ok, nil}
end
end
This example demonstrates:
- The agent’s
checkpoint/2building a map withexternalized_keyslinking the key:threadto the plugin’s internal state key. - The plugin’s
on_checkpoint/2returning{:externalize, :thread, pointer}to store only a lightweight reference. - During thaw,
Persistloading the checkpoint, calling the agent’srestore/2, then invokingMyApp.ThreadPlugin.on_restore/2with the pointer, which returns{:ok, nil}becausePersisthandles full thread rehydration separately.
Summary
- Checkpoint creation occurs in
Jido.Persist.create_checkpoint/3, which calls the agent’scheckpoint/2callback or falls back todefault_checkpoint/3inlib/jido/persist.ex. - Plugin externalization allows plugins to return
{:externalize, key, pointer}fromon_checkpoint/2, storing lightweight pointers instead of full state. - Storage adapters like
Jido.Storage.FileandJido.Storage.ETShandle the actual serialization viaStorage.put_checkpoint/3. - Restoration in
Jido.Persist.thaw/3fetches the checkpoint, rebuilds the agent viarestore/2ordefault_restore/2, reattaches threads viarehydrate_thread/4, and restores plugin state throughon_restore/2callbacks.
Frequently Asked Questions
What is the difference between hibernation and checkpointing in Jido?
Hibernation refers to the high-level operation of pausing an agent and saving its state, while checkpointing is the specific mechanism that serializes the agent's state into a storable map. When you call Persist.hibernate/2, it internally invokes create_checkpoint/3 to build the checkpoint before writing it to storage.
How do plugins handle large state objects during checkpoint?
Plugins handle large state objects by returning {:externalize, key, pointer} from their on_checkpoint/2 callback. This instructs Jido.Persist to remove the bulky state from the checkpoint map and store only a lightweight pointer (such as a thread ID and revision). The actual heavy object is persisted separately, and the plugin receives the pointer during on_restore/2 to rehydrate the data.
Can I use custom storage adapters with Jido's persistence layer?
Yes, Jido's persistence layer is adapter-based. You can implement the storage behaviour defined in the codebase to create custom adapters. The repository includes Jido.Storage.File for atomic file-based persistence and Jido.Storage.ETS for in-memory testing. Your custom adapter must implement put_checkpoint/3 and fetch_checkpoint/3 to integrate with Jido.Persist.
What happens if the thread revision mismatches during restore?
If the thread pointer in the checkpoint contains a revision that does not match the current revision of the stored thread, Jido.Persist.rehydrate_thread/4 aborts the restoration process. This revision-checking mechanism prevents stale state corruption by ensuring that the agent only attaches to threads that haven't been modified since the checkpoint was created.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →