From 97da2c756b9116b586fba6452347cdf952538ad8 Mon Sep 17 00:00:00 2001 From: Kiran Muddukrishna Date: Tue, 27 Oct 2026 13:35:27 +1100 Subject: [PATCH 1/3] docs: state which connection paths through a pooler pg-sprite supports Operators meet a pooler URL first on most hosted platforms, and the docs did not say where pg-sprite should connect or which pooling modes it supports. --- README.md | 6 ++++++ docs/limitations.md | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/README.md b/README.md index 48f2ebcb..cbd67a5e 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,12 @@ refusal — never a silently wrong or incomplete result: and environments it cannot run against. The shadow build also refuses a statement the copy cannot carry: a `USING` expression it cannot reproduce, or a dropped-and-re-added column it cannot fill. +- **Connection poolers**: point pg-sprite directly at the primary; the + application keeps whatever pooling it uses. pg-sprite's own connection + through a pooler is unsupported, except Supabase's Supavisor session + endpoint for native schema changes, and a transaction-mode pooler is + refused with `ErrNoSessionAffinity` + ([connections and poolers](docs/limitations.md#connections-and-poolers)). - **Row security** is included in exports when present, with reviewable before/after differences. The [atomic executor](docs/atomic-row-security.md) applies RLS-only changes to existing tables through `migrate --desired` or the Go API. Only the Go API supports binding execution to diff --git a/docs/limitations.md b/docs/limitations.md index 445e6e55..39950d4a 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -77,6 +77,30 @@ follows `bytea_output`. The copy, the verifier, and the catch-up each pin both s (`3` and `hex`) in their own sessions, so such a conversion lands the same text from all three whatever the database or role is configured with. +## Connections and poolers + +pg-sprite keeps state on each connection's PostgreSQL session: the execution bounds +(`lock_timeout`, `statement_timeout`) every statement runs under, and the per-table +advisory lock that keeps a second run off the same table. Each of its connections must +therefore stay on one session for its whole life. + +This constrains only pg-sprite's own connection. The application keeps whatever pooling +it uses — a client-side pool, or a server-side pooler in session, transaction, or +statement mode — and copy-and-swap reads the application's writes from the WAL through +logical decoding, whichever pooler carried them. + +| pg-sprite connects through | Support | +| --- | --- | +| A direct connection to the primary | Supported, and the recommended deployment. Every schema change path, copy-and-swap included, is tested this way. | +| Supabase's Supavisor session endpoint | Supported for the native schema changes listed in [supabase.md](supabase.md#what-works-today). Copy-and-swap through it is unsupported. | +| PgBouncer, or any other server-side pooler, in session mode | Unsupported. Connect directly to the primary. | +| Any pooler in transaction mode | Unsupported, and refused before any work runs: opening the pool fails with `ErrNoSessionAffinity`, whose message names the remedy. | +| Any pooler in statement mode | Unsupported. Connect directly to the primary. | + +The application's pooler and pg-sprite share the server's `max_connections`, which has +to cover the application's backends plus pg-sprite's pool, its dedicated sessions, and, +for copy-and-swap, its replication connections. + ## Declarative model boundaries The declarative front door (desired-state files, `diff`, and schema export) From ba48f08f891338d6437d49b3469dd5926de400e1 Mon Sep 17 00:00:00 2001 From: Kiran Muddukrishna Date: Tue, 27 Oct 2026 13:39:31 +1100 Subject: [PATCH 2/3] docs: explain session affinity and how pg-sprite checks for it --- docs/limitations.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/limitations.md b/docs/limitations.md index 39950d4a..d164456c 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -82,7 +82,19 @@ three whatever the database or role is configured with. pg-sprite keeps state on each connection's PostgreSQL session: the execution bounds (`lock_timeout`, `statement_timeout`) every statement runs under, and the per-table advisory lock that keeps a second run off the same table. Each of its connections must -therefore stay on one session for its whole life. +therefore stay on one session for its whole life. This is **session affinity**: every +statement sent on a connection runs on the same PostgreSQL backend, so a setting or lock +taken by one statement is still there for the next. + +A direct connection always has it. A transaction-mode or statement-mode pooler does not: +it may hand the connection's next transaction to a different backend, one another client +may have just used, so pg-sprite's timeouts could silently vanish and two runs could both +believe they hold the same table lock. pg-sprite checks rather than assumes. When it opens +its pool it reads back the timeouts it set, and on the pool and again on the table lock's +own connection it takes a throwaway advisory lock, then confirms from a second connection +that the first still holds it and that the second cannot take it. A connection that drops +the timeouts is refused with `ErrSessionSettingsDiscarded`, and one that fails the lock +check with `ErrNoSessionAffinity`, before any schema change runs. This constrains only pg-sprite's own connection. The application keeps whatever pooling it uses — a client-side pool, or a server-side pooler in session, transaction, or From f8c250ae1a5f47c9b7d358ace4f2fc12018a5439 Mon Sep 17 00:00:00 2001 From: Kiran Muddukrishna Date: Wed, 28 Oct 2026 13:50:25 +1100 Subject: [PATCH 3/3] docs: qualify pooler detection and name both refusal causes The affinity check is evidence, not certainty: an idle transaction-mode pooler can pass it. Statement mode is refused since the probe reads its rejected BEGIN as no affinity. --- README.md | 4 ++-- docs/limitations.md | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index cbd67a5e..68477212 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,8 @@ refusal — never a silently wrong or incomplete result: - **Connection poolers**: point pg-sprite directly at the primary; the application keeps whatever pooling it uses. pg-sprite's own connection through a pooler is unsupported, except Supabase's Supavisor session - endpoint for native schema changes, and a transaction-mode pooler is - refused with `ErrNoSessionAffinity` + endpoint for native schema changes. Startup checks usually refuse a + transaction-mode pooler, but passing them does not make it supported ([connections and poolers](docs/limitations.md#connections-and-poolers)). - **Row security** is included in exports when present, with reviewable before/after differences. The [atomic executor](docs/atomic-row-security.md) applies RLS-only changes to existing tables diff --git a/docs/limitations.md b/docs/limitations.md index d164456c..438f7ee4 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -96,6 +96,11 @@ that the first still holds it and that the second cannot take it. A connection t the timeouts is refused with `ErrSessionSettingsDiscarded`, and one that fails the lock check with `ErrNoSessionAffinity`, before any schema change runs. +These checks guard against session loss they can observe; they cannot prove its absence. +An idle transaction-mode pooler with spare backends can keep a connection on one backend +for the length of the checks and move it later, so a clean start does not make a pooler +path safe. Connect directly to the primary. + This constrains only pg-sprite's own connection. The application keeps whatever pooling it uses — a client-side pool, or a server-side pooler in session, transaction, or statement mode — and copy-and-swap reads the application's writes from the WAL through @@ -106,8 +111,8 @@ logical decoding, whichever pooler carried them. | A direct connection to the primary | Supported, and the recommended deployment. Every schema change path, copy-and-swap included, is tested this way. | | Supabase's Supavisor session endpoint | Supported for the native schema changes listed in [supabase.md](supabase.md#what-works-today). Copy-and-swap through it is unsupported. | | PgBouncer, or any other server-side pooler, in session mode | Unsupported. Connect directly to the primary. | -| Any pooler in transaction mode | Unsupported, and refused before any work runs: opening the pool fails with `ErrNoSessionAffinity`, whose message names the remedy. | -| Any pooler in statement mode | Unsupported. Connect directly to the primary. | +| Any pooler in transaction mode | Unsupported. Connect directly to the primary. The startup checks usually refuse it before any work runs, with `ErrSessionSettingsDiscarded` when the pooler drops the timeouts or `ErrNoSessionAffinity` when it moves the connection; the tests pin `ErrNoSessionAffinity` for PgBouncer and Supavisor in transaction mode. A pooler that passes the checks is still unsupported. | +| Any pooler in statement mode | Unsupported. Connect directly to the primary. Opening the pool refuses it with `ErrNoSessionAffinity`: the pooler rejects the check's transaction, which the tests pin for PgBouncer in statement mode. | The application's pooler and pg-sprite share the server's `max_connections`, which has to cover the application's backends plus pg-sprite's pool, its dedicated sessions, and,