Normandy.Behaviours.SessionStore behaviour
(normandy v2.2.0)
View Source
Contract for externalizing a session's conversation entries and turn state.
The memory-backing half (append_entry, history, fork) persists the
parent-linked entry graph keyed by session_id. The turn-state half
(save_turn_state/4, load_turn_state/2) round-trips an opaque term, which
Turn.Server uses to suspend and resume a turn (passivation).
handle is impl-specific (a pid for InMemory, a table for ETS). The in-process
impls expose new/0 (or new/1) returning a fresh handle; Redis.new/1 requires
:url and :namespace; for Postgres the handle is the host's Repo module (no
constructor).
Every impl must pass the ExUnit suite Normandy.Test.SessionStoreContract.
This seam persists the full entry graph: it deliberately does not apply
max_messages windowing (that is a read-time / agent-runtime concern, applied
by the turn loop, not at the persistence layer).
Missing-session semantics are asymmetric by intent: history/2 is lenient and
returns {:ok, []} for an unknown session, while fork/3 is strict and returns
{:error, _} — you cannot branch a conversation that does not exist.
load_turn_state/2 and load_config_template/2 return :error only when
nothing is saved for the session. A backend failure (a timeout, a lost
connection, an aborted transaction) is {:error, reason}: Turn.Server waits
:unavailable and retries, replying the reason to run/2, because starting as
if nothing were saved would drop an in-flight turn and the next save would
overwrite it.
Summary
Callbacks
Appends entry to the session's history and returns {:ok, entry_id}.
Deletes a session: its turn state, its config template, its membership of the
in-flight index and the entries it wrote that no other session reaches, including a
branch its head left (an append with an explicit parent_id).
Creates a new session whose history is the branch of session_id up to from_entry_id,
and returns {:ok, new_session_id}.
Returns {:ok, entries}, the session's active branch from the oldest entry to the head.
Optional. Returns {:ok, entries}, the session's active branch from its last
"compaction" entry to the head, or the whole branch when it has none.
Lists the sessions with a turn in flight: those whose last save_turn_state/4 had
inflight?: true.
Returns {:ok, template}, the term the last save_config_template/3 saved.
Returns {:ok, state}, the term the last save_turn_state/4 saved for the session.
Saves template for the session, replacing an earlier one, and returns :ok.
Saves the session's turn state and, in the same write, whether its turn is in flight.
Functions
The entries a restore rebuilds a session's memory from (AgentMemory.from_entries/1):
store's history_since_compaction/2 when it exports one, else history/2. Both
rebuild the same memory. Turn.Server and Normandy.Coordination.AgentProcess call it.
Types
Callbacks
@callback append_entry(handle(), session_id(), Normandy.Components.AgentMemory.Entry.t()) :: {:ok, String.t()} | {:error, term()}
Appends entry to the session's history and returns {:ok, entry_id}.
entry_id is the entry's own id, or a new one when it is nil. An entry with no
parent_id goes after the session's current head, and a session that does not exist yet
is created. Turn.Server calls it for every message and for each compaction. A store
error is {:error, reason}; for a message the turn fails with a :persist_failed
Normandy.TurnError, and a failed compaction entry is logged and the turn goes on.
@callback delete_session(handle(), session_id()) :: :ok | {:error, term()}
Deletes a session: its turn state, its config template, its membership of the
in-flight index and the entries it wrote that no other session reaches, including a
branch its head left (an append with an explicit parent_id).
Idempotent: deleting a missing session is :ok. A fork keeps its history: on the
backends that copy a fork's entries (InMemory, ETS, Redis) the session's own copy goes;
on those that share ancestor entries between forks (Postgres, Mnesia) an entry stays
while another session's history still reaches it, and concurrent deletes of sessions
that share entries must not both keep them. Deleting does not stop a live
Turn.Server; Normandy.Agents.Turn.Session.delete/1 does both.
@callback fork(handle(), session_id(), from_entry_id :: String.t()) :: {:ok, session_id()} | {:error, term()}
Creates a new session whose history is the branch of session_id up to from_entry_id,
and returns {:ok, new_session_id}.
The original keeps its own history. A session that does not exist is
{:error, :no_such_session}, and an entry that is not on the session's branch is
{:error, :no_such_entry}. The new session has no turn state or config template. Normandy
does not call it; it is for hosts that branch a conversation.
@callback history(handle(), session_id()) :: {:ok, [Normandy.Components.AgentMemory.Entry.t()]} | {:error, term()}
Returns {:ok, entries}, the session's active branch from the oldest entry to the head.
An unknown session is {:ok, []}. The full branch comes back: max_messages is not
applied. Turn.Server rebuilds the memory from it (AgentMemory.from_entries/1) when it
loads a session; {:error, reason} makes the server wait :unavailable and retry.
@callback history_since_compaction(handle(), session_id()) :: {:ok, [Normandy.Components.AgentMemory.Entry.t()]} | {:error, term()}
Optional. Returns {:ok, entries}, the session's active branch from its last
"compaction" entry to the head, or the whole branch when it has none.
Turn.Server stores a snapshot of the whole conversation at each compaction, and
AgentMemory.from_entries/1 reads only the last one and what follows it, so this is all
a restore needs: a store that reads nothing older than that entry lets a session's load
time follow its last context window rather than its number of compactions. restore_history/3 calls it, and
falls back to history/2 for a store that does not export it. An unknown session is
{:ok, []}; a failure is {:error, reason}, as for history/2.
@callback list_resumable(handle()) :: {:ok, [session_id()]} | {:error, term()}
Lists the sessions with a turn in flight: those whose last save_turn_state/4 had
inflight?: true.
Used by Turn.ResumeReaper on node-down to find the eager sessions to restart; the
reaper still checks liveness (whereis) and the saved turn state, so a stale member
is skipped. On the InMemory, ETS, Postgres and Redis stores the cost follows the number
of sessions in flight, not the number stored; the Mnesia store scans every session row.
@callback load_config_template(handle(), session_id()) :: {:ok, term()} | :error | {:error, term()}
Returns {:ok, template}, the term the last save_config_template/3 saved.
Turn.Session.run/2 and Turn.Server read it to rebuild a session's config. :error
means no template is saved: run/2 then returns a :session_unavailable
Normandy.TurnError with reason {:no_config_template, session_id}. A backend failure
is {:error, reason}, never :error.
@callback load_turn_state(handle(), session_id()) :: {:ok, term()} | :error | {:error, term()}
Returns {:ok, state}, the term the last save_turn_state/4 saved for the session.
:error means nothing is saved. A backend failure is {:error, reason}, never :error:
Turn.Server waits :unavailable and retries on it, but starts a session with no saved
state on :error.
@callback save_config_template(handle(), session_id(), template :: term()) :: :ok | {:error, term()}
Saves template for the session, replacing an earlier one, and returns :ok.
The template is the plain-data map Normandy.Agents.ConfigTemplate.from_config/3 builds,
plus the session's :server_options. Turn.Session.run/2 saves it when it starts a
server for a session that has a :config and a :template_provider. Treat it as an
opaque term and return it unchanged from load_config_template/2. {:error, reason} is
returned by that run/2.
@callback save_turn_state(handle(), session_id(), state :: term(), inflight? :: boolean()) :: :ok | {:error, term()}
Saves the session's turn state and, in the same write, whether its turn is in flight.
inflight? is what Turn.Server computes: the session is :eager and its turn has
not ended (status other than :stopped and :failed). list_resumable/1 lists the
sessions whose last save had inflight?: true. The two are written together, so the
index never disagrees with the saved state; a backend that cannot write both
atomically may leave a session listed that is not in flight (the reaper re-checks the
turn state), never the reverse.
Functions
@spec restore_history(module(), handle(), session_id()) :: {:ok, [Normandy.Components.AgentMemory.Entry.t()]} | {:error, term()}
The entries a restore rebuilds a session's memory from (AgentMemory.from_entries/1):
store's history_since_compaction/2 when it exports one, else history/2. Both
rebuild the same memory. Turn.Server and Normandy.Coordination.AgentProcess call it.