The CompositeSessionStore combines a SessionStore backend and an ArtifactStore backend behind a single process. It implements both the SessionStore and ArtifactStore behaviours, delegating each call to the appropriate underlying store. This lets you use one pid (or registered name) for all persistence operations.
Purpose
Most applications need two kinds of storage:
- Session storage -- sessions, runs, and events (structured, queryable data)
- Artifact storage -- large binary blobs like workspace patches and snapshot manifests
These are defined by separate behaviour contracts (SessionStore and ArtifactStore). The CompositeSessionStore wraps one of each, presenting a unified handle to the rest of your system. You can combine any session backend (InMemory, Ecto) with any artifact backend (File, S3) without changing calling code.
Configuration
The composite store requires one SessionStore reference and one started ArtifactStore backend:
| Option | Required | Description |
|---|---|---|
:session_store | Yes | Any SessionStore ref (pid/name or {Module, context}) |
:artifact_store | Yes | A pid or registered name of a running ArtifactStore implementation |
:name | No | Optional registered name for the composite GenServer |
Starting the Store
Start your backend stores first, then pass them to the composite:
alias AgentSessionManager.Adapters.{
CompositeSessionStore,
EctoSessionStore,
S3ArtifactStore
}
# Session backend can be module-backed
session_store = {EctoSessionStore, MyApp.Repo}
# Start the artifact backend
{:ok, s3} = S3ArtifactStore.start_link(
bucket: "my-app-artifacts",
prefix: "asm/artifacts/"
)
# Combine them
{:ok, store} = CompositeSessionStore.start_link(
session_store: session_store,
artifact_store: s3
)With a registered name for supervision trees:
{:ok, store} = CompositeSessionStore.start_link(
session_store: session_store,
artifact_store: s3,
name: MyApp.Store
)Usage
Once started, use the composite store handle with the standard port modules. Session operations are routed to the session backend; artifact operations are routed to the artifact backend.
Session Operations
alias AgentSessionManager.Ports.SessionStore
alias AgentSessionManager.Core.{Session, Run, Event}
# Save and retrieve a session
{:ok, session} = Session.new(%{agent_id: "my-agent"})
:ok = SessionStore.save_session(store, session)
{:ok, fetched} = SessionStore.get_session(store, session.id)
# List sessions with filters
{:ok, sessions} = SessionStore.list_sessions(store, status: :active, limit: 10)
# Run operations
{:ok, run} = Run.new(%{session_id: session.id})
:ok = SessionStore.save_run(store, run)
{:ok, active} = SessionStore.get_active_run(store, session.id)
# Event operations
{:ok, event} = Event.new(%{
type: :message_received,
session_id: session.id,
run_id: run.id,
data: %{content: "Hello!"}
})
{:ok, sequenced} = SessionStore.append_event_with_sequence(store, event)
{:ok, events} = SessionStore.get_events(store, session.id, after: 0, limit: 50)Artifact Operations
alias AgentSessionManager.Ports.ArtifactStore
# Store a workspace patch
:ok = ArtifactStore.put(store, "patch-#{run.id}", patch_binary)
# Retrieve it later
{:ok, data} = ArtifactStore.get(store, "patch-#{run.id}")
# Clean up
:ok = ArtifactStore.delete(store, "patch-#{run.id}")How Delegation Works
The CompositeSessionStore is a GenServer that holds references to both backends in its state:
%{session_store: #PID<0.250.0>, artifact_store: #PID<0.251.0>}When a GenServer.call arrives, the handle_call/3 clauses inspect the message tag and forward to the correct backend using the corresponding port module:
- Session/run/event messages (e.g.,
{:save_session, session}) are forwarded viaSessionStore.save_session(state.session_store, session) - Artifact messages (e.g.,
{:put_artifact, key, data, opts}) are forwarded viaArtifactStore.put(state.artifact_store, key, data, opts)
This means calls go through two GenServer hops: caller -> composite -> backend. For most workloads this overhead is negligible. If you need to bypass it for high-throughput artifact writes, you can call the artifact backend directly while still using the composite for session operations.
Example: Ecto(SQLite) + File (Development)
A lighter-weight setup for local development:
alias AgentSessionManager.Adapters.{
CompositeSessionStore,
EctoSessionStore,
FileArtifactStore
}
{:ok, session_store} = EctoSessionStore.start_link(repo: MyApp.Repo)
{:ok, files} = FileArtifactStore.start_link(root: "priv/dev_artifacts")
{:ok, store} = CompositeSessionStore.start_link(
session_store: session_store,
artifact_store: files
)Example: InMemory + File (Testing)
For tests that need artifact persistence but throwaway session state:
alias AgentSessionManager.Adapters.{
CompositeSessionStore,
InMemorySessionStore,
FileArtifactStore
}
{:ok, mem} = InMemorySessionStore.start_link()
{:ok, files} = FileArtifactStore.start_link(root: System.tmp_dir!())
{:ok, store} = CompositeSessionStore.start_link(
session_store: mem,
artifact_store: files
)Notes and Caveats
Start order matters. The artifact backend must be running before
CompositeSessionStore.start_link/1. For session backends, module refs (e.g.{EctoSessionStore, Repo}) do not require a dedicated process.No cross-store transactions. The composite delegates independently to each backend. There is no atomicity guarantee across a session write and an artifact write. If you need both to succeed or fail together, handle that in your application layer.
Error passthrough. Errors from the underlying backends are returned as-is. A
:storage_errorfrom the SessionStore or S3 propagates directly through the composite without wrapping.Artifact crash isolation. Artifact backend crashes are isolated and returned as
{:error, %Error{code: :storage_error}}; session operations remain available.Supervision. In a supervision tree, start the backends as children before the composite, or use a dedicated supervisor that guarantees start order.
Single caller bottleneck. The composite is a single GenServer, so all calls are serialized through it before fanning out. For very high concurrency, consider calling the backends directly using separate references.