89 lines
2.7 KiB
Python
89 lines
2.7 KiB
Python
"""Small typed asyncpg pool boundary."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Protocol, Self, cast
|
|
|
|
import asyncpg # type: ignore[import-untyped]
|
|
from asyncpg import Pool
|
|
|
|
from app.config import Settings
|
|
from app.db.migrations import load_migrations
|
|
|
|
LATEST_SCHEMA_VERSION = max(migration.version for migration in load_migrations())
|
|
|
|
|
|
class Database(Protocol):
|
|
"""Application-facing database lifecycle and health interface."""
|
|
|
|
async def connect(self) -> None: ...
|
|
|
|
async def close(self) -> None: ...
|
|
|
|
async def is_ready(self) -> bool: ...
|
|
|
|
|
|
class AsyncpgDatabase:
|
|
"""Own an asyncpg pool without exposing it as global mutable state."""
|
|
|
|
def __init__(self, settings: Settings) -> None:
|
|
self._settings = settings
|
|
self._pool: Pool | None = None
|
|
|
|
@property
|
|
def pool(self) -> Pool:
|
|
"""Return the initialized pool to repository factories."""
|
|
|
|
if self._pool is None:
|
|
message = "database pool is not initialized"
|
|
raise RuntimeError(message)
|
|
return self._pool
|
|
|
|
async def connect(self) -> None:
|
|
"""Create the pool and verify the first connection."""
|
|
|
|
if self._pool is not None:
|
|
return
|
|
settings = self._settings
|
|
pool = await asyncpg.create_pool(
|
|
dsn=settings.database_url.get_secret_value(),
|
|
min_size=settings.db_pool_min_size,
|
|
max_size=settings.db_pool_max_size,
|
|
timeout=settings.db_connect_timeout_seconds,
|
|
command_timeout=settings.db_command_timeout_seconds,
|
|
)
|
|
if pool is None: # pragma: no cover - asyncpg types allow this for legacy reasons
|
|
message = "asyncpg did not create a pool"
|
|
raise RuntimeError(message)
|
|
self._pool = cast(Pool, pool)
|
|
|
|
async def close(self) -> None:
|
|
"""Close all pooled connections; repeated close is safe."""
|
|
|
|
pool, self._pool = self._pool, None
|
|
if pool is not None:
|
|
await pool.close()
|
|
|
|
async def is_ready(self) -> bool:
|
|
"""Check connectivity and that all packaged migrations are applied."""
|
|
|
|
if self._pool is None:
|
|
return False
|
|
try:
|
|
return bool(
|
|
await self._pool.fetchval(
|
|
"""SELECT COALESCE(max(version), 0) >= $1
|
|
FROM schema_migrations""",
|
|
LATEST_SCHEMA_VERSION,
|
|
)
|
|
)
|
|
except asyncpg.PostgresError:
|
|
return False
|
|
|
|
async def __aenter__(self) -> Self:
|
|
await self.connect()
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
|
await self.close()
|