1616
1717from typing import Optional
1818
19- from sqlalchemy import Engine , create_engine
19+ from sqlalchemy import Engine , create_engine , event
2020
2121from application .core .settings import settings
2222
@@ -43,17 +43,17 @@ def _resolve_uri() -> str:
4343#: Per-statement wall-clock cap applied to every connection handed out by
4444#: the engine. 30s is generous for interactive hot paths (reads under a few
4545#: hundred ms are normal) but still catches a runaway query before it
46- #: stacks up on PgBouncer or holds locks indefinitely. Override by
47- #: rebuilding the engine with a different ``connect_args`` in tests.
46+ #: stacks up on PgBouncer or holds locks indefinitely.
4847STATEMENT_TIMEOUT_MS = 30_000
4948
5049
5150def get_engine () -> Engine :
5251 """Return the process-wide SQLAlchemy Engine, creating it if needed.
5352
5453 The engine applies a server-side ``statement_timeout`` to every
55- connection it hands out, so both :func:`db_session` and
56- :func:`db_readonly` inherit the same guardrail.
54+ connection it hands out via a ``connect`` event, so both
55+ :func:`db_session` and :func:`db_readonly` inherit the same
56+ guardrail.
5757
5858 Returns:
5959 A SQLAlchemy ``Engine`` configured with a pooled connection to
@@ -68,13 +68,20 @@ def get_engine() -> Engine:
6868 pool_pre_ping = True , # survive PgBouncer / idle-disconnect recycles
6969 pool_recycle = 1800 ,
7070 future = True ,
71- connect_args = {
72- # ``-c`` passes a GUC to the backend at connect time. This
73- # covers *all* sessions — interactive, Celery, seeder — so
74- # no route-handler can opt out by accident.
75- "options" : f"-c statement_timeout={ STATEMENT_TIMEOUT_MS } " ,
76- },
7771 )
72+
73+ @event .listens_for (_engine , "connect" )
74+ def _apply_session_guardrails (dbapi_conn , _record ):
75+ # Apply as a SQL ``SET`` (not a libpq ``options=-c ...``
76+ # startup parameter) so the engine works behind
77+ # PgBouncer-style poolers — notably Neon's ``-pooler``
78+ # endpoint, which rejects startup options. Explicit
79+ # ``commit()`` so the session-level SET survives SA's
80+ # transaction resets on pool return.
81+ with dbapi_conn .cursor () as cur :
82+ cur .execute (f"SET statement_timeout = { STATEMENT_TIMEOUT_MS } " )
83+ dbapi_conn .commit ()
84+
7885 return _engine
7986
8087
0 commit comments