You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Persistent sessions are backed by the SQLite session store. That works well locally and
for a single instance, but it is hard to run docker agent serve api as a long-lived
server where more than one replica may serve requests — Kubernetes, Cloud Run, ECS.
Every replica would need the same SQLite file to resume and mutate the same session,
which requires a shared POSIX filesystem or session affinity; and container filesystems
are ephemeral, so a restart loses the history entirely.
pkg/session already exposes a session.Store interface, and #3771 moved the
file-backed open/recovery path into pkg/session/sqlitestore so the driver stays out of
the code-built embedder surface. That leaves a natural place for a second backend, and I
would like to add PostgreSQL there:
The runtime and API server keep depending only on session.Store. --session-db
behaviour is unchanged; a backend-neutral --session-store <URI> selects an
implementation, and the two are mutually exclusive.
Scope limitation, stated up front: a shared store alone does not make serve api
stateless. SessionManager keeps the live runtime, the SSE event log, the per-session streaming mutex and the follow-up injectors in process, so /steer, /followup, /resume, /elicitation, /events, /status and /queue still need the replica that
owns the turn. What a shared store buys is durable history, replica-independent listing
and new turns, and survival across restarts — with session affinity still assumed at the
deployment layer. I would rather be explicit about that boundary than imply this makes
the server stateless.
Motivation
The concrete case is serve api on Cloud Run with more than one instance. Instances are
disposable with ephemeral filesystems, so session history dies with the instance — not
just on scale-in, but on every deploy. The workarounds are pinning to one instance
(giving up availability) or putting SQLite on network storage, which SQLite documents as
unsupported.
Use cases
serve api on Cloud Run / Kubernetes with N > 1 replicas, where a redeploy or
scale-in must not destroy session history.
A single long-lived serve api whose sessions survive a container restart without a
persistent volume.
Several surfaces (serve api, serve a2a, acp) sharing one session database.
Proposed solution
Layout.pkg/session/postgresstore as a leaf package next to sqlitestore, so pkg/session stays driver-free, plus a small URI→Store factory for the CLI. I would
extend e2e/dependencies_test.go to forbid github.com/jackc/pgx in the embedder
surface so the new driver cannot leak back in.
Concurrency. This needs the most care. Today the append paths build the next position
with an inline (SELECT COALESCE(MAX(position), -1) + 1 FROM session_items WHERE session_id = ?), and AddMessage, AddSummary and AddError run it outside a
transaction. There is no UNIQUE(session_id, position) constraint, only a plain index.
That is safe today because pkg/server's per-session streaming mutex serialises turns within a process — not because the schema enforces it. With a network store that
protection is gone, so the PostgreSQL backend would:
add UNIQUE (session_id, position) on session_items;
run every append in a transaction that first takes SELECT 1 FROM sessions WHERE id = $1 FOR UPDATE, serialising appends per session while different sessions stay
parallel, with a bounded retry on 23505 as a backstop;
keep PersistCompaction atomic, including its ErrOriginMismatch behaviour;
Related: PersistenceObserver calls UpdateMessage once per streaming delta and
rewrites the full body each time, with no throttling. I originally wrote that this is
free against a local file; measuring it, that is not quite right — a local WAL fsync
costs about the same per call as a localhost PostgreSQL round-trip. The costs a network
store actually adds are the accumulated round-trips (one per chunk, so 1–3 ms each on a
same-region managed instance) and the O(K²) payload: persisting a 400-chunk, 8 KB
streamed message means roughly 1.5 MB on the wire, since every delta resends the whole
body. Some debouncing would be needed; I have no strong opinion on whether it belongs in
the store or the observer.
Migrations. The 27 sequential SQLite migrations net out to the current two-table
schema (several add a column a later one drops), so replaying that history has no value.
I would give the backend its own ledger starting at 001_initial_schema = the current
schema, keep the ErrNewerDatabase guard, and run it under pg_advisory_xact_lock so N
replicas starting at once do not race. One deliberate difference from sqlitestore.New:
on migration failure it must fail closed, never move-aside-and-recreate, which on a
shared database would discard other replicas' data.
PR sequence. Three reviewable changes:
A backend-neutral session.Store contract test suite, run against InMemory and
SQLite. Worth having on its own even if the rest is rejected.
pkg/session/postgresstore satisfying the same suite, plus concurrent-append tests
and the dependency-budget guard.
CLI wiring: --session-store and the existing construction sites, --session-db
untouched.
No SQLite→PostgreSQL migration tool in this series; better as a follow-up.
Alternatives
Session affinity, one replica per session. Helps availability; the history still
dies with the instance's filesystem.
SQLite on shared network storage (EFS, Filestore, NFS). Locking over NFS is
unreliable and unsupported by SQLite.
An external-store extension point only, PostgreSQL out of tree. Legitimate, and I
would be happy with it — but the session.Store contract is currently defined only by
two in-tree implementations that do not fully agree, so an external implementer has
nothing to code against. The contract suite is the prerequisite either way.
Adopt a conflict-resistant strategy for session DB migrations from parallel branches #3968 — conflict-resistant session DB migrations. A backend on a fresh, independent
ledger does not inherit the parallel-branch ID collision problem and can adopt whatever
that issue settles on; its "fail closed, never an automatic reset" invariant is also
what a shared database requires.
Additional context
The two in-tree stores do not currently satisfy a single contract: GetSession returns
the live stored object from InMemorySessionStore but a fresh copy from SQLite, and AddSubSession embeds the child in memory but stores it as a separate row in SQLite.
Hence the split into a core suite and a persistent-backend suite rather than forcing
agreement in the same change. (Smaller thing noticed while mapping the schema: AddSession's INSERT omits the starred column that UpdateSession, addSessionTx and PersistCompaction all set, so adding an already-starred session loses the flag — happy
to fix separately.)
Cloud SQL needs no provider-specific code: pgx DSNs already express Unix-socket hosts
and private IPs, so the Auth Proxy stays a deployment concern.
Questions
Is supporting network-accessible persistent session stores desirable at all?
Should PostgreSQL live in-tree, or should core only expose a stronger external-store
extension point?
Is --session-store <URI> the right shape, or would you prefer --session-postgres-dsn?
Is github.com/jackc/pgx/v5 acceptable confined to a leaf package, and how would you
want PostgreSQL integration tests run in CI? I did not find a precedent for a
database-backed test in the repo.
Is the scope boundary acceptable (store only, affinity assumed), or would you want the
runtime-state side addressed in the same effort?
Happy to start with the contract test suite so the abstraction work can be reviewed
before any PostgreSQL code lands.
Overview
Persistent sessions are backed by the SQLite session store. That works well locally and
for a single instance, but it is hard to run
docker agent serve apias a long-livedserver where more than one replica may serve requests — Kubernetes, Cloud Run, ECS.
Every replica would need the same SQLite file to resume and mutate the same session,
which requires a shared POSIX filesystem or session affinity; and container filesystems
are ephemeral, so a restart loses the history entirely.
pkg/sessionalready exposes asession.Storeinterface, and #3771 moved thefile-backed open/recovery path into
pkg/session/sqlitestoreso the driver stays out ofthe code-built embedder surface. That leaves a natural place for a second backend, and I
would like to add PostgreSQL there:
The runtime and API server keep depending only on
session.Store.--session-dbbehaviour is unchanged; a backend-neutral
--session-store <URI>selects animplementation, and the two are mutually exclusive.
Scope limitation, stated up front: a shared store alone does not make
serve apistateless.
SessionManagerkeeps the live runtime, the SSE event log, the per-sessionstreamingmutex and the follow-up injectors in process, so/steer,/followup,/resume,/elicitation,/events,/statusand/queuestill need the replica thatowns the turn. What a shared store buys is durable history, replica-independent listing
and new turns, and survival across restarts — with session affinity still assumed at the
deployment layer. I would rather be explicit about that boundary than imply this makes
the server stateless.
Motivation
The concrete case is
serve apion Cloud Run with more than one instance. Instances aredisposable with ephemeral filesystems, so session history dies with the instance — not
just on scale-in, but on every deploy. The workarounds are pinning to one instance
(giving up availability) or putting SQLite on network storage, which SQLite documents as
unsupported.
Use cases
serve apion Cloud Run / Kubernetes with N > 1 replicas, where a redeploy orscale-in must not destroy session history.
serve apiwhose sessions survive a container restart without apersistent volume.
serve api,serve a2a,acp) sharing one session database.Proposed solution
Layout.
pkg/session/postgresstoreas a leaf package next tosqlitestore, sopkg/sessionstays driver-free, plus a small URI→Store factory for the CLI. I wouldextend
e2e/dependencies_test.goto forbidgithub.com/jackc/pgxin the embeddersurface so the new driver cannot leak back in.
Concurrency. This needs the most care. Today the append paths build the next position
with an inline
(SELECT COALESCE(MAX(position), -1) + 1 FROM session_items WHERE session_id = ?), andAddMessage,AddSummaryandAddErrorrun it outside atransaction. There is no
UNIQUE(session_id, position)constraint, only a plain index.That is safe today because
pkg/server's per-sessionstreamingmutex serialises turnswithin a process — not because the schema enforces it. With a network store that
protection is gone, so the PostgreSQL backend would:
UNIQUE (session_id, position)onsession_items;SELECT 1 FROM sessions WHERE id = $1 FOR UPDATE, serialising appends per session while different sessions stayparallel, with a bounded retry on
23505as a backstop;PersistCompactionatomic, including itsErrOriginMismatchbehaviour;UpdateMessagelast-writer-wins, matching SQLite.Related:
PersistenceObservercallsUpdateMessageonce per streaming delta andrewrites the full body each time, with no throttling. I originally wrote that this is
free against a local file; measuring it, that is not quite right — a local WAL fsync
costs about the same per call as a localhost PostgreSQL round-trip. The costs a network
store actually adds are the accumulated round-trips (one per chunk, so 1–3 ms each on a
same-region managed instance) and the O(K²) payload: persisting a 400-chunk, 8 KB
streamed message means roughly 1.5 MB on the wire, since every delta resends the whole
body. Some debouncing would be needed; I have no strong opinion on whether it belongs in
the store or the observer.
Migrations. The 27 sequential SQLite migrations net out to the current two-table
schema (several add a column a later one drops), so replaying that history has no value.
I would give the backend its own ledger starting at
001_initial_schema= the currentschema, keep the
ErrNewerDatabaseguard, and run it underpg_advisory_xact_lockso Nreplicas starting at once do not race. One deliberate difference from
sqlitestore.New:on migration failure it must fail closed, never move-aside-and-recreate, which on a
shared database would discard other replicas' data.
PR sequence. Three reviewable changes:
session.Storecontract test suite, run against InMemory andSQLite. Worth having on its own even if the rest is rejected.
pkg/session/postgresstoresatisfying the same suite, plus concurrent-append testsand the dependency-budget guard.
--session-storeand the existing construction sites,--session-dbuntouched.
No SQLite→PostgreSQL migration tool in this series; better as a follow-up.
Alternatives
dies with the instance's filesystem.
unreliable and unsupported by SQLite.
would be happy with it — but the
session.Storecontract is currently defined only bytwo in-tree implementations that do not fully agree, so an external implementer has
nothing to code against. The contract suite is the prerequisite either way.
Related issues
pkg/session/sqlitestore; theextension point this builds on.
ledger does not inherit the parallel-branch ID collision problem and can adopt whatever
that issue settles on; its "fail closed, never an automatic reset" invariant is also
what a shared database requires.
Additional context
The two in-tree stores do not currently satisfy a single contract:
GetSessionreturnsthe live stored object from
InMemorySessionStorebut a fresh copy from SQLite, andAddSubSessionembeds the child in memory but stores it as a separate row in SQLite.Hence the split into a core suite and a persistent-backend suite rather than forcing
agreement in the same change. (Smaller thing noticed while mapping the schema:
AddSession's INSERT omits thestarredcolumn thatUpdateSession,addSessionTxandPersistCompactionall set, so adding an already-starred session loses the flag — happyto fix separately.)
Cloud SQL needs no provider-specific code:
pgxDSNs already express Unix-socket hostsand private IPs, so the Auth Proxy stays a deployment concern.
Questions
extension point?
--session-store <URI>the right shape, or would you prefer--session-postgres-dsn?acceptable, and how should it relate to whatever Adopt a conflict-resistant strategy for session DB migrations from parallel branches #3968 settles on?
github.com/jackc/pgx/v5acceptable confined to a leaf package, and how would youwant PostgreSQL integration tests run in CI? I did not find a precedent for a
database-backed test in the repo.
runtime-state side addressed in the same effort?
Happy to start with the contract test suite so the abstraction work can be reviewed
before any PostgreSQL code lands.