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

handle()

@type handle() :: term()

session_id()

@type session_id() :: String.t()

Callbacks

append_entry(handle, session_id, t)

@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.

delete_session(handle, session_id)

@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.

fork(handle, session_id, from_entry_id)

@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.

history(handle, session_id)

@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.

history_since_compaction(handle, session_id)

(optional)
@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.

list_resumable(handle)

@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.

load_config_template(handle, session_id)

@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.

load_turn_state(handle, session_id)

@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.

save_config_template(handle, session_id, template)

@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.

save_turn_state(handle, session_id, state, inflight?)

@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

restore_history(store, handle, session_id)

@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.