Skip to content

kind="null"/"static" cannot build an engine (pool_size, max_overflow and pool_timeout go to every pool class), and the "queue" pool the docstrings recommend is sync-only #53

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

Two of the three pool kinds the configuration guide lists cannot build an engine. _apply_pool_settings (base/engine.py:237-247) passes pool_size, max_overflow and pool_timeout to create_async_engine for every pool class, and SQLAlchemy rejects them for NullPool and StaticPool. The docstring above it (base/engine.py:227-229) says a hasattr check keeps them away from "pools like NullPool or StaticPool"; there is no such check. PoolSettings.size, .max_overflow and .timeout are not optional (contrib/settings/postgres.py:58-66), so every PoolSettings carries all three. AsyncSessionManager(url, pool_settings=...) without a poolclass fails the same way, because the constructor's default is "null" (session/manager.py:229).

The advice on which pool to choose does not run either. The AsyncSessionManager docstring says Use "queue" for production, "null" for testing. (session/manager.py:247-248). The three examples in the AsyncSessionManagerBuilder docstring use .with_pool("queue") (session/builder.py:35, 44, 55; also with_pool's argument doc at 103). "queue" resolves to the sync QueuePool (base/engine.py:52), and SQLAlchemy refuses it on an asyncio engine. docs/agents.md:371-374 correctly says "async_adapted_queue". The docstrings say otherwise.

Reproduction

No database is needed, because building the engine is enough:

import asyncio

from pydantic import SecretStr
from sqlalchemy_foundation_kit import AsyncSessionManager, AsyncSessionManagerBuilder, create_async_session_manager
from sqlalchemy_foundation_kit.contrib.settings import BasePostgresConfig, ConnectionSettings, PoolSettings

URL = "postgresql+asyncpg://user:secret@localhost:5432/app"  # nothing connects: building the engine is enough


async def attempt(label: str, build) -> None:
    try:
        manager = build()
    except Exception as exc:
        print(f"{label:62} {type(exc).__name__}: {str(exc).splitlines()[0]}")
    else:
        print(f"{label:62} ok, pool = {type(manager.engine.pool).__name__}")
        await manager.aclose()


def from_settings(kind: str):
    config = BasePostgresConfig(
        connection=ConnectionSettings(password=SecretStr("secret"), database="app"),
        application_name="probe",
        pool=PoolSettings(kind=kind, max_overflow=0),
        use_orjson_serialization=False,
    )
    return lambda: create_async_session_manager(config)


async def main() -> None:
    for kind in ("async_adapted_queue", "null", "static"):
        await attempt(f'PoolSettings(kind="{kind}", max_overflow=0)', from_settings(kind))
    await attempt("AsyncSessionManager(url)", lambda: AsyncSessionManager(URL))
    await attempt("AsyncSessionManager(url, pool_settings=PoolSettings())",
                  lambda: AsyncSessionManager(URL, pool_settings=PoolSettings()))
    await attempt('AsyncSessionManager(url, poolclass="queue")', lambda: AsyncSessionManager(URL, poolclass="queue"))
    await attempt('AsyncSessionManagerBuilder(url).with_pool("queue").build()',
                  lambda: AsyncSessionManagerBuilder(URL).with_pool("queue").build())


asyncio.run(main())

Actual

PoolSettings(kind="async_adapted_queue", max_overflow=0)       ok, pool = AsyncAdaptedQueuePool
PoolSettings(kind="null", max_overflow=0)                      TypeError: Invalid argument(s) 'pool_size','max_overflow','pool_timeout' sent to create_engine(), using configuration PGDialect_asyncpg/NullPool/Engine.  Please check that the keyword arguments are appropriate for this combination of components.
PoolSettings(kind="static", max_overflow=0)                    TypeError: Invalid argument(s) 'pool_size','max_overflow','pool_timeout' sent to create_engine(), using configuration PGDialect_asyncpg/StaticPool/Engine.  Please check that the keyword arguments are appropriate for this combination of components.
AsyncSessionManager(url)                                       ok, pool = NullPool
AsyncSessionManager(url, pool_settings=PoolSettings())         TypeError: Invalid argument(s) 'pool_size','max_overflow','pool_timeout' sent to create_engine(), using configuration PGDialect_asyncpg/NullPool/Engine.  Please check that the keyword arguments are appropriate for this combination of components.
AsyncSessionManager(url, poolclass="queue")                    ArgumentError: Pool class QueuePool cannot be used with asyncio engine (Background on this error at: https://sqlalche.me/e/20/pcls)
AsyncSessionManagerBuilder(url).with_pool("queue").build()     ArgumentError: Pool class QueuePool cannot be used with asyncio engine (Background on this error at: https://sqlalche.me/e/20/pcls)

Why

  • base/engine.py:237-247 copies every non-None pool setting, whatever poolclass is. The comment at 244-246 says pools that do not support them "will ignore them or raise TypeError". NullPool and StaticPool raise.

  • The PoolSettings validator (contrib/settings/postgres.py:68-73) only rejects static with max_overflow > 0. So the combination the guide recommends, kind="static", max_overflow=0 (docs/guide/configuration.md:208-215), passes validation and then fails when the engine is built.

  • Filtering the kwargs would not make kind="static" what the guide describes. The guide calls it a "Fixed-size pool with no overflow" (docs/guide/configuration.md:184) and shows it at 208 as "High-throughput: static pool" with size=50. SQLAlchemy's StaticPool is "A Pool of exactly one connection, used for all requests", and size has no meaning for it. On an async engine, concurrent sessions share that one asyncpg connection:

    engine = create_async_engine(url, poolclass=StaticPool)  # what kind="static" builds once the kwargs are filtered
    sessions = async_sessionmaker(engine)
    
    async def request() -> int:
        async with sessions() as s:
            return await s.scalar(text("SELECT pg_backend_pid() FROM pg_sleep(0.1)"))
    
    print("sequential:", [await request() for _ in range(3)])
    for result in await asyncio.gather(*(request() for _ in range(3)), return_exceptions=True): ...
    sequential: [98, 98, 98]
    concurrent: InterfaceError: (sqlalchemy.dialects.postgresql.asyncpg.InterfaceError) <class 'asyncpg.exceptions._base.InterfaceError'>: cannot perform operation: another operation is in progress
    concurrent: 98
    concurrent: InterfaceError: (sqlalchemy.dialects.postgresql.asyncpg.InterfaceError) <class 'asyncpg.exceptions._base.InterfaceError'>: cannot use Connection.transaction() in a manually started transaction
    

    A fixed-size pool is async_adapted_queue with max_overflow=0.

  • PoolRegistry (base/engine.py:50-57) and PoolClassStr include "queue" and "singleton_thread". Both are sync pools and raise ArgumentError: ... cannot be used with asyncio engine in a library that is async-only.

Proposal

  1. In _apply_pool_settings, pass pool_size, max_overflow and pool_timeout only when issubclass(poolclass, QueuePool). That covers AsyncAdaptedQueuePool, FallbackAsyncAdaptedQueuePool, registered subclasses and the class instrument_pool_class builds. Every Pool accepts pool_recycle and pool_pre_ping. Then fix the docstring at 227-229 to describe what the code does.
  2. Document static as one shared connection (for tests), and drop the "High-throughput" example. For a fixed-size pool, point to async_adapted_queue with max_overflow=0. The validator could then reject kind="static" with size > 1 rather than with overflow.
  3. In the AsyncSessionManager and builder docstrings, replace "queue" with "async_adapted_queue". Either drop "queue" and "singleton_thread" from PoolClassStr and the built-in registry, or resolve "queue" to AsyncAdaptedQueuePool. Nothing else can run on these engines.
  4. Consider making "async_adapted_queue" the default poolclass of AsyncSessionManager and the builder (session/builder.py:70). Today AsyncSessionManager(url) quietly opens a new connection for every session, and until item 1 lands, adding pool_settings= without also setting poolclass crashes. create_async_session_manager already ends up on async_adapted_queue through PoolSettings.kind. If the "null" default stays (docs/agents.md:371 documents it as "for tests"), the docstrings should say so instead of recommending "queue".

Activity

  1. AlexeyShalaev commented on Oct 16, 2026

    @AlexeyShalaev
    MemberAuthor

    Reproduced on master with the script above: same seven lines.

    Plan:

    • _apply_pool_settings passes pool_size, max_overflow and pool_timeout only when issubclass(poolclass, QueuePool). That covers the async queue pools, registered subclasses and the class instrument_pool_class builds. pool_recycle and pool_pre_ping still go to every pool. The docstring will say what the code does.
    • kind="static" gets documented as what SQLAlchemy builds: one connection shared by every session, which is fine for tests and wrong for anything concurrent. The "high-throughput" example goes. A fixed-size pool is async_adapted_queue with max_overflow=0. The validator stops checking max_overflow for static, since the setting doesn't apply there, and rejects size > 1 with a message pointing at async_adapted_queue. That way kind="static", size=50 fails at config load and not as another operation is in progress under load. It can't break a working config: until now no PoolSettings with kind="static" could build an engine.
    • AsyncSessionManager(url, pool_settings=...) without poolclass takes the pool from pool_settings.kind. The protocol already carries kind, and create_async_session_manager already does this. Without the change, the kwargs filter would turn today's crash into a silent NullPool that ignores the sizes. The signature default becomes None, which still means "null" when no pool_settings are given.
    • In the AsyncSessionManager/builder docstrings, "queue" becomes "async_adapted_queue", and "null" is described as one connection per checkout, for tests and scripts.

    Things I'm not changing:

    • "queue" and "singleton_thread" stay in PoolRegistry/PoolClassStr. The registry and resolve_pool_class are public and can serve a sync engine too (a migrations engine built from the same PoolSettings), so making "queue" resolve to AsyncAdaptedQueuePool would break that. On the async manager, SQLAlchemy already rejects them at construction with a clear ArgumentError. The trap was the docstrings recommending them, and those get fixed.
    • The "null" default of AsyncSessionManager and the builder stays. Pytest suites that build a manager per test (and per event loop) depend on it, because a pooled asyncpg connection can't cross loops. Making the default async_adapted_queue would break them for an aesthetic gain.
  2. AlexeyShalaev commented on Oct 16, 2026

    @AlexeyShalaev
    MemberAuthor

    Fix is in #61. PoolSettings(kind="null") and kind="static", size=1 now build an engine and run queries (integration test on PostgreSQL 17 through create_async_session_manager). AsyncSessionManager(url, pool_settings=...) without poolclass picks up pool_settings.kind. The docstrings recommend "async_adapted_queue".

    What you might need to change: a config with kind="static" and size > 1 now fails validation, with a message pointing at async_adapted_queue + max_overflow=0. That config could never build an engine, so it only turns a crash at engine creation into one at config load. "queue" stays in the registry for sync engines, as described in the plan above.

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

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions