Skip to content

AsyncCConnection does not provide PgBouncer compatibility: unused with the default caches, and fails like asyncpg.Connection with caches > 0 #60

Closed

Description

@AlexeyShalaev

Versions: sqlalchemy-foundation-kit 0.4.0 (same code on master, adcd645), SQLAlchemy 2.0.54, asyncpg 0.31.0, Python 3.13.12, PostgreSQL 17, PgBouncer 1.25.2 (pool_mode=transaction)

create_async_session_manager defaults connection_class to AsyncCConnection, "which provides pgbouncer transaction-mode compatibility" (session/factories.py:47-48, 97). The class docstring says unique statement names are "required when using pgbouncer in transaction mode" (session/connection.py:9-17). The PgBouncer section of the configuration guide says the library is safe on old and new PgBouncer alike because the caches are 0 and AsyncCConnection names statements uniquely (docs/guide/configuration.md:468-474). docs/guide/advanced.md:656-658 and docs/agents.md:435-436 say the same. #23 also argued that AsyncCConnection "still earns its place for older PgBouncers". Measured across every combination of the two cache settings, with and without PgBouncer's prepared-statement tracking, the connection class changes nothing:

  • With the kit's default caches (0/0), _get_unique_id is never called. Nothing in asyncpg asks it for a name, because statements are unnamed: prepare(name=None) falls back to self._stmt_cache_enabled, which is False when statement_cache_size=0 (asyncpg/connection.py:656).
  • With statement_cache_size > 0 and tracking off, the workload fails with prepared statement "__asyncpg_stmt_..." does not exist, with AsyncCConnection and with a plain asyncpg.Connection alike. A UUID in the name does not make a statement exist on a server connection it was never prepared on.
  • With tracking on (max_prepared_statements=200), every combination passes with either class.

The version numbers in those paragraphs are off as well. PgBouncer added protocol-level prepared statements in 1.21.0, opt-in with max_prepared_statements defaulting to 0. The default became 200 in 1.24.0 ("Enable prepared statement support by default, max_prepared_statements is now set to 200 by default", PgBouncer changelog). The docs say "Since 1.22 ... max_prepared_statements=200 by default" (docs/guide/configuration.md:439, 468-474, docs/agents.md:435-436, docs/guide/advanced.md:658). So a stock PgBouncer 1.21-1.23 has tracking off.

Reproduction

import asyncio
import re
from collections import Counter

import asyncpg
from pydantic import SecretStr
from sqlalchemy import text
from sqlalchemy_foundation_kit import create_async_session_manager
from sqlalchemy_foundation_kit.contrib.settings import BasePostgresConfig, ConnectionSettings, QuerySettings
from sqlalchemy_foundation_kit.session.connection import AsyncCConnection
from testcontainers.core.container import DockerContainer
from testcontainers.core.network import Network
from testcontainers.core.wait_strategies import LogMessageWaitStrategy
from testcontainers.postgres import PostgresContainer

names_generated = Counter()


class CountingAsyncCConnection(AsyncCConnection):
    def _get_unique_id(self, prefix):
        names_generated[prefix] += 1
        return super()._get_unique_id(prefix)


class CountingPlainConnection(asyncpg.Connection):
    def _get_unique_id(self, prefix):
        names_generated[prefix] += 1
        return super()._get_unique_id(prefix)


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", "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()


