feat: measure the wait for a pool connection, and count pool checkout timeouts - #27
Merged
Merged
Conversation
… timeouts `postgres_db_connection_timeouts_total` was fed only from the engine's `handle_error` listener. A `QueuePool` checkout timeout is raised by `pool.connect()` before any DBAPI call and never reaches that listener, so the one timeout a pool actually produces under load was the one the counter did not count -- it stayed at zero through a saturated pool. `postgres_db_connection_checkout_duration_seconds` is the time between the `checkout` and `checkin` events: how long a connection was *held*, which is query duration seen from the pool, not the time a caller waited for one. The wait -- the number that predicts a pool outage -- was not exposed at all. SQLAlchemy fires no event when a checkout is requested, so the wait cannot come from a listener. `instrument_pool_class(pool_class, metrics)` returns a subclass that times `Pool.connect()`, which runs exactly once per checkout for sync and async engines alike and through which the pool's `TimeoutError` passes exactly once. `_do_get` is the wrong hook: `QueuePool` recurses into it when the non-blocking get comes back empty and the overflow race is lost -- precisely the contended case -- and would observe the same wait twice. The recorder lives on the class, so `recreate()` after `dispose()` keeps recording. `AsyncSessionManager` applies it to whatever `resolve_pool_class` returned, so registered custom pools and the six built-ins are covered without touching callers. The capability is a new narrow `CheckoutWaitRecorder`, deliberately not part of `PostgresMetricsProtocol`: an implementation written against the three older capabilities stays valid and simply publishes no wait series. `attach_metrics` warns when the metrics object records the wait but the engine's pool was not built for it, rather than leaving a histogram that never moves. Metrics: - `postgres_db_connection_checkout_wait_seconds` is new, with buckets to 30 s so the default `pool_timeout` does not land in `+Inf`. - `postgres_db_connection_held_duration_seconds` is the held duration under a name that says so. - `postgres_db_connection_checkout_duration_seconds` is deprecated. It still carries the same numbers under its old name, so dashboards keep working, and will be removed in a future release. - `postgres_db_connection_timeouts_total` now also counts pool checkout timeouts, which is what its description always claimed.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two defects in the pool metrics, both reported in #26 and both reproduced before anything
was written.
postgres_db_connection_timeouts_totaldid not count pool checkout timeouts. It wasfed only from the engine's
handle_errorlistener. AQueuePoolcheckout timeout israised by
pool.connect()before any DBAPI call, so it never reaches that listener — theone timeout a pool actually produces under load was the one the counter did not count.
postgres_db_connection_checkout_duration_secondsmeasures held time, not wait. It isthe interval between the
checkoutandcheckinevents: how long a caller held aconnection, which is query duration seen from the pool. Useful, but its name says checkout
and the guide read it as the time a caller waited. The wait — the number that predicts a
pool outage — was not exposed at all.
What changed
postgres_db_connection_checkout_wait_secondspool.connect()before a connection is handed out — the queue wait, plus the pre-ping and the connect handshake when the pool has to grow. Buckets run to 30 s, the defaultpool_timeout, so a saturated pool fills the top buckets instead of collapsing into+Infpostgres_db_connection_held_duration_secondspostgres_db_connection_checkout_duration_secondspostgres_db_connection_timeouts_totalNothing is removed and no signature changes, so this is a
feat:— a minor bump, which iswhat a metric changing meaning warrants.
Design, and what I rejected
SQLAlchemy fires no event when a checkout is requested, only when one succeeds, so the
wait cannot come from a listener. It has to be timed inside the pool.
instrument_pool_class(pool_class, metrics)(insession.manager, besideattach_metrics)returns a subclass that times
Pool.connect(). That is the right hook:Engine.raw_connection()calls for syncand async engines alike;
TimeoutErrorpasses through it exactly once;AsyncAdaptedQueuePoolthe queue wait is anawait_onlyinside that call, so aperf_counterpair aroundsuper().connect()in the greenlet spans the whole suspensionand measures the time the caller really spent waiting.
Rejected: overriding
_do_get.QueuePoolrecurses into it when the non-blockinggetcomes back empty and the race for an overflow slot is lost — precisely the contended case
this metric exists for. It would observe the same wait twice and count one timeout as two.
Rejected: timing
session.connection()inget_session. It misses everything that goesthrough
session_maker— the unit of work, both DI containers — andengine.connect().The recorder lives on the class, which is what makes a pool produced by
recreate()afterdispose()keep recording.AsyncSessionManagerwraps whateverresolve_pool_classreturned before building the engine, so registered custom pools and the six built-ins are
covered without touching callers.
The capability is a new narrow
CheckoutWaitRecorder, deliberately not part ofPostgresMetricsProtocol: a hand-written implementation from the guide stays valid andsimply publishes no wait series.
attach_metricsnow logs a warning when the metrics objectrecords the wait but the engine's pool was not built for it, so a self-built engine does not
end up with an empty histogram nobody notices.
Verification
make checkandmake test(the repo's full gate, 90% coverage floor):Tests that fail on
masterand pass here —tests/integration/test_metrics_integration.py,against a PostgreSQL 17 container:
The reporter's own lab, run unmodified against this branch —
timeouts_totalnow tracksthe application's own count of
sqlalchemy.exc.TimeoutErrorexactly, where it stayed at 0before:
Its
checkout waitcolumn still shows held time because the script reads the deprecatedseries by name — which is exactly the confusion the rename fixes. The same run with the
column pointed at
…_checkout_wait_seconds, and a second column for…_held_duration_seconds:heldis the query duration, 1 s.waitis the queue wait, bounded above by the 1 spool_timeout— the callers that give up contribute exactly 1000 ms, the ones that areserved waited less. Before the slowdown the two coincide, because a pool of four with eight
workers is exactly saturated and each caller waits about one query's worth.
Behaviour for anyone who never had this problem is unchanged: no metric was removed or
renamed in place,
record_checkoutkeeps its name and its meaning,PostgresMetricsProtocolkeeps its three methods, and a metrics object without
record_checkout_waitgets exactlywhat it got before.
Type of change
Checklist
make checkpasses locally (ruff+mypy)CHANGELOG.mdupdated under[Unreleased]— n/a, the changelog is generated byrelease-please from the conventional commit
docs/agents.mdincludedRelated issues
Closes #26