Skip to content

feat: stop sending jit and search_path as startup parameters - #24

Merged
AlexeyShalaev merged 1 commit into
masterfrom
feat/pgbouncer-safe-defaults
Sep 7, 2026
Merged

feat: stop sending jit and search_path as startup parameters#24
AlexeyShalaev merged 1 commit into
masterfrom
feat/pgbouncer-safe-defaults

Conversation

@AlexeyShalaev

Copy link
Copy Markdown
Member

BasePostgresConfig.jit defaulted to "off", and create_async_session_manager sent it — and db_schema as search_path — in asyncpg server_settings, which are PostgreSQL startup parameters. PgBouncer in transaction mode refuses any it does not track, so with the defaults every connection failed with unsupported startup parameter: jit; with ignore_startup_parameters it connected and dropped both, so db_schema was accepted by the library and ignored by the database. The configuration guide said jit="off" # Required for pgbouncer.

What changes. jit defaults to None and goes into server_settings only when set; it is still a startup parameter then, and the docs say what that means through a pooler (track_extra_parameters, or ALTER ROLE … SET jit). db_schema never goes as a startup parameter again: create_async_session_manager hands it to the manager as search_path, and the manager attaches a begin listener to the engine that runs SELECT set_config('search_path', $1, true) — the function form of SET LOCAL, value as a bind parameter — as the first statement of every transaction. Under the asyncpg adapter BEGIN is sent lazily with the first statement, so this lands inside the transaction, after PgBouncer has pinned the server connection. New surface: AsyncSessionManager(search_path=None), AsyncSessionManagerBuilder.with_search_path(), and attach_search_path(engine, search_path) beside attach_metrics in session.manager (not re-exported). Nothing is attached when no schema is configured; with one it costs one round-trip per transaction. Statement caches at 0 and AsyncCConnection stay, for PgBouncer before 1.22.

Why the engine and not the unit of work. SET LOCAL in open_session() covers only the three UoW blocks: manager.get_session(), get_transaction(), a raw engine.connect() and the statements after a session.commit() inside managed_session() would all run in public, and db_schema would have to be threaded into both DI integrations' UoW providers. The begin event covers all of those from one place, including the transaction that follows a commit in the same session, and fires once per transaction rather than per savepoint. A connect-event SET search_path was rejected because it is per server connection, which through a transaction pooler is exactly the leak the lab's last case shows. Docs-only was rejected because it leaves a setting the library accepts and the database ignores. The one shape the listener cannot cover is a statement under isolation_level="AUTOCOMMIT" — no transaction, nothing to scope to — which is equally true of any per-connection state through a pooler; the docs say so.

Direct-to-PostgreSQL users see two changed defaults: JIT now follows the server setting unless jit is set, and the schema is applied per transaction rather than per connection, which is the same effective result for anything that runs in a transaction. That is why this is feat: and a minor bump rather than a patch. The agents page follows the code: the new argument and builder method, jit=None in the config table, rule 16, a row in the fixed-since table, a common-mistakes pair and an errors row; the configuration guide gets a PgBouncer section built around the numbers below.

Tests. Unit: the default config yields server_settings == {"application_name": …} and no search_path; an explicit jit="off" is still in server_settings; db_schema reaches the manager, not the startup packet; attach_search_path registers a begin listener that executes set_config with the value; builder and manager wiring. Integration on PostgreSQL 17: search_path in get_session(), get_transaction() with and without an isolation level, engine.connect(), after a commit() in the same session, an unqualified table resolving in the schema, the reporter's BasePostgresConfig(db_schema="app") through create_async_session_manager, uow.transaction(), uow.query(), uow.managed_session() across a commit, inside and after a savepoint, and a control with no schema reading "$user", public. Run against master in a separate worktree: 6 unit tests fail, test_manager.py fails to import, 12 integration tests error on the unknown search_path argument; the explicit-jit test and the no-schema control pass on both, as they should.

Gate:

make check   ruff check: All checks passed! / ruff format --check: 94 files already formatted / mypy: Success: no issues found in 44 source files
make test    605 passed, coverage 93.55% (threshold 90%)
zensical build --clean   No issues found

The reporter's two scripts, unmodified, from a scratch venv against 0.2.1 from PyPI and then against this branch (uv pip install -e ".[settings]" asyncpg "testcontainers[postgres]"), PostgreSQL 17 and PgBouncer 1.25.2 in Docker.

pgbouncer_lab.py on 0.2.1:

