Skip to content

pre_ping=True behind transaction-mode PgBouncer doubles server transactions and moves PgBouncer's queue into checkout wait, outside pool_timeout #58

Closed

Description

@AlexeyShalaev

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, ;, ROLLBACK as 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_timeout does not bound it. The ping runs inside pool.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_seconds records that wait. pool_timeout covers only the local queue, so nothing enforces it here: the wait can run as long as PgBouncer's query_wait_timeout (120 s by default), and connection_timeouts_total never moves. The docs say the opposite. The checkout wait "turns into connection_timeouts_total when it crosses pool_timeout" (docs/guide/advanced.md:283-286, with the same reading at docs/guide/configuration.md:563-566 and docs/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_ping pings 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 of super().connect(), including the ping. That is documented ("plus the pre-ping"). Behind PgBouncer, though, the ping is where the whole second queue sits, and pool_timeout does 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 that pre_ping=True doubles PgBouncer transactions and moves PgBouncer's queue into checkout_wait_seconds, outside pool_timeout. Show PoolSettings(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. Setting recycle below client_idle_timeout (and below any load balancer idle timeout) avoids most of those failures.
  • Qualify the "turns into connection_timeouts_total when it crosses pool_timeout" sentences: that is true of the local queue only.

Activity

  1. AlexeyShalaev commented on Oct 16, 2026

    @AlexeyShalaev
    MemberAuthor

    Reproduced against PgBouncer 1.25.2 in transaction mode: 20 vs 10 PgBouncer transactions for 10 requests, and with pre_ping=True a 1.0 s checkout wait on a pool with timeout=0.2 and no timeout counted.

    Plan, docs only, as proposed. The default stays True for direct connections.

    • PgBouncer section of the configuration guide and docs/agents.md: behind a transaction-mode pooler, pre_ping=True costs a second server transaction on every reused checkout and moves PgBouncer's queue into checkout_wait_seconds, where pool_timeout doesn't bound it. The example shows PoolSettings(pre_ping=False), with recycle set below client_idle_timeout and below any load balancer idle timeout to replace what the ping was buying.
    • The "turns into connection_timeouts_total when it crosses pool_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.

  2. AlexeyShalaev commented on Oct 16, 2026

    @AlexeyShalaev
    MemberAuthor

    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 use PoolSettings(pre_ping=False, recycle=300), and the pool_timeout sentences are qualified.

    I measured the recycle advice before writing it down, with client_idle_timeout=2 and 3 s idle: pre_ping=False, recycle=3600 fails the next request with connection is closed, recycle=1 doesn't, and pre_ping=True doesn't either.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentationenhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions