"""PostgreSQL access: one pool for the app role, plain connections for other roles.""" from __future__ import annotations import logging import threading from pathlib import Path from typing import Any, Literal import psycopg from psycopg_pool import ConnectionPool from .settings import Settings, get_settings log = logging.getLogger(__name__) Role = Literal["app", "worker", "migrate"] MIGRATIONS_DIR = Path(__file__).resolve().parent / "migrations" APP_NAMES: dict[str, str] = {"app": "ps-app", "worker": "ps-worker", "migrate": "ps-migrate"} class DatabaseUnavailable(Exception): """No connection could be made (bad secret, server down, pool timeout).""" def connect_kwargs(role: Role, settings: Settings | None = None) -> dict[str, Any]: """Connection keyword arguments for a role. The worker logs in as the app user.""" s = settings or get_settings() db_role = "migrate" if role == "migrate" else "app" return { "host": s.pg_host, "port": s.pg_port, "dbname": s.pg_database, "user": s.pg_user(db_role), "password": s.pg_password(db_role), "application_name": APP_NAMES[role], "connect_timeout": 5, } def connect_as(role: Role, settings: Settings | None = None, **extra: Any) -> psycopg.Connection[Any]: """A new connection as the given role; the caller closes it.""" kwargs = connect_kwargs(role, settings) kwargs.update(extra) return psycopg.connect(**kwargs) _pool: ConnectionPool[Any] | None = None _pool_lock = threading.Lock() def get_pool(settings: Settings | None = None) -> ConnectionPool[Any]: """The app role's pool, opened on first use without waiting for the server.""" global _pool with _pool_lock: if _pool is None: try: kwargs = connect_kwargs("app", settings) except Exception as e: raise DatabaseUnavailable(str(e)) from None _pool = ConnectionPool(kwargs=kwargs, min_size=1, max_size=10, open=False, name="ps-app") _pool.open(wait=False) return _pool def close_pool() -> None: global _pool with _pool_lock: if _pool is not None: _pool.close() _pool = None def ping(settings: Settings | None = None, timeout: float = 3.0) -> None: """SELECT 1 as the app role; raises DatabaseUnavailable.""" try: pool = get_pool(settings) with pool.connection(timeout=timeout) as conn: conn.execute("SELECT 1").fetchone() except DatabaseUnavailable: raise except Exception as e: raise DatabaseUnavailable(type(e).__name__) from None