--- plain SQLAlchemy + asyncpg, driver defaults (statement cache on) ---
  direct to PostgreSQL                                       ok=800  errors=none  (0.3s)
  PgBouncer transaction mode, defaults (max_prepared_statements=200) ok=800  errors=none  (0.3s)
  PgBouncer transaction mode, max_prepared_statements=0 (pre-1.22 default) ok=485  errors={'DBAPIError': 315}  (0.3s)
      first error: (sqlalchemy.dialects.postgresql.asyncpg.Error) <class 'asyncpg.exceptions.InvalidSQLStatementNameError'>: prepared statement "__asyncpg_stmt_32d__" does not exi
--- sqlalchemy-foundation-kit, its pgbouncer-safe settings ---
  PgBouncer transaction mode, defaults                       ok=0    errors={'ProtocolViolationError': 800}  (0.3s)
      first error: unsupported startup parameter: jit
  PgBouncer with ignore_startup_parameters=jit,search_path   ok=800  errors=none  (0.5s)
--- session state through PgBouncer, transaction mode, one server connection ---
  client A ran SET search_path TO leaked; client B sees search_path = 'leaked'

pgbouncer_lab.py on this branch:

--- plain SQLAlchemy + asyncpg, driver defaults (statement cache on) ---
  direct to PostgreSQL                                       ok=800  errors=none  (0.3s)
  PgBouncer transaction mode, defaults (max_prepared_statements=200) ok=800  errors=none  (0.4s)
  PgBouncer transaction mode, max_prepared_statements=0 (pre-1.22 default) ok=485  errors={'DBAPIError': 315}  (0.5s)
      first error: (sqlalchemy.dialects.postgresql.asyncpg.Error) <class 'asyncpg.exceptions.InvalidSQLStatementNameError'>: prepared statement "__asyncpg_stmt_32d__" does not exi
--- sqlalchemy-foundation-kit, its pgbouncer-safe settings ---
  PgBouncer transaction mode, defaults                       ok=800  errors=none  (1.2s)
  PgBouncer with ignore_startup_parameters=jit,search_path   ok=800  errors=none  (0.9s)
--- session state through PgBouncer, transaction mode, one server connection ---
  client A ran SET search_path TO leaked; client B sees search_path = 'leaked'

jit_probe.py on 0.2.1:

  direct to PostgreSQL                             jit='off' search_path='app' application_name='probe'
  PgBouncer, ignore_startup_parameters=jit,search_path jit='on' search_path='"$user", public' application_name='probe'

jit_probe.py on this branch:

  direct to PostgreSQL                             jit='on' search_path='app' application_name='probe'
  PgBouncer, ignore_startup_parameters=jit,search_path jit='on' search_path='app' application_name='probe'

The probe's direct row now reads jit='on' because nothing is sent and the server default applies; the PgBouncer row reads search_path='app' because the schema is set inside the transaction the probe's SHOW autobegins, so ignore_startup_parameters has nothing left to drop. The plain-SQLAlchemy rows are the same in both runs, as they should be. The kit rows' timings are Docker noise: a rerun on this branch gave 0.5s for both.

Closes #23

`BasePostgresConfig.jit` defaults to None and is put into asyncpg
`server_settings` only when set explicitly. `db_schema` no longer goes
as a `search_path` startup parameter: `create_async_session_manager`
hands it to the manager, which attaches a `begin` listener running
`set_config('search_path', ..., true)` -- SET LOCAL -- as the first
statement of every transaction. That is the one scope PgBouncer in
transaction mode honours: it refused every connection on the default
`jit` (`unsupported startup parameter: jit`) and, with
`ignore_startup_parameters`, silently dropped the schema.

New: `AsyncSessionManager(search_path=...)`,
`AsyncSessionManagerBuilder.with_search_path()`,
`session.manager.attach_search_path(engine, search_path)`.

Two defaults change for direct-to-PostgreSQL users: JIT follows the
server setting unless `jit` is set, and the schema is per transaction
rather than per connection (a statement under AUTOCOMMIT is not
covered). The configuration guide's PgBouncer section is rewritten
around the measurements in the issue; the agents page follows.
@codecov

codecov Bot commented Sep 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.57143% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
sqlalchemy_foundation_kit/session/builder.py 25.00% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@AlexeyShalaev
AlexeyShalaev merged commit 704c343 into master Sep 7, 2026
7 checks passed
@AlexeyShalaev
AlexeyShalaev deleted the feat/pgbouncer-safe-defaults branch September 7, 2026 05:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The pgbouncer-safe defaults cannot connect through PgBouncer: jit and search_path are startup parameters it rejects, or silently drops

1 participant