Skip to content

fix: keep bare [SET/CREATE TEMP, SELECT] batch on the shared client on a transaction pooler (#173) - #174

Merged
aesslinger merged 2 commits into
mainfrom
fix/173-bare-batch-session-state-pooler
Oct 23, 2026
Merged

aesslinger merged 2 commits into
mainfrom
fix/173-bare-batch-session-state-pooler

Conversation

@aesslinger

Copy link
Copy Markdown
Collaborator

Summary

PR #172 (#169 fix) routed a non-in_transaction result-set query in a batch to the one-shot pinned path (exec_query_pooled_txn) on a separate connection. A bare batch (no session_id, no BEGIN) that sets session-local state in an earlier non-result-set statement — a session SET (not SET LOCAL) or a CREATE TEMP TABLE outside a transaction — then runs a result-set SELECT silently loses that state, because the SELECT runs on a fresh one-shot connection that never saw the SET/temp table (the one-shot applies only the host's schema via SET LOCAL and does not replay prior session state).

Result: the SELECT returns the wrong precision (GUC case) or ERROR: relation "t" does not exist (temp-table case). No error surfaces about the lost state. On main (rc.11, before #172), run_batch_in_session ran every query via exec_query_on_client on one shared pg_client, so a SET then SELECT in a single bare batch reliably shared state (same connection, same backend). #172 broke that for the pooler !in_transaction case.

Closes #173.

Fix

Detect session-local state set earlier in the bare batch and keep the result-set SELECT on the shared batch client with a column-type pre-resolve, instead of the one-shot.

  • New pure classifier sets_session_local_state (src/handlers/query.rs): matches a session SET (without LOCAL) or a CREATE TEMP[ORARY] TABLE. Transaction-scoped forms (SET LOCAL) and permanent CREATE TABLE are excluded — the in_transaction branch already keeps the former on the shared client, and the latter is visible to any connection.
  • A sticky session_state_set flag in run_batch_in_session flips after such a statement; the routing condition becomes in_transaction || session_state_set, reusing the existing in_transaction pre-resolve path verbatim.

Why no regression to #166/#168/#160/#163/#169

The fix only adds a third routing condition that routes to the same exec_query_on_client(pre_resolve_types=true) path the in_transaction branch already uses. It never alters:

Warming CachedTypeInfo.types via prepare means query_typed's get_type is a cache hit and typeinfo_statement never fires mid-drain on any connection — the identical mechanism #166/#168 and the #169 in_transaction branch rely on, so no new hang/deadlock is introduced.

Scope

Restores the direct-PostgreSQL + pooler_mode=transaction config (one TCP connection = one backend, held) and the temp-table case — the clearest new regressions #173 identifies. On a real PgBouncer transaction pooler, a session SET between autocommit statements is already lost to backend reassignment on main, so the pure-GUC case is pre-existing there (not a new regression) and stays out of scope; the existing session-pinned BEGIN/COMMIT path (covered by wide_enum_table_returns_rows_via_session_pinned_batch_on_pooler) remains the PgBouncer-canonical workaround.

Tests

  • 9 unit tests for sets_session_local_state (src/handlers/query_tests.rs).
  • 2 live regression tests (src/client_live_tests.rs): temp_table_visible_in_bare_batch_on_pooler and session_set_visible_in_bare_batch_on_pooler (probed via SELECT current_setting('extra_float_digits') returning "3" vs the lost-SET default "1").

Verification

…n a transaction pooler (#173)

PR #172 (#169 fix) routed a non-`in_transaction` result-set query in a
batch to the one-shot pinned path (`exec_query_pooled_txn`) on a separate
connection. A bare batch (no `session_id`, no `BEGIN`) that sets
session-local state in an earlier non-result-set statement — a session
`SET` (not `SET LOCAL`) or a `CREATE TEMP TABLE` outside a transaction —
then runs a result-set `SELECT` silently loses that state, because the
SELECT runs on a fresh one-shot connection that never saw the `SET`/temp
table (the one-shot applies only the host's `schema` via `SET LOCAL` and
does not replay prior session state).

Result: the SELECT returns the wrong precision (GUC case) or
`ERROR: relation "t" does not exist` (temp-table case). No error surfaces
about the lost state. On `main` (rc.11, before #172), `run_batch_in_session`
ran every query via `exec_query_on_client` on one shared `pg_client`, so a
`SET` then `SELECT` in a single bare batch reliably shared state (same
connection, same backend). #172 broke that for the pooler `!in_transaction`
case.

Fix: detect session-local state set earlier in the bare batch and keep the
result-set SELECT on the shared batch client with a column-type pre-resolve,
instead of the one-shot. A new pure classifier `sets_session_local_state`
matches a session `SET` (without `LOCAL`) or a `CREATE TEMP[ORARY] TABLE`;
transaction-scoped forms (`SET LOCAL`) and permanent `CREATE TABLE` are
excluded — the `in_transaction` branch already keeps the former on the
shared client, and the latter is visible to any connection. A sticky
`session_state_set` flag in `run_batch_in_session` flips after such a
statement, and the routing condition `in_transaction || session_state_set`
reuses the existing `in_transaction` pre-resolve path verbatim.

No regression to #166/#168/#160/#163/#169: the fix only adds a third
routing condition that routes to the same `exec_query_on_client(
pre_resolve_types=true)` path the `in_transaction` branch already uses. It
never alters the direct-connection #166 path (`!is_transaction_pooler()`),
the #164 one-shot path, or the #169 `in_transaction` path. Warming
`CachedTypeInfo.types` via `prepare` means `query_typed`'s `get_type` is a
cache hit and `typeinfo_statement` never fires mid-drain on any connection
— the identical mechanism #166/#168 and the #169 `in_transaction` branch
rely on, so no new hang/deadlock is introduced.

Scope: restores the direct-PostgreSQL + `pooler_mode=transaction` config
(one TCP connection = one backend, held) and the temp-table case — the
clearest *new* regressions #173 identifies. On a real PgBouncer transaction
pooler, a session `SET` between autocommit statements is already lost to
backend reassignment on `main`, so the pure-GUC case is pre-existing there
(not a new regression) and stays out of scope; the existing session-pinned
`BEGIN`/`COMMIT` path (covered by `wide_enum_table_returns_rows_via_session_
pinned_batch_on_pooler`) remains the PgBouncer-canonical workaround.

Adds 9 unit tests for `sets_session_local_state` and two live regression
tests: `temp_table_visible_in_bare_batch_on_pooler` (the temp-table case)
and `session_set_visible_in_bare_batch_on_pooler` (probed via
`SELECT current_setting('extra_float_digits')` returning "3" vs the lost-SET
default "1").

Verified: 434 unit + 13 live `client_live_tests` (incl. all #166/#168/#160/
#163/#169 guards) pass against a live PostgreSQL; 38/39 `live_db`
end-to-end (the one failure is `extension "vector" is not available`, a
pre-existing fixture gap needing the pgvector-test container). clippy
clean, fmt applied.
@aesslinger aesslinger self-assigned this Oct 23, 2026
@aesslinger aesslinger added bug Something isn't working prerelease:rc Version suggestion targets a release candidate labels Oct 23, 2026
@github-actions

Copy link
Copy Markdown

Version suggestion

Based on this PR's title (fix) and the prerelease:rc label:

Current 1.0.0-rc.11
Suggested next tag v1.0.0-rc.12

This is informational only — no tag or release is created automatically yet.

@kilo-code-bot

kilo-code-bot Bot commented Oct 23, 2026

Copy link
Copy Markdown

Kilo Code Review could not run — your account is out of credits.

Add credits or switch to a free model to enable reviews on this change.

Pre-handoff ensemble review of #174 surfaced four verified findings:

1. `CREATE [GLOBAL|LOCAL] TEMP[ORARY] TABLE` was a false negative. PostgreSQL
   accepts an optional deprecated `GLOBAL`/`LOCAL` qualifier before
   `TEMP`/`TEMPORARY` (both still create a session-scoped temp table — verified
   live), but the 3-keyword lookahead classified `CREATE LOCAL TEMP TABLE ...`
   as non-session-local, so a later `SELECT * FROM t` in the bare batch still
   hit the exact #173 `relation does not exist` regression. Extend the
   `CREATE` arm to 4 keywords and skip the `GLOBAL`/`LOCAL` noise word
   (mirrors how `transaction_effect` skips optional `WORK`/`TRANSACTION`).

2. `SET TRANSACTION` / `SET CONSTRAINTS` were false positives —
   transaction-control verbs, not GUCs, that set no session state a later
   SELECT depends on. Exclude them alongside `SET LOCAL`.

3. `session_state_set` flipped on the query text alone, ignoring
   `outcome.is_ok()` — a `SET`/`CREATE TEMP TABLE` the server rejected
   established no session state but still steered a later SELECT onto the
   shared client. Gate on success, mirroring the sibling `in_transaction`
   update two lines above.

4. Docs drift: the `exec_query_on_client` pre-resolve comment listed "two
   cases where a plain `prepare` is safe" and said the no-open-transaction
   pooler path "can't safely `prepare`" — but #173 now routes that path to
   the shared-client pre-resolve. Rewrote the comment to list three cases and
   document the #173 case's real-PgBouncer tradeoff honestly (the prepare is
   non-fatal; safe on the direct+`pooler_mode=transaction` config the PR
   targets). Also fixed "two cases"→"three cases" in the routing comment and
   the `exec_query` batch-path description, and corrected the
   `sets_session_local_state` doc (it listed `SET SESSION AUTHORIZATION` as
   "may be missed" but the classifier catches it).

Adds a unit test for the `GLOBAL`/`LOCAL` qualifier and folds `SET
TRANSACTION`/`SET CONSTRAINTS` into the existing exclusion test.

Verified: 435 unit + 13 live `client_live_tests` (incl. all #166/#168/#160/
#163/#169 guards) pass against a live PostgreSQL; clippy clean; fmt applied.
@aesslinger
aesslinger merged commit 542b823 into main Oct 23, 2026
7 of 8 checks passed
@aesslinger
aesslinger deleted the fix/173-bare-batch-session-state-pooler branch October 23, 2026 16:23
aesslinger added a commit that referenced this pull request Oct 23, 2026
…n't starve them (#167) (#176)

* fix: route ping/connect/cancel to dedicated lanes so stuck queries don't starve them (#167)

The plugin had 4 workers fed by a single shared queue; every RPC type
shared them. When 4 calls were stuck (any slow query up to the host's 120s
call timeout), every subsequent RPC queued behind them — a `ping` or
`test_connection` to a *different* database, even the `cancel`
notification that's meant to relieve the stuck workers. The module comment
claimed "a slow query on one connection does not block a `ping`/metadata
call on another," but that isolation was by-worker-slot, and it broke at
saturation (>=4 stuck calls). Worst: the `cancel` notification routed
through the same 4-worker queue, so 4 stuck workers deadlocked the relief
valve — only quitting Tabularis freed them.

Fix: route by method into three lanes before a worker pulls the line.

- cancel lane (1 worker): `cancel` notifications run on a dedicated worker
  that bypasses the general queue. When 4 general workers are stuck, the
  cancel still fires `pg_cancel_backend` so the stuck workers' awaits
  resolve and free up — instead of queuing behind them. The relief valve is
  no longer stuck behind what it relieves.
- lifecycle lane (2 workers): `ping`/`test_connection`/`initialize`/
  `shutdown` run on a dedicated lane, so a health check or new connect to
  database B isn't blocked by 4 slow queries against database A. Two
  workers (not one) so a single stuck `ping` (a `SELECT 1` against an
  unreachable DB) doesn't starve the lane.
- general lane (4 workers): unchanged — metadata, execute_query, CRUD,
  DDL, BLOB.

`run_reader` peeks the method (`rpc::peek_method`) and routes; `handle_line`
stays the single source of truth for parse + dispatch + response. The
`SHUTDOWN_GRACE` drain (#155) now drains all lanes' worker handles the same
way (timeout_at + abort()), so the orphan-on-quit behavior is preserved.

A cancel-driven worker-abort (Layer 3: `select!`-on-cancel dropping the
in-flight `handle_line` future) was implemented and tested, then reverted.
The `cancel_notification_stops_the_server_side_statement` live test proved
a cancelled query MUST return its `57014` error response (the documented
`#126` contract — the host reads it); the worker-abort dropped the future
before it could produce that response, suppressing it and hanging the
host. The "server ignores `pg_cancel_backend`" gap is left as a
documented pre-existing limit (matching the builtin) rather than traded
for a contract break — see `cancel::cancel`'s doc comment.

Adds 6 `peek_method` unit tests and 2 `tests/worker_starvation.rs`
integration tests: a `ping` to another DB returns past 4 hung general
queries (Layer 2); a `cancel` notification isn't queued behind 4 hung
general workers (Layer 1).

Verified empirically against a live PostgreSQL: 13/13 `client_live_tests`
(the full #160->#173 chain — #166/#168 direct enum pre-resolve, #164/#160
pooler one-shot, #169 batch pooler, #173/#174 bare-batch session state —
all intact); 3/3 `tests/shutdown.rs` (#155 drain); 2/2
`tests/worker_starvation.rs`; 38/39 `tests/live_db` (the one failure is
`extension "vector" is not available`, a pre-existing pgvector fixture
gap needing the 54321 container — not a regression). 441 unit tests
pass; clippy clean; fmt applied.

* fix: remove stale Layer 3 worker-abort docs, make cancel-lane test non-vacuous (#167)

A pre-handoff ensemble review (11 dimensions + /code-review) found the
module doc and test-file doc still described the reverted Layer 3
worker-abort (select!/CancellationToken) as live and tested, when
run_worker runs handle_line unwrapped with no such mechanism. Five
findings fixed:

- src/main.rs module doc + spawn-site comments: rewrite to describe
  Layers 1-2 only, with an honest 'worker-abort was considered but not
  added' paragraph pointing to cancel::cancel's doc for the rationale.
  Remove 'Cancellable (Layer 3)' inline comments.
- tests/worker_starvation.rs module doc: rewrite to describe Layers 1-2
  only; remove the dangling 'asserted in the next test' comment (there
  was no next test).
- Cancel test rewritten as cancel_runs_on_its_own_lane_*: send cancel as
  a request (id 200, not a notification) and assert a stdout response
  within 10s — a misrouted cancel queues behind 4 stuck workers and
  never responds. Mutation-tested: misrouting cancel->General fails the
  test with the right message; the old test passed on that regression.
- Add 5 direct lane_for unit tests to main.rs (the routing decision the
  whole fix depends on had no direct test). Add PartialEq/Eq/Debug to
  Lane for assert_eq!.
- Remove dead _cancellable: bool param on run_worker and the unused
  cancel_notification test helper (YAGNI — their doc comments were the
  stale ones).
- Document the cancel-lane single-worker tradeoff at the spawn site
  (no connect_timeout on cancel_query; a stuck cancel blocks the lane).
- Reword peek_method doc: the double parse is an accepted cost of
  keeping handle_line's signature unchanged, not 'avoids a second parse'.

Verified: 441 unit tests pass (incl. 5 new lane_for), 2/2 worker_starvation
integration tests pass, clippy -D warnings clean, fmt clean.
aesslinger added a commit that referenced this pull request Oct 23, 2026
Bumps version to 1.0.0-rc.12 in Cargo.toml, Cargo.lock, .tabularium, and
CHANGELOG.md.

### Included since rc.11

**Fixed:**
- Direct-connection result-set queries on non-builtin column types (custom
  enum/composite/domain/range/extension types) no longer hang on large
  results (#166, #168) — pre-resolves column types via `prepare` on the
  shared cached client before the `query_typed` data fetch (the rc.11 fix
  only covered the transaction-pooler path)
- Batch / interactive-transaction-session result-set queries on a
  transaction-mode pooler no longer deadlock on wide non-builtin-typed
  tables (#169, #172) — `run_batch_in_session` now makes the same per-query
  routing decision as the single-query path (in_transaction pre-resolves on
  the shared client; no open transaction routes to the one-shot pinned path)
- A bare batch (no `session_id`, no `BEGIN`) that sets session-local state
  earlier (a session `SET` or `CREATE TEMP[ORARY] TABLE`) then runs a
  result-set `SELECT` no longer silently loses that state on a
  `pooler_mode = transaction` connection (#173, #174) — the rc.11 #172 fix
  routed the SELECT to a fresh one-shot that never saw the SET/temp table;
  the fix keeps the SELECT on the shared batch client with a pre-resolve,
  restoring main's within-batch guarantee (handles GLOBAL/LOCAL TEMP,
  excludes SET LOCAL / SET TRANSACTION / SET CONSTRAINTS)
- A slow or hung query no longer starves `ping`/`test_connection`/
  `initialize`/`shutdown` and the `cancel` notification for every
  connection (#167, #176) — RPCs are routed by method into three lanes
  before a worker pulls the line: a dedicated cancel lane (the relief
  valve, never queued behind the workers it relieves), a 2-worker lifecycle
  lane, and the existing 4-worker general lane; the SHUTDOWN_GRACE drain
  (#155) covers all lanes' worker handles

**Known limitations (tracked as follow-ups, not regressions vs. the builtin):**
- A bare [SET, SELECT] / [CREATE TEMP TABLE, SELECT] batch on a *real*
  PgBouncer (pool_mode = transaction) can still lose session state between
  autocommit statements and, in rare cases with non-builtin types under
  reassignment during the pre-resolve prepare, can hang (#175) — wrap such
  batches in BEGIN/COMMIT; the direct + pooler_mode=transaction flag config
  (the common use) is unaffected
- A composite type ALTER TYPE-ed (same OID, new field layout) on a recycled
  pooled connection can read a stale cached Type and produce wrong JSON for
  the composite's fields (#171) — pre-existing
  (tokio-postgres/deadpool cache-invalidation limitation); the rc.11/#168
  pre-resolve widened the cache-warming window but did not introduce the
  bug

Verified: 441 unit tests pass; cargo clippy --all-targets -- -D warnings
clean; markdownlint clean on CHANGELOG.md.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working prerelease:rc Version suggestion targets a release candidate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bare [SET/CREATE TEMP, SELECT] batch on a transaction pooler loses session-local state on the one-shot path

1 participant