Skip to content

fix: pre-resolve column types on direct connections to stop enum-table hang (#166) - #168

Merged
aesslinger merged 4 commits into
mainfrom
fix/direct-enum-hang-166
Oct 23, 2026
Merged

aesslinger merged 4 commits into
mainfrom
fix/direct-enum-hang-166

Conversation

@aesslinger

@aesslinger aesslinger commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

What this fixes

#166: a SELECT * on a table with a non-builtin column type (custom enum, composite, extension type) and wide rows hangs on a direct (non-pooler) connection once the result is larger than a few KB. The rc.11 fix (#164) only pre-resolved column types when Pooler Mode = "Transaction", so direct connections still used the old path.

Root cause

tokio-postgres's query_typed resolves non-builtin column types (get_type → the internal named typeinfo_statement) inside its RowDescription loop, mid-stream over the same channel delivering the query's DataRows. The typeinfo reply is queued behind the pending rows, but query_typed won't drain the rows until get_type returns → deadlock once the row data fills the bounded responses channel + socket buffer (hence the size threshold — small results pass, large ones hang). This is not pooler-specific — it deadlocks on any connection. The pooler additionally orphans the statement across backend reassignment (42P05 on PgBouncer), but the hang mechanism is the interleave, not the reassignment. The #164 framing ("Problem B = pooler reassignment") was incomplete.

The fix

Pre-resolve result column types via prepare(query) on the shared cached client before the query_typed data fetch, for direct connections only (gated on !is_transaction_pooler in exec_query_on_client). prepare runs Parse+Describe+Sync with no Execute/no DataRows, so its get_type interleave has an empty channel and completes cleanly, warming CachedTypeInfo.types on the shared InnerClient. The subsequent query_typed then hits that cache (get_type's client.type_(oid) returns early) → no typeinfo_statement mid-drain → no deadlock. This is exactly rc.8's behavior, which worked.

  • Placement: exec_query_on_client (not exec_query) — it's the single convergence point for single, batch, and explain queries (the batch path calls it directly, bypassing exec_query).
  • Pooler path untouched: poolers keep the fix: pin backend for transaction-pooler result queries on non-builtin column types (#160, #163) #164 pinned one-shot transaction. A plain prepare would collide across backend reassignment (the s0→s1 shift that killed the removed pre-warm, ca33b98); direct connections have no reassignment hazard, so the plain prepare is safe and cheaper.
  • Non-fatal: a prepare failure is swallowed (.ok()) and query_typed runs anyway — for builtin types get_type is always a Type::from_oid cache hit (no hang), and a direct prepare failure on a valid result-set query is rare.
  • No double-prepare: the prepared Statement is kept alive so its .columns() is reused by the empty-rows branch (which previously did its own prepare for column discovery on direct connections).

Measured against a live direct Postgres (podman, no pooler): ~39ms/call (cached path) vs ~37ms baseline, with no type-resolution overhead for builtin types (the common case — get_type is a Type::from_oid cache hit, no typeinfo_statement; the prepare round-trip is still incurred, matching rc.8). Pool reuse is preserved (no per-query connection build, unlike the one-shot pooler path).

What stays (no existing work lost)

Also in this PR

Out of scope / follow-up

Verification

cargo fmt --all && cargo clippy --all-targets -- -D warnings && cargo test --lib   # 425 pass
PG_PLUGIN_TEST_HOST=127.0.0.1 PG_PLUGIN_TEST_PORT=54320 \
  PG_PLUGIN_TEST_USER=postgres PG_PLUGIN_TEST_DB=testdb PG_PLUGIN_TEST_PASSWORD=password \
  cargo test --lib -- --include-ignored --test-threads=1 client_live_tests          # 7 pass (incl. wide-enum regression)

Verified locally against pg-tabularis-test (direct, no pooler):

query limit rc.11 this fix
wide enum table 50 hangs past 25s ✅ 0.34s
wide enum table 500 hangs ✅ returns 500 rows
plain text table 500 ✅ ✅ (no regression)
SHOW search_path 100 ✅ ✅ (no regression)
pooler path (pooler_mode=transaction), enum 500 ✅ ✅ (no regression)

…e hang (#166)

rc.11 (#164) only pre-resolved result column types when Pooler Mode =
"Transaction", so direct (non-pooler) connections still hung on a table
with a non-builtin column type (custom enum, composite, extension type)
once the result was larger than a few KB. The same tokio-postgres
interleave that #164 fixed for poolers deadlocks on any connection:

query_typed resolves non-builtin column types (get_type → the internal
named typeinfo_statement) inside its RowDescription loop, mid-stream over
the same channel delivering the query's DataRows. The typeinfo reply is
queued behind the pending rows, but query_typed won't drain the rows
until get_type returns → deadlock once the row data fills the bounded
responses channel + socket buffer (the size threshold — small results
pass, large ones hang). This is not pooler-specific; the pooler
additionally orphans the statement across backend reassignment (42P05
on PgBouncer), but the hang mechanism is the interleave, not the
reassignment.

Fix: pre-resolve result column types via prepare(query) on the shared
cached client before the query_typed data fetch, for direct connections
only (gated on !is_transaction_pooler in exec_query_on_client, which
covers single, batch, and explain queries — the batch path calls
exec_query_on_client directly, bypassing exec_query). prepare runs
Parse+Describe+Sync with no Execute/no DataRows, so its get_type
interleave has an empty channel and completes cleanly, warming
CachedTypeInfo.types on the shared InnerClient. The subsequent
query_typed then hits that cache (get_type's client.type_(oid) returns
early) → no typeinfo_statement mid-drain → no deadlock. This is exactly
rc.8's behavior, which worked.

The pooler path (#164's exec_query_pooled_txn pinned one-shot
transaction) is untouched — a plain prepare would collide across backend
reassignment (the s0→s1 shift that killed the removed pre-warm, ca33b98),
so poolers keep the pinned path. Direct connections have no reassignment
hazard, so the plain prepare is safe and cheaper (preserves pool reuse;
measured ~39ms/call vs ~37ms baseline, zero overhead on builtin types).

The pre-resolve is non-fatal: a prepare failure is swallowed (.ok()) and
query_typed runs anyway — for builtin types get_type is always a
Type::from_oid cache hit (no hang), and a direct prepare failure on a
valid result-set query is rare. The prepared Statement is kept alive so
its warmed cache persists into query_typed and its .columns() is reused
by the empty-rows branch (eliminating the double-prepare that the
pre-#166 empty-rows column-discovery would otherwise incur).

Also corrects the #164 framing in exec_query's routing comment: Problem
B is the typeinfo_statement interleave during Row deserialization
(deadlocks on any connection once the response is large enough), not
just pooler reassignment — the pooler adds the reassignment orphan on
top, but the hang mechanism is the interleave.

Adds a live regression test (wide_enum_table_returns_rows_on_direct_
connection): 600 rows × 500-byte pad + an enum column, fetched at limit
50 (≈25 KB, enough to fill the channel buffer) on a direct connection,
asserting it returns within 30s. rc.11 hangs past 120s on this; the
existing small-enum test uses a single tiny row and passes on the
broken path, which is the coverage gap that let #166 ship.

Verified locally against pg-tabularis-test (direct, no pooler):
- wide enum table, limit 50: returns 50 rows in 0.34s (rc.11 hung)
- wide enum table, limit 500: returns 500 rows (rc.11 hung)
- plain text table, limit 500: returns 500 rows (no regression)
- SHOW search_path: returns 1 row (no regression)
- pooler path (pooler_mode=transaction), enum limit 500: returns 500
  rows (no regression)
@aesslinger aesslinger added prerelease:rc Version suggestion targets a release candidate bug Something isn't working labels Oct 23, 2026
@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.

@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.

1 similar comment
@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.

@aesslinger aesslinger self-assigned this Oct 23, 2026
A new advisory published 2026-10-23 (RUSTSEC-2026-0385) flags ctutils
0.4.2 for non-constant-time mask generation and u128 comparisons (a
crypto-failure / timing-side-channel category). ctutils is a transitive
dep (pulled via the RustCrypto utils chain through rustls/ring/aws-lc-rs),
not a direct dependency, so `cargo update -p ctutils` bumps it within
the same minor to 0.4.5 (satisfies the >=0.4.4 patch). cargo audit now
reports zero vulnerabilities (only the pre-existing chacha20 yanked-crate
warning remains, as on rc.10/rc.11). Build and 425 unit tests pass.

This is independent of the #166 fix in this PR — the advisory dropped
after rc.11's audit passed yesterday.
…laim)

The pre-handoff review (4 independent agents) flagged two comment
imprecisions in the #166 pre-resolve block:

- Line 724: said the Statement is kept alive "so its warmed cache
  persists into the query_typed" — but tokio-postgres's CachedTypeInfo.types
  lives on InnerClient, not Statement; Statement::Drop sends a server Close
  but does not clear the cache. The Statement is actually kept alive for
  .columns() reuse in the empty-rows branch. A maintainer reading the old
  comment might avoid dropping the Statement early under the false belief
  it would clear the cache and re-introduce the hang.

- Line 713: said "This is exactly rc.8's behavior" — rc.8 used the named
  query() path (types resolved in prepare's Describe); this uses a separate
  prepare + the unnamed query_typed. The outcome is equivalent (types
  resolved before the data fetch, no mid-stream interleave) but the code
  path is not identical.

No code change — comments only.
The pre-handoff review found the batch path (run_batch_in_session →
exec_query_on_client) with a wide enum table on a direct connection was
untested — the single-query test covers exec_query → exec_query_on_client,
but the batch caller is a distinct path with its own connection handling.
If the pre-resolve were removed from the batch path specifically, every
existing test would still pass.

Add wide_enum_table_returns_rows_via_batch_on_direct_connection:
execute_query_batch (no session_id, the non-session batch path) with the
same 600-row × 500-byte-pad enum table at limit 50, asserting rows return
within 30s. Mirrors the single-query test's fixture, just routed through
the batch RPC (run_batch_in_session → exec_query_on_client).
@aesslinger
aesslinger merged commit eaf64aa into main Oct 23, 2026
7 of 8 checks passed
@aesslinger
aesslinger deleted the fix/direct-enum-hang-166 branch October 23, 2026 14:05
aesslinger added a commit that referenced this pull request Oct 23, 2026
* fix: pre-resolve column types on batch/session pooler path (#169)

The #166 fix (#168) and the #164 pinned one-shot path both only cover
non-session single result-set queries. A batch or interactive transaction
session on a transaction-mode pooler (pooler_mode=transaction) still
deadlocked on a table with a non-builtin column type (custom enum,
composite, extension type) once the result was large enough to fill the
channel buffer — the same typeinfo_statement mid-stream interleave as
#166, on the batch path that neither #164 nor #168 covered.

run_batch_in_session calls exec_query_on_client directly (bypassing
exec_query's routing to the #164 one-shot pinned path), and #168's
pre-resolve in exec_query_on_client was gated on !is_transaction_pooler,
so a pooler connection (is_transaction_pooler=true) skipped it →
query_typed fired typeinfo_statement mid-stream → deadlock.

Fix: per-query routing in run_batch_in_session, keyed on
is_transaction_pooler() && returns_result_set(query), with two sub-cases:

* in_transaction (the user's BEGIN has pinned the backend on this shared
  connection — either a session-pinned batch reusing a prior BEGIN, or a
  bare batch whose earlier statement was BEGIN): a plain prepare inside
  that transaction is safe (no reassignment can orphan the named
  statement) and keeps the SELECT on the same connection where
  transaction-local state (temp tables, SET variables) is visible. Run
  exec_query_on_client with pre_resolve_types=true.

* Not in_transaction (no open transaction pins the backend): route to the
  one-shot pinned path (exec_query_pooled_txn, the proven #164
  mechanism). The one-shot client dies with its stale client-side type
  cache; the shared batch client sits idle for that one query (acceptable:
  when there's no transaction there's no transaction-local state to lose
  by using a different connection).

Using in_transaction (not reused) as the routing condition is the key
correctness point: a bare batch [BEGIN, CREATE TEMP TABLE t, SELECT *
FROM t] on a pooler has reused=false but in_transaction=true after the
mid-batch BEGIN. With reused, the SELECT would route to a one-shot
connection that can't see the temp table; with in_transaction, it stays
on the shared client where the temp table is visible.

Also adds a pre_resolve_types: bool parameter to exec_query_on_client,
separating the pre-resolve gate from is_transaction_pooler (which stays
for error-message phrasing only). This avoids overloading
is_transaction_pooler=false on a connection that IS a transaction pooler
(which would corrupt error formatting). The three callers pass:
- exec_query (single): !is_transaction_pooler() (the #168 direct path)
- run_pinned_body (#164 one-shot): false (it does its own prepare)
- run_batch_in_session (this fix): true when in_transaction

No changes to exec_query_pooled_txn, run_pinned_body, pin_pinned_txn,
or exec_query's routing. The #166 direct-connection fix and the #164
single-query pooler fix are untouched.

Adds two live regression tests:
- wide_enum_table_returns_rows_via_session_pinned_batch_on_pooler: the
  reporter's actual scenario (BEGIN → SELECT wide enum → ROLLBACK in a
  session-pinned batch with pooler_mode=transaction). Hung past 30s on
  rc.11/#168; returns cleanly with the fix.
- wide_enum_table_returns_rows_via_non_session_batch_on_pooler: the
  non-session sub-case (no session_id, routes to the one-shot path).

Verified locally: 425 unit tests + 10 live tests pass, clippy clean.

* fix: stop one-shot routing from evicting the batch cancel guard (#172)

The #169 batch/session pooler fix routed a non-in_transaction result-set
query to exec_query_pooled_txn from inside run_batch_in_session's loop.
The one-shot registers its own CancelGuard under the SAME request_id as
the batch guard; cancel::insert_action is a HashMap::insert (overwrite),
and the one-shot guard's Drop removes the key entirely — so the batch's
shared-client guard was evicted, leaving subsequent batch queries with
no registered cancel handle (a host cancel(request_id) became a no-op,
defeating #126's "a late cancel is never a no-op for an in-flight query"
guarantee). On main the batch never called the one-shot, so this is a
new regression introduced by #172.

Fix: hold the guard as Option<CancelGuard>, take+drop it around each
one-shot routing (so the one-shot owns the map entry while it runs),
and re-register for the shared client before the next iteration.

Also fixes pre-handoff-review findings on #172:
- E1: the pre-resolve block comment still said "Gated to direct
  connections / poolers keep the one-shot path" after the gate changed
  to pre_resolve_types (now true for session-pinned pooler in_transaction
  too). Rewrote to match the gate.
- E4: exec_query's comment said "the batch path stays on the shared
  cached client / owns its own transaction" — now wrong for the no-tx
  pooler one-shot routing and the bare-batch-no-BEGIN case. Rewrote.
- E3: two test comments named reused (not in_transaction) as the
  routing key; the code keys on in_transaction. Corrected.
- Added empty_result_returns_columns_via_session_pinned_batch_on_pooler
  to guard the empty-rows column-discovery fallback (pre_resolve_types
  gate), which #169 made reachable for session-pinned pooler batches but
  no test exercised.

Follow-ups filed: #173 (bare [SET/CREATE TEMP, SELECT] batches on a
pooler lose session-local state on the one-shot path), #170 updated
(the .ok() swallow's 25P02 transaction-abort cascade on the new
pooler in_transaction path).

Verified: 425 unit + 11 live tests pass, clippy clean.
aesslinger added a commit that referenced this pull request Oct 23, 2026
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 added a commit that referenced this pull request Oct 23, 2026
…n a transaction pooler (#173) (#174)

* fix: keep bare [SET/CREATE TEMP, SELECT] batch on the shared client on 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.

* fix: address pre-handoff-review findings on #173 classifier and routing

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 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.

1 participant