Repository navigation
pre_ping=True behind transaction-mode PgBouncer doubles server transactions and moves PgBouncer's queue into checkout wait, outside pool_timeout #58
Description
Versions: sqlalchemy-foundation-kit 0.4.0 (same code on master, adcd645), SQLAlchemy 2.0.54, asyncpg 0.31.0, prometheus-client 0.26.0, Python 3.13.12, PostgreSQL 17, PgBouncer 1.25.2 (pool_mode=transaction)
PoolSettings.pre_ping defaults to True (contrib/settings/postgres.py:60). build_engine_kwargs also falls back to True when no pool settings are passed (base/engine.py:197). The configuration guide labels it "(recommended)" (docs/guide/configuration.md:177), and its PgBouncer section says "Behind PgBouncer in transaction mode this is all it takes" with the default left on (docs/guide/configuration.md:477-484). The kit does not mention what pre-ping does behind a transaction-mode PgBouncer, and two of its effects work against what the docs promise:
- Every checkout of a reused connection costs a second server transaction. SQLAlchemy's asyncpg ping runs
BEGIN,;,ROLLBACKas its own explicit transaction, "to support pgbouncer transaction mode" (sqlalchemy/dialects/postgresql/asyncpg.py:820-831, PGDialect_asyncpg.do_ping occasionally uses prepared statements sqlalchemy/sqlalchemy#10226). PgBouncer has to assign a server connection for the ping, release it, and assign one again for the request. Ten requests cost twenty PgBouncer transactions. - PgBouncer's queue is recorded as checkout wait, and
pool_timeoutdoes not bound it. The ping runs insidepool.connect(), so when all of PgBouncer's server connections are busy, the request waits in PgBouncer's queue while the pool is still checking out a connection.postgres_db_connection_checkout_wait_secondsrecords that wait.pool_timeoutcovers only the local queue, so nothing enforces it here: the wait can run as long as PgBouncer'squery_wait_timeout(120 s by default), andconnection_timeouts_totalnever moves. The docs say the opposite. The checkout wait "turns intoconnection_timeouts_totalwhen it crossespool_timeout" (docs/guide/advanced.md:283-286, with the same reading atdocs/guide/configuration.md:563-566anddocs/agents.md:438-439).
Reproduction
import asyncio
import csv
import io
import time
from prometheus_client import REGISTRY
from pydantic import SecretStr
from sqlalchemy import text
from sqlalchemy_foundation_kit import create_async_session_manager
from sqlalchemy_foundation_kit.contrib.metrics import PostgresMetrics
from sqlalchemy_foundation_kit.contrib.settings import BasePostgresConfig, ConnectionSettings, PoolSettings
from testcontainers.core.container import DockerContainer
from testcontainers.core.network import Network
from testcontainers.core.wait_strategies import LogMessageWaitStrategy
from testcontainers.postgres import PostgresContainer
def start_pgbouncer(network: Network, **settings: object) -> DockerContainer:
container = DockerContainer("edoburu/pgbouncer:v1.25.2-p0").with_network(network).with_exposed_ports(5432)
env = {"DB_HOST": "db", "DB_USER": "test", "DB_PASSWORD": "test", "DB_NAME": "test",
"AUTH_TYPE": "scram-sha-256", "ADMIN_USERS": "test", "PGPASSWORD": "test",
"POOL_MODE": "transaction", **{k.upper(): str(v) for k, v in settings.items()}}
for key, value in env.items():
container.with_env(key, value)
container.waiting_for(LogMessageWaitStrategy("process up").with_startup_timeout(30))
return container.start()
def bouncer_transactions(bouncer: DockerContainer) -> int:
out = bouncer.exec(["psql", "-h", "127.0.0.1", "-U", "test", "-d", "pgbouncer", "--csv", "-c", "SHOW STATS"])
return sum(int(r["total_xact_count"]) for r in csv.DictReader(io.StringIO(out.output.decode())) if r["database"] == "test")
def config(bouncer: DockerContainer, pre_ping: bool) -> BasePostgresConfig:
return BasePostgresConfig(
connection=ConnectionSettings(host=bouncer.get_container_host_ip(), port=int(bouncer.get_exposed_port(5432)),
user="test", password=SecretStr("test"), database="test"),
application_name="probe",
pool=PoolSettings(size=2, max_overflow=0, timeout=0.2, pre_ping=pre_ping),
use_orjson_serialization=False,
)
def sample(prefix: str, name: str) -> float:
return REGISTRY.get_sample_value(f"{prefix}_postgres_db_{name}") or 0.0
async def warm(manager) -> None:
async def one() -> None:
async with manager.get_transaction() as s:
await s.execute(text("SELECT pg_sleep(0.05)"))
await asyncio.gather(one(), one()) # both local connections opened and back in the pool
async def main(bouncer: DockerContainer) -> None:
print("1. PgBouncer transactions for 10 requests, each `get_transaction()` + one SELECT")
for pre_ping in (True, False):
manager = create_async_session_manager(config(bouncer, pre_ping))
await warm(manager)
before = bouncer_transactions(bouncer)
for _ in range(10):
async with manager.get_transaction() as s:
await s.execute(text("SELECT 1"))
print(f" pre_ping={pre_ping!s:5}: {bouncer_transactions(bouncer) - before} transactions")
await manager.aclose()
print("2. PgBouncer's only server connection is busy for 1 s; local pool has an idle connection; pool timeout 0.2 s")
for pre_ping in (True, False):
prefix = f"preping_{str(pre_ping).lower()}"
manager = create_async_session_manager(config(bouncer, pre_ping), metrics=PostgresMetrics(prefix=prefix))
await warm(manager)
busy, release = asyncio.Event(), asyncio.Event()
async def holder() -> None:
async with manager.get_transaction() as s:
await s.execute(text("SELECT 1"))
busy.set()
await release.wait()
task = asyncio.create_task(holder())
await busy.wait()
asyncio.get_running_loop().call_later(1.0, release.set)
wait_before = sample(prefix, "connection_checkout_wait_seconds_sum")
started = time.perf_counter()
async with manager.get_transaction() as s:
await s.execute(text("SELECT 1"))
took = time.perf_counter() - started
await task
wait = sample(prefix, "connection_checkout_wait_seconds_sum") - wait_before
print(f" pre_ping={pre_ping!s:5}: request took {took:.2f} s; checkout wait recorded {wait:.2f} s; "
f"connection_timeouts_total={sample(prefix, 'connection_timeouts_total')}")
await manager.aclose()
with Network() as network, PostgresContainer(
"postgres:17-alpine", username="test", password="test", dbname="test"
).with_network(network).with_network_aliases("db"):
bouncer = start_pgbouncer(network, default_pool_size=1, max_client_conn=50)
try:
asyncio.run(main(bouncer))
finally:
bouncer.stop()Actual
1. PgBouncer transactions for 10 requests, each `get_transaction()` + one SELECT
pre_ping=True : 20 transactions
pre_ping=False: 10 transactions
2. PgBouncer's only server connection is busy for 1 s; local pool has an idle connection; pool timeout 0.2 s
pre_ping=True : request took 1.12 s; checkout wait recorded 1.07 s; connection_timeouts_total=0.0
pre_ping=False: request took 1.06 s; checkout wait recorded 0.00 s; connection_timeouts_total=0.0
With pre_ping=True, a pool whose timeout is 0.2 s reports a 1.07 s checkout wait and no timeout. With pre_ping=False, the request waits just as long, but the wait shows up where it actually happens: inside the statement, i.e. in the held duration, not in the checkout wait.
Why
pool_pre_pingpings every connection the pool reuses. On an asyncpg connection outside a transaction, SQLAlchemy wraps the ping in its own transaction. Through a transaction-mode pooler, that transaction is a separate server assignment, made before the request's own transaction begins.InstrumentedPool.connect(session/manager.py:93-110) times all ofsuper().connect(), including the ping. That is documented ("plus the pre-ping"). Behind PgBouncer, though, the ping is where the whole second queue sits, andpool_timeoutdoes not apply to it.
Proposal
Keep the default for direct connections, where a ping is cheap and the server holds no queue. Document the trade-off behind a transaction-mode pooler:
- In the PgBouncer section of the configuration guide and in
docs/agents.md, say thatpre_ping=Truedoubles PgBouncer transactions and moves PgBouncer's queue intocheckout_wait_seconds, outsidepool_timeout. ShowPoolSettings(pre_ping=False)in the PgBouncer example. Without the ping, a client connection that PgBouncer or a load balancer has closed fails on its first statement and SQLAlchemy invalidates it. Settingrecyclebelowclient_idle_timeout(and below any load balancer idle timeout) avoids most of those failures. - Qualify the "turns into
connection_timeouts_totalwhen it crossespool_timeout" sentences: that is true of the local queue only.
Activity
AlexeyShalaev commentedon Oct 16, 2026
Reproduced against PgBouncer 1.25.2 in transaction mode: 20 vs 10 PgBouncer transactions for 10 requests, and with
pre_ping=Truea 1.0 s checkout wait on a pool withtimeout=0.2and no timeout counted.Plan, docs only, as proposed. The default stays
Truefor direct connections.docs/agents.md: behind a transaction-mode pooler,pre_ping=Truecosts a second server transaction on every reused checkout and moves PgBouncer's queue intocheckout_wait_seconds, wherepool_timeoutdoesn't bound it. The example showsPoolSettings(pre_ping=False), withrecycleset belowclient_idle_timeoutand below any load balancer idle timeout to replace what the ping was buying.connection_timeouts_totalwhen it crossespool_timeout" sentences get qualified: that holds for the local queue only.I'll do it in one PR with #60, because both rewrite the same PgBouncer section and rule 16 in
docs/agents.md.AlexeyShalaev commentedon Oct 16, 2026
Docs are in #67, together with #60. The PgBouncer section, rule 16 and the advanced guide now explain the extra transaction and the queue landing outside
pool_timeout, the examples usePoolSettings(pre_ping=False, recycle=300), and thepool_timeoutsentences are qualified.I measured the
recycleadvice before writing it down, withclient_idle_timeout=2and 3 s idle:pre_ping=False, recycle=3600fails the next request withconnection is closed,recycle=1doesn't, andpre_ping=Truedoesn't either.