async def workload(bouncer: DockerContainer, caches: tuple[int, int], connection_class: type) -> str:
    config = 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",
        query=QuerySettings(statement_cache_size=caches[0], prepared_statement_cache_size=caches[1]),
        use_orjson_serialization=False,
    )
    manager = create_async_session_manager(config, connection_class=connection_class)
    names_generated.clear()
    outcomes = Counter()

    async def worker(n: int) -> None:
        for i in range(20):
            try:
                async with manager.get_transaction() as s:
                    await s.execute(text("SELECT CAST(:v AS integer) + 1"), {"v": n})
                    await s.execute(text("SELECT CAST(:v AS text) || 'x'"), {"v": str(i)})
                outcomes["ok"] += 1
            except Exception as exc:
                cause = getattr(getattr(exc, "orig", None), "__cause__", None) or exc
                message = re.sub(r'"__asyncpg_stmt_[^"]*"', '"__asyncpg_stmt_..."', str(cause).splitlines()[0])
                outcomes[f"{type(cause).__name__}: {message}"] += 1

    await asyncio.gather(*(worker(n) for n in range(4)))
    await manager.aclose()
    lines = [f"ok={outcomes.pop('ok', 0)}, statement names generated={sum(names_generated.values())}"]
    lines += [f"      {count:3} x {error}" for error, count in outcomes.most_common()]
    return "\n".join(lines)


async def main(untracked: DockerContainer, tracking: DockerContainer) -> None:
    print("PgBouncer 1.25.2, pool_mode=transaction, default_pool_size=2; 4 workers x 20 transactions x 2 statements")
    for bouncer, label in ((untracked, "max_prepared_statements=0"), (tracking, "max_prepared_statements=200 (default)")):
        print(f"\n{label}")
        for caches in ((0, 0), (100, 0), (100, 100), (0, 100)):
            for connection_class in (CountingAsyncCConnection, CountingPlainConnection):
                name = "AsyncCConnection" if connection_class is CountingAsyncCConnection else "asyncpg.Connection"
                result = await workload(bouncer, caches, connection_class)
                print(f"  caches {caches[0]:>3}/{caches[1]:<3} {name:18} -> {result}")


with Network() as network, PostgresContainer(
    "postgres:17-alpine", username="test", password="test", dbname="test"
).with_network(network).with_network_aliases("db"):
    untracked = start_pgbouncer(network, default_pool_size=2, max_client_conn=100, max_prepared_statements=0)
    tracking = start_pgbouncer(network, default_pool_size=2, max_client_conn=100)
    try:
        asyncio.run(main(untracked, tracking))
    finally:
        untracked.stop()
        tracking.stop()

Actual

PgBouncer 1.25.2, pool_mode=transaction, default_pool_size=2; 4 workers x 20 transactions x 2 statements

max_prepared_statements=0
  caches   0/0   AsyncCConnection   -> ok=80, statement names generated=0
  caches   0/0   asyncpg.Connection -> ok=80, statement names generated=0
  caches 100/0   AsyncCConnection   -> ok=65, statement names generated=152
       15 x InvalidSQLStatementNameError: prepared statement "__asyncpg_stmt_..." does not exist
  caches 100/0   asyncpg.Connection -> ok=74, statement names generated=162
        6 x InvalidSQLStatementNameError: prepared statement "__asyncpg_stmt_..." does not exist
  caches 100/100 AsyncCConnection   -> ok=60, statement names generated=46
       20 x InvalidSQLStatementNameError: prepared statement "__asyncpg_stmt_..." does not exist
  caches 100/100 asyncpg.Connection -> ok=50, statement names generated=68
       30 x InvalidSQLStatementNameError: prepared statement "__asyncpg_stmt_..." does not exist
  caches   0/100 AsyncCConnection   -> ok=80, statement names generated=0
  caches   0/100 asyncpg.Connection -> ok=80, statement names generated=0

max_prepared_statements=200 (default)
  caches   0/0   AsyncCConnection   -> ok=80, statement names generated=0
  caches   0/0   asyncpg.Connection -> ok=80, statement names generated=0
  caches 100/0   AsyncCConnection   -> ok=80, statement names generated=168
  caches 100/0   asyncpg.Connection -> ok=80, statement names generated=168
  caches 100/100 AsyncCConnection   -> ok=80, statement names generated=16
  caches 100/100 asyncpg.Connection -> ok=80, statement names generated=16
  caches   0/100 AsyncCConnection   -> ok=80, statement names generated=0
  caches   0/100 asyncpg.Connection -> ok=80, statement names generated=0

