Repository navigation
Closed
Labels
bugSomething isn't workingSomething isn't working
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
attach_metrics samples the three pool gauges from the pool's checkout and checkin events (session/manager.py:145-162). None of them means what its help text and the docs say:
postgres_db_pool_overflowexportspool.overflow()unchanged (session/manager.py:150).QueuePoolstarts its overflow counter at-pool_sizeand adds one for each connection it opens (sqlalchemy/pool/impl.py:138, 253-254). Withsize=10, the gauge reads-9after the first request and stays negative until more thansizeconnections are open. The help text is "Number of connections over pool_size (within max_overflow)" (contrib/metrics/postgres.py:99-102).postgres_db_pool_sizeexportspool.size(), which is themaxsizeset inQueuePool's config (impl.py:244-245). It stays at 10 whether the pool holds one connection or ten. The help text says "Current database connection pool size" (contrib/metrics/postgres.py:91-94),docs/agents.md:339says "connections the pool holds", anddocs/guide/advanced.md:271says "Current pool size".- All three go stale after a burst. They are written only on
checkout/checkin. SQLAlchemy dispatchescheckinbefore it gives the connection back (sqlalchemy/pool/base.py:769-772:pool.dispatch.checkin(...), thenpool._return_conn(self)). So the last sample of a burst still counts the connection being returned, and nothing updates the gauges until the next checkout. An idle pool reportschecked_out=1or more for as long as it stays idle.
Reproduction
import asyncio
from prometheus_client import REGISTRY
from sqlalchemy import text
from sqlalchemy_foundation_kit import AsyncSessionManager
from sqlalchemy_foundation_kit.contrib.metrics import PostgresMetrics
from sqlalchemy_foundation_kit.contrib.settings import PoolSettings
from testcontainers.postgres import PostgresContainer
def gauges() -> str:
values = {g: REGISTRY.get_sample_value(f"postgres_db_pool_{g}") for g in ("size", "checked_out", "overflow")}
return " ".join(f"{name}={value}" for name, value in values.items())
async def main(url: str) -> None:
manager = AsyncSessionManager(
url,
poolclass="async_adapted_queue",
pool_settings=PoolSettings(size=10, max_overflow=20),
metrics=PostgresMetrics(),
)
pool = manager.engine.pool
async def hold(n: int) -> None:
checked_out, release = asyncio.Event(), asyncio.Event()
ready = 0
async def one() -> None:
nonlocal ready
async with manager.get_session() as session:
await session.execute(text("SELECT 1"))
ready += 1
if ready == n:
checked_out.set()
await release.wait()
tasks = [asyncio.create_task(one()) for _ in range(n)]
await checked_out.wait()
print(f"{n:2} connections checked out: {gauges()} (open connections: {pool.checkedin() + pool.checkedout()})")
release.set()
await asyncio.gather(*tasks)
def after() -> None:
print(f"all returned: {gauges()} (pool.checkedout()={pool.checkedout()}, "
f"open connections: {pool.checkedin() + pool.checkedout()})")
await hold(1)
after()
await hold(12)
after()
await manager.aclose()
with PostgresContainer("postgres:17-alpine", driver="asyncpg") as pg:
asyncio.run(main(pg.get_connection_url()))Actual
1 connections checked out: size=10.0 checked_out=1.0 overflow=-9.0 (open connections: 1)
all returned: size=10.0 checked_out=1.0 overflow=-9.0 (pool.checkedout()=0, open connections: 1)
12 connections checked out: size=10.0 checked_out=12.0 overflow=2.0 (open connections: 12)
all returned: size=10.0 checked_out=2.0 overflow=2.0 (pool.checkedout()=0, open connections: 10)
The first idle pool exports overflow=-9 and checked_out=1 while it has one open connection and none in use. After the burst, every connection is back and the two overflow connections are closed, yet the exporter still reports two connections in use and two in overflow. It keeps reporting that until the next request.
Why
record_pool_stats(session/manager.py:145-153) passespool.size(),pool.checkedout()andpool.overflow()through unchanged. Those areQueuePool's internal counters:overflow()is "open connections minuspool_size", which can be negative, andsize()is the configured maximum.- It runs from
on_checkout(session/manager.py:155-158) andon_checkin(session/manager.py:160-162). No event fires after a connection has been returned, so the value written by the lastcheckinis always one return behind.
Proposal
- Read the pool when Prometheus scrapes rather than from events, e.g.
Gauge.set_function(pool.checkedout), bound when the metrics are attached to an engine. A narrow optional capability, detected the wayCheckoutWaitRecorderis, would keepPostgresMetricsProtocolunchanged. This fixes the staleness and also removes listener work from every checkout and checkin. - Export
pool_overflowasmax(pool.overflow(), 0). - For
pool_size, either change the help text and docs to "configuredpool_size", or export open connections (checkedin() + checkedout()) and publish the configuredpool_size/max_overflowas separate series. The "Pool utilization" query indocs/guide/advanced.md:336-337divideschecked_outbypool_size, so it goes above 100% as soon as overflow connections are used. The pool's capacity ispool_size + max_overflow.
Metadata
Metadata
Assignees
Labels
bugSomething isn't workingSomething isn't working
Activity
AlexeyShalaev commentedon Oct 16, 2026
Reproduced on master: same four lines.
Plan:
CheckoutWaitRecorder, detected the same way, soPostgresMetricsProtocoldoesn't change:PoolStatsFunctionRecorder.set_pool_stats_functions(pool_size, pool_checked_out, pool_overflow), which takes zero-argument callables.PostgresMetricsimplements it withGauge.set_function.attach_metricsbinds the callables toengine.pooland reads it at call time, so the pool thatdispose()recreates is the one being read. For such a recorder the checkout/checkin listeners no longer touch the gauges, only the held-duration histogram.pool_overflowis exported asmax(pool.overflow(), 0)on both paths.pool_sizekeeps its value, the configuredpool_size, and the HELP text, protocol docstring,docs/agents.mdand the metrics table say so. I thought about switching it to open connections, as the docs claim, but that would quietly change the meaning of a series people already graph and alert on. The utilization query in the advanced guide gets a note that it passes 1 once overflow connections are in use, because capacity ispool_size + max_overflow.record_pool_statskeeps the event path (with the overflow clamp). Its docstring will say that the event-time sample is one return behind and point at the new capability.Who notices: anyone graphing
postgres_db_pool_overflow(no more negative values) orpostgres_db_pool_checked_out(it returns to 0 when idle).AlexeyShalaev commentedon Oct 16, 2026
Fix is in #62. With
PostgresMetricsthe three gauges are now read when Prometheus scrapes, through a new optionalPoolStatsFunctionRecordercapability, so your script printschecked_out=0.0 overflow=0.0for both idle rows andoverflow=0.0(not-9) with one connection out.pool_sizekeeps its value and is now documented as the configuredpool_size.Nothing to change on your side unless you have a custom metrics class: it keeps working as before (with the overflow clamp), and adding
set_pool_stats_functionsopts it into reading at scrape time.