fix(db): enable pool_pre_ping on async engine — 500 on stale Postgres connection

증상:
- 라이브 smoke 도중 SSE poll loop 가 0.5s 마다 connection 을 빌리던 중,
  asyncpg pool 이 idle/network blip 으로 socket 이 닫힌 stale connection
  을 그대로 넘김.  다음 요청 (GET /api/sessions) 이
  `sqlalchemy.exc.InterfaceError: connection is closed` 로 500.

원인:
- `create_async_engine(database_url, poolclass=None, echo=False)` —
  pool_pre_ping 미설정.  SQLAlchemy 가 checkout 시 connection 생존
  확인 안 함.

수정:
- `pool_pre_ping=True` 한 줄 추가.  SQLAlchemy 가 매 checkout 직전 빠른
  SELECT 1 (asyncpg 는 protocol-level ping) 을 보내고 실패 시 pool 에서
  invalidate 후 새 connection 발급.  표준 SQLAlchemy 권장 패턴.
- 부하 (SSE 0.5s polling + REST) 에서 검증: 재시작 후 GET /api/sessions
  연속 호출 모두 200.

테스트:
- ruff / mypy: PASS (141 files)
- pytest tests/integration/test_persistence.py: 20 passed (회귀 없음)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chungyeong
2026-05-18 00:24:24 +09:00
parent 96c8849e2c
commit 40ef833ad3

View File

@@ -75,10 +75,17 @@ class Database:
"""
def __init__(self, database_url: str) -> None:
# v0.3 hotfix: Postgres asyncpg pool occasionally hands out stale
# connections whose underlying socket was closed by the server (idle
# timeout, container restart, network blip, …). `pool_pre_ping`
# adds a fast ping before each checkout and invalidates dead
# connections so the next acquire dials a fresh one — fixes the
# "InterfaceError: connection is closed" 500 seen under SSE load.
self._engine: AsyncEngine = create_async_engine(
database_url,
poolclass=None,
echo=False,
pool_pre_ping=True,
)
_attach_dialect_pragmas(self._engine)
self._session_factory: async_sessionmaker[AsyncSession] = async_sessionmaker(