Why

  • With statement_cache_size=0, asyncpg prepares unnamed statements and sends the Parse again with every execution (asyncpg/connection.py:483-492: "We always do this when stmt_cache_size is set to 0 assuming people are running PgBouncer"). A statement therefore never depends on what a server connection remembers, even when SQLAlchemy caches the statement object (the 0/100 rows). That, and not the connection class, is what makes the defaults safe behind any PgBouncer.
  • With statement_cache_size > 0, asyncpg names statements and reuses them in later transactions, through its own cache and SQLAlchemy's. Through a transaction-mode pooler without tracking, a later transaction can land on a different server connection, which has never seen the name. Changing __asyncpg_stmt_12__ into __asyncpg_stmt_<uuid4>__ cannot fix that. A name collision between two clients (Using statement_cache_size asyncpg setting / prepared statement name for asyncpg w pgbouncer sqlalchemy/sqlalchemy#6467) is the only failure unique names prevent, and it needs this same untracked setup, which fails anyway with "does not exist". With tracking on, PgBouncer rewrites statement names per server connection itself.
  • The docstring's "unique per connection" also understates it: uuid4() names are unique across every connection.

Proposal

  • Rewrite the rule in the PgBouncer section and in docs/agents.md rule 16 as: "With statement_cache_size=0 (the default) any PgBouncer works, because asyncpg then sends unnamed statements and re-parses them on every execution. statement_cache_size > 0 needs PgBouncer >= 1.21 with max_prepared_statements > 0, which is the default from 1.24. The connection class plays no part." Fix the 1.22 references.
  • Either stop defaulting connection_class to AsyncCConnection (keep the class exported, with a deprecation note), or keep it as a harmless default and change its docstring and the create_async_session_manager argument doc so they no longer claim it provides PgBouncer compatibility.

Activity

  1. AlexeyShalaev commented on Oct 16, 2026

    @AlexeyShalaev
    MemberAuthor

    Reproduced against PgBouncer 1.25.2, both with max_prepared_statements=0 and with the default 200: same pattern as your table, with AsyncCConnection and plain asyncpg.Connection behaving the same in every row. The version numbers check out against the PgBouncer changelog: protocol-level prepared statements arrived in 1.21.0 behind max_prepared_statements (off by default), and the default became 200 in 1.24.0.

    Plan, docs and docstrings only:

    • The PgBouncer section, docs/agents.md rule 16 and the advanced guide get the rule as you wrote it: with statement_cache_size=0 (the default) any PgBouncer works, because asyncpg sends unnamed statements and re-parses them every time. statement_cache_size > 0 needs PgBouncer 1.21+ with max_prepared_statements > 0, the default from 1.24. The connection class plays no part. The 1.22 references get fixed.
    • connection_class keeps defaulting to AsyncCConnection. It's harmless (a uuid per named statement, and none at all with the default caches), and changing a default that does nothing would be churn. The class docstring and the create_async_session_manager argument doc stop claiming it provides PgBouncer compatibility and describe what it does: uuid statement names, which only matter for name collisions on a shared server connection, a setup that fails with "does not exist" anyway.

    Shipping together with #58 in one PR, since both rewrite the same section.

  2. AlexeyShalaev commented on Oct 16, 2026

    @AlexeyShalaev
    MemberAuthor

    Docs are in #67, together with #58. The PgBouncer rule now credits statement_cache_size=0, not the connection class, and the version boundary is fixed. I checked it on stock images: 1.21.0, 1.22.1 and 1.23.1 report max_prepared_statements=0, while 1.24.1 and 1.25.2 report 200. The AsyncCConnection docstring and the connection_class doc no longer claim PgBouncer compatibility. The class stays the default because it's harmless.

    Nothing to change on your side.

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 documentation

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions