Skip to content

feat: model pooler mode in ConnectionParams for PgBouncer + custom types - #158

Merged
aesslinger merged 2 commits into
mainfrom
feat/pooler-mode-157
Oct 15, 2026
Merged

aesslinger merged 2 commits into
mainfrom
feat/pooler-mode-157

Conversation

@aesslinger

Copy link
Copy Markdown
Collaborator

Summary

Models pooler mode as a first-class connection property so the plugin can adapt its behavior when the user declares the connection goes through a transaction-mode pooler (e.g. PgBouncer pool_mode = transaction). Closes the residual gap from tabularis#1097: rc.9 fixed the primary failure (named user-issued queries splitting across two Syncs), but tokio-postgres's internal typeinfo_statement — fired during Row deserialization for non-builtin column types (custom enums, composites, extension types) — still uses a named statement that can be orphaned by pooler reassignment.

Changes

  1. ConnectionParams::pooler_mode (src/models.rs) — Option<String> ("transaction"/"session"/None), parsed from the host's params object. is_transaction_pooler() helper. No-op until the host sends the field (backward-compatible: defaults to None, all existing tests pass unchanged).

  2. Gate prepare() at query.rs:451 — on a transaction pooler, skip prepare() for empty-result-set column discovery and return columns: []. Eliminates the plugin's own named-statement trigger. On direct connections, prepare() is still used (safe there, better column discovery).

  3. format_query_error (src/client.rs) — rewrites SQLSTATE 26000/42P05 (prepared-statement errors) with actionable guidance when on a transaction pooler: points the user to max_prepared_statements > 0 in pgbouncer.ini, the official pooler-side mechanism for making named statements safe under transaction mode (PgBouncer 1.21+, 2023). For non-pooler connections or other errors, falls through to format_pg_error unchanged.

Why this approach (PostgreSQL ecosystem perspective)

The PostgreSQL ecosystem converged on a two-part answer for prepared statements under transaction-mode poolers:

  • Client side: use the unnamed statement where possible (rc.9 did this for user queries).
  • Pooler side: max_prepared_statements > 0 for named statements that can't be eliminated from the driver (tokio-postgres's internal typeinfo_statement).

This PR does the client-side part (skip the plugin's own prepare(), surface actionable errors) and points the user to the pooler-side part. The remaining trigger (tokio-postgres's internal typeinfo_statement during Row deserialization) can't be eliminated without patching tokio-postgres — max_prepared_statements is the designed solution for exactly this.

What this does NOT do

  • No simple_query fallback (regresses to the simple protocol, loses typed results — wrong direction)
  • No retry hack (doesn't fix root cause)
  • No runtime PgBouncer detection (unreliable — SHOW server_version through PgBouncer returns the backend version)
  • No patching tokio-postgres (the remaining trigger is handled by max_prepared_statements pooler-side)

Verification

Check Result
cargo build clean
cargo clippy --all-targets -- -D warnings clean
cargo fmt --check clean
Unit tests (--lib) 410/0
Live DB (live_db.rs) 37/0 — no regressions
Shutdown (shutdown.rs) 3/0
Backward compat all existing ConnectionParams test fixtures pass with pooler_mode: None

Companion

  • tabularis#1126 — host side: adds pooler_mode to ConnectionParams and exposes it in the connection UI. Once that lands, the host sends pooler_mode: "transaction" and this PR's behavior lights up automatically. Until then, the field defaults to None and the plugin behaves exactly as before.

References

🤖 Generated with Claude Code

…pes (#157)

Add a `pooler_mode` field to `ConnectionParams` so the plugin can adapt its
behavior when the user declares the connection goes through a transaction-mode
pooler (e.g. PgBouncer `pool_mode = transaction`). This closes the residual
gap from tabularis#1097: rc.9 fixed the primary failure (named user-issued
queries splitting across two Syncs), but tokio-postgres's internal
`typeinfo_statement` — fired during Row deserialization for non-builtin
column types (custom enums, composites, extension types) — still uses a
named statement that can be orphaned by pooler reassignment.

Plugin-side changes:

- `ConnectionParams::pooler_mode` (Option<String>, "transaction"/"session"/
  None) parsed from the host's params object. `is_transaction_pooler()` helper.
  No-op until the host sends the field (backward-compatible: defaults to None).

- Gate the `prepare()` at `query.rs:451` (column discovery on empty result
  sets) — on a transaction pooler, skip it and return `columns: []`. This
  eliminates the plugin's own named-statement trigger. On direct connections,
  `prepare()` is still used (safe there, better column discovery).

- `format_query_error` rewrites SQLSTATE 26000/42P05 (prepared-statement
  errors) with actionable guidance when on a transaction pooler: points the
  user to `max_prepared_statements > 0` in pgbouncer.ini, the official
  pooler-side mechanism for making named statements safe under transaction
  mode (PgBouncer 1.21+, 2023). For non-pooler connections or other errors,
  falls through to `format_pg_error` unchanged.

The remaining trigger (tokio-postgres's internal `typeinfo_statement` during
Row deserialization on the non-empty-rows path) can't be eliminated without
patching tokio-postgres. The error-message rewrite makes that failure
legible instead of cryptic, and `max_prepared_statements` handles it
pooler-side — the division of responsibility the PostgreSQL ecosystem
converged on (client uses unnamed statements where it can; pooler caches
named statements it can't avoid).

Companion: tabularis#1126 (host-side: add `pooler_mode` to ConnectionParams
+ expose in connection UI).

Fixes #157.
@aesslinger aesslinger added the prerelease:beta Version suggestion targets a beta prerelease label Oct 15, 2026
@aesslinger aesslinger self-assigned this Oct 15, 2026
@aesslinger aesslinger added enhancement New feature or request rust Pull requests that update rust code labels Oct 15, 2026
Four findings from the ensemble review (all 50-74 band, non-blocking):

1. test-coverage: the entire pooler_mode feature path was untested — all
   test fixtures set pooler_mode: None, so is_transaction_pooler() always
   returned false and the new branches (format_query_error rewrite, prepare()
   skip) were exercised by zero tests. Added src/models_tests.rs with 7 unit
   tests for is_transaction_pooler() covering: true for "transaction", false
   for None/session/empty/typo/non-string-JSON, and case-insensitivity.

2. docs-vs-code-drift: comment at query.rs:476 said "error-message rewrite
   below" but format_query_error is called at line 453 — above the comment,
   not below. Fixed "below" → "above".

3. boundary-and-edge-cases: case-sensitive == Some("transaction") was a
   fragile contract — a host-side capitalization change would silently
   disable all pooler-safe behavior. Changed to eq_ignore_ascii_case for
   case-insensitive matching. Added a test for "Transaction"/"TRANSACTION".

4. /code-review + error-handling: format_query_error duplicated
   format_pg_error's as_db_error formatting inline (and the else branch was
   unreachable dead code — SQLSTATE only exists on DbError). Replaced the
   inline duplication with a call to format_pg_error(e), which produces the
   same prefix string and avoids divergence if format_pg_error changes.

Note: format_query_error's SQLSTATE 26000/42P05 rewrite branch can't be unit
tested without a live DB — tokio_postgres::Error's constructors are all
pub(crate), so there's no public way to construct an Error with a specific
SQLSTATE. The non-pooler fall-through path is covered by existing tests.
The is_transaction_pooler() gate (which controls both the rewrite and the
prepare() skip) is now fully unit-tested.
@aesslinger aesslinger added prerelease:rc Version suggestion targets a release candidate and removed prerelease:beta Version suggestion targets a beta prerelease labels Oct 15, 2026
@github-actions

Copy link
Copy Markdown

Version suggestion

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

Current 1.0.0-rc.9
Suggested next tag v1.0.0-rc.10

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 (feat) and the prerelease:rc label:

Current 1.0.0-rc.9
Suggested next tag v1.0.0-rc.10

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

@TabularisDB TabularisDB deleted a comment from github-actions Bot Oct 15, 2026
@aesslinger
aesslinger merged commit e600633 into main Oct 15, 2026
9 checks passed
@aesslinger
aesslinger deleted the feat/pooler-mode-157 branch October 15, 2026 19:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request prerelease:rc Version suggestion targets a release candidate rust Pull requests that update rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant