From aab1c42fdb1a5434bf120b8d50e50ea57e9afa3a Mon Sep 17 00:00:00 2001 From: Kiran Muddukrishna Date: Sat, 31 Oct 2026 12:25:13 +1100 Subject: [PATCH 1/3] docs: explain how a connection maps to a session and a backend --- docs/session-affinity.md | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/docs/session-affinity.md b/docs/session-affinity.md index f3128fa0..672263c4 100644 --- a/docs/session-affinity.md +++ b/docs/session-affinity.md @@ -12,6 +12,8 @@ on session affinity, how pg-sprite proves it, what it refuses, and how to connec - [Is it mandatory?](#is-it-mandatory) - [What depends on one session](#what-depends-on-one-session) + - [Connection, session and backend](#connection-session-and-backend) + - [What pg-sprite keeps in the session](#what-pg-sprite-keeps-in-the-session) - [What breaks without it](#what-breaks-without-it) - [How pg-sprite proves it](#how-pg-sprite-proves-it) - [Which endpoints pass](#which-endpoints-pass) @@ -33,8 +35,34 @@ affinity again on its own connection. ## What depends on one session -Each item below is state PostgreSQL keeps **per server session**, not per client connection. -If the connection's next statement lands on a different backend, the state is not there. +### Connection, session and backend + +PostgreSQL starts one **backend** process for each connection it accepts, and that backend +*is* the **session**: everything set with `SET`, every session-level lock, and every +prepared statement lives in its memory until the connection closes. `pg_backend_pid()` names +it. What varies is how many client connections share a backend, and for how long: + +| Path | Client connection → backend | Session state survives across transactions? | +|---|---|---| +| Direct | 1 : 1 for the connection's whole life | Yes | +| Session-mode pooler | 1 : 1 while the client stays connected; the backend is handed to the next client after it disconnects | Yes | +| Transaction-mode pooler | bound for one transaction, then any free backend | No | +| Statement-mode pooler | bound for one statement, then any free backend | No | + +```diagram + direct / session mode transaction mode + ───────────────────── ──────────────── + client conn ─────────▶ backend A client conn ─ tx 1 ─▶ backend A + tx 1 runs on A ─ tx 2 ─▶ backend B + tx 2 runs on A ─ tx 3 ─▶ backend A, after + (A keeps the SETs and locks) another client used it +``` + +### What pg-sprite keeps in the session + +So "the connection" pg-sprite holds is only as stable as the backend behind it. Each item +below is state PostgreSQL keeps **per backend**, not per client connection: if the +connection's next statement lands on a different backend, the state is not there. | Session state | What pg-sprite uses it for | Invariant | |---|---|---| From 33ec99d76b408c551fed0aea4f250de5e88dd37c Mon Sep 17 00:00:00 2001 From: Kiran Muddukrishna Date: Sat, 31 Oct 2026 12:27:22 +1100 Subject: [PATCH 2/3] docs: word the pooler rows as how long a backend is bound --- docs/session-affinity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/session-affinity.md b/docs/session-affinity.md index 672263c4..bd7f9f80 100644 --- a/docs/session-affinity.md +++ b/docs/session-affinity.md @@ -46,8 +46,8 @@ it. What varies is how many client connections share a backend, and for how long |---|---|---| | Direct | 1 : 1 for the connection's whole life | Yes | | Session-mode pooler | 1 : 1 while the client stays connected; the backend is handed to the next client after it disconnects | Yes | -| Transaction-mode pooler | bound for one transaction, then any free backend | No | -| Statement-mode pooler | bound for one statement, then any free backend | No | +| Transaction-mode pooler | Bound to a backend only for the length of one transaction; the next transaction can run on any free backend | No | +| Statement-mode pooler | Bound to a backend only for the length of one statement; the next statement can run on any free backend | No | ```diagram direct / session mode transaction mode From 4a193c99172e2f50f3f10c2fd0d5561b10595e71 Mon Sep 17 00:00:00 2001 From: Kiran Muddukrishna Date: Mon, 2 Nov 2026 15:46:12 +1100 Subject: [PATCH 3/3] docs: tighten the session-affinity connection model --- docs/README.md | 2 +- docs/session-affinity.md | 29 +++++++++++++++++------------ 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/docs/README.md b/docs/README.md index c296d1ca..4dcf1e38 100644 --- a/docs/README.md +++ b/docs/README.md @@ -54,7 +54,7 @@ Aurora-only. Why that combination is the product is [vision.md](vision.md); star | [lint-report.md](lint-report.md) | The **lint report contract** — the versioned JSON shape `pg-sprite lint` emits for offline CI gating: finding fields (verbatim SQL, line/column), the codes table, severities and exit behavior, the offline-conservatism rules, and how the contract versions relative to the plan report. | | [suggest-report.md](suggest-report.md) | The **suggest report contract** — the versioned JSON shape `pg-sprite suggest` emits for offline advice: the typed caveat vocabulary (what changes about how you must run a safer form, and what a failed step leaves behind), the typed guidance codes for rewrites the planner cannot construct, and the operation → safer form → caveats table (pinned by test). | | [progress-report.md](progress-report.md) | The **progress report contract** — the versioned JSON snapshot a caller receives when polling a running change through the `*WithProgress` entry points: phases and operations vocabularies, the terminal-freeze rule, server-observed and engine-measured work counters, and polling semantics (pinned by test). | -| [session-affinity.md](session-affinity.md) | **Why pg-sprite needs one PostgreSQL session per connection** — the session state it depends on (timeouts, the table lock, a catalog-first `search_path`), what breaks behind a transaction- or statement-mode pooler, the session-affinity proof every pool runs, which endpoints pass, and how to connect. | +| [session-affinity.md](session-affinity.md) | **Why pg-sprite needs one PostgreSQL session per connection** — how a client connection maps to a session and a server backend in each pooling mode, the session state it depends on (timeouts, the table lock, a catalog-first `search_path`), what breaks behind a transaction- or statement-mode pooler, the session-affinity proof every pool runs, which endpoints pass, and how to connect. | | [engine-role.md](engine-role.md) | The **engine-role provisioning contract** — the tiered minimum access a PostgreSQL user needs to run schema changes against tables it does not own: role membership for owner-gated DDL, schema `CREATE` for index builds and shadow objects, `SET ROLE` for owner-correct shadow creation, replication access for CDC, and the explicit list of powers the engine role must *not* have. Preflight refusals name the missing `GRANT` and point here. | | [invalid-index-recovery.md](invalid-index-recovery.md) | The **operator runbook** for the native path's invalid-index outcomes — an invalid index the executor found or left. How the typed states are told apart, which ones `RebuildAbandonedIndex` recovers on its own (and the lock-and-identity proof that makes its drop safe), when the entry may be another actor's healthy in-flight build, and what to check when the executor could prove nothing. | | [testing.md](testing.md) | The **test-suite guide** — how to run the suite (unit, per-major, all supported majors, compose database), current coverage, the remaining executor-phase test obligations, and the vanilla-PostgreSQL-matrix vs real-Aurora validation boundary. | diff --git a/docs/session-affinity.md b/docs/session-affinity.md index bd7f9f80..27a0c554 100644 --- a/docs/session-affinity.md +++ b/docs/session-affinity.md @@ -37,15 +37,18 @@ affinity again on its own connection. ### Connection, session and backend -PostgreSQL starts one **backend** process for each connection it accepts, and that backend -*is* the **session**: everything set with `SET`, every session-level lock, and every -prepared statement lives in its memory until the connection closes. `pg_backend_pid()` names -it. What varies is how many client connections share a backend, and for how long: - -| Path | Client connection → backend | Session state survives across transactions? | +PostgreSQL starts one server process, a **backend**, for each connection it accepts. (On this +page "backend" always means that process, not pg-sprite's execution backend, such as native +or copy-and-swap.) The backend *is* the **session**: it owns everything set with `SET`, every +session-level lock, and every prepared statement. Each lasts until it is released or reset +(`RESET`, `DISCARD ALL`) or the connection closes, and a `SET` inside a transaction that +rolls back is undone with it. `pg_backend_pid()` names the backend. What varies is how many +client connections share a backend, and for how long: + +| Path | Client connection → server backend | Session state survives across transactions? | |---|---|---| | Direct | 1 : 1 for the connection's whole life | Yes | -| Session-mode pooler | 1 : 1 while the client stays connected; the backend is handed to the next client after it disconnects | Yes | +| Session-mode pooler | 1 : 1 while the client stays connected; after it disconnects, the pooler resets the backend's session state (PgBouncer's `server_reset_query`, `DISCARD ALL` by default) and hands it to the next client | Yes | | Transaction-mode pooler | Bound to a backend only for the length of one transaction; the next transaction can run on any free backend | No | | Statement-mode pooler | Bound to a backend only for the length of one statement; the next statement can run on any free backend | No | @@ -66,7 +69,7 @@ connection's next statement lands on a different backend, the state is not there | Session state | What pg-sprite uses it for | Invariant | |---|---|---| -| `lock_timeout` and `statement_timeout`, sent as startup parameters and read back by `NewPool` | Bound every lock wait and every statement, so a queued `ALTER` can never sit at the head of the lock queue and block every reader and writer of the table | [LK-2](invariants.md#lk-2--exactly-one-access-exclusive-window-and-every-strong-lock-is-bounded) | +| `lock_timeout` and `statement_timeout`, sent as startup parameters, set again with `SET` on every new connection, and read back by `NewPool` | Bound every lock wait and every statement, so a queued `ALTER` can never sit at the head of the lock queue and block every reader and writer of the table | [LK-2](invariants.md#lk-2--exactly-one-access-exclusive-window-and-every-strong-lock-is-bounded) | | A session-level advisory lock on a dedicated connection (`pg_try_advisory_lock(0x70677370, hashtext(…))`) | Make sure at most one pg-sprite run works on a table at a time | [LK-1](invariants.md#lk-1--at-most-one-migration-runs-per-table) | | A catalog-first `search_path`, set once per physical connection | Stop a user schema from shadowing `pg_class`, `hashtext` and other catalog names in the engine's unqualified reads | [CO-9](invariants.md#co-9--decision-reads-resolve-to-the-real-catalog) | @@ -76,6 +79,8 @@ session itself has to be stable. ## What breaks without it +This follows the transaction-mode row of the +[table above](#connection-session-and-backend), with pg-sprite's session state attached. Behind a **transaction-mode** pooler, the client connection is bound to a backend only for the length of one transaction. Between transactions the pooler can hand the next statement to any backend. Example: one pg-sprite connection whose session state landed on backend A, followed @@ -96,10 +101,10 @@ by an `ALTER` in its next transaction: forever) ``` -(pg-sprite sends its timeouts as startup parameters. A transaction-mode PgBouncer rejects -startup parameters it doesn't track, or is configured to drop them; `NewPool` reads the -values back and refuses a connection that doesn't carry them, with -`ErrSessionSettingsDiscarded`. The proof below covers the case where the settings did land, +(pg-sprite sends its timeouts as startup parameters and sets them again with `SET` on every +new connection, because a pooler in any mode may drop startup parameters it is configured to +ignore. `NewPool` then reads the values back and refuses a connection that doesn't carry them, +with `ErrSessionSettingsDiscarded`. The proof below covers the case where the settings did land, but only on one backend.) Three things went wrong, and none is visible to the client: