diff --git a/.env.example b/.env.example index 7c54b9d..81c424f 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,22 @@ # Database DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/criticalbit_auth +# Database connection pool (per Cloud Run instance). Cloud Run scales +# horizontally, so size against Cloud SQL's max_connections: +# +# (DB_POOL_SIZE + DB_MAX_OVERFLOW) × max-instances + migrate-job + headroom +# ≤ Cloud SQL max_connections +# +# Defaults below (10/instance) fit a 200-connection tier comfortably at +# max-instances=10 (see .github/workflows/deploy.yml). For a ~100-connection +# tier, lower max-instances or the pool. Defaults are applied to Postgres +# only; SQLite (dev/test) ignores them. +# DB_POOL_SIZE=5 # persistent connections kept open per instance +# DB_MAX_OVERFLOW=5 # extra burst connections above pool_size +# DB_POOL_TIMEOUT=30 # seconds to wait for a free connection before erroring +# DB_POOL_RECYCLE=1800 # recycle connections older than N seconds +# DB_POOL_PRE_PING=true # check liveness before use (handles idle drops) + # Auth - generate with: openssl rand -hex 32 SECRET_KEY=your-secret-key-change-in-production diff --git a/app/config.py b/app/config.py index 910220c..3b438a7 100644 --- a/app/config.py +++ b/app/config.py @@ -14,6 +14,18 @@ class Settings(BaseSettings): # Database database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/criticalbit_auth" + # Database connection pool (per Cloud Run instance). On Cloud Run we scale + # horizontally, so total DB connections ≈ (pool_size + max_overflow) × + # running instances and must stay under Cloud SQL's max_connections with + # headroom for the Alembic migrate job. Conservative defaults; tune per + # tier via env. See .env.example for the sizing formula. Ignored for + # SQLite (dev/test), whose pool doesn't take these args. + db_pool_size: int = 5 + db_max_overflow: int = 5 + db_pool_timeout: int = 30 + db_pool_recycle: int = 1800 + db_pool_pre_ping: bool = True + # Auth secret_key: str = "change-me-in-production" diff --git a/app/database.py b/app/database.py index df6408a..876f2d6 100644 --- a/app/database.py +++ b/app/database.py @@ -12,9 +12,31 @@ class Base(DeclarativeBase): pass +def _pool_kwargs(database_url: str) -> dict: + """Connection-pool kwargs for ``create_async_engine``. + + Empty for SQLite (dev/test), which SQLAlchemy backs with a ``StaticPool`` + that rejects ``pool_size``/``max_overflow``. For the production + ``postgresql+asyncpg`` engine these bound the per-instance pool and add + liveness checks (``pool_pre_ping``) plus periodic recycling, so Cloud + SQL dropping an idle connection surfaces as a transparent reconnect + rather than a request error. + """ + if database_url.startswith("sqlite"): + return {} + return { + "pool_size": settings.db_pool_size, + "max_overflow": settings.db_max_overflow, + "pool_timeout": settings.db_pool_timeout, + "pool_recycle": settings.db_pool_recycle, + "pool_pre_ping": settings.db_pool_pre_ping, + } + + engine = create_async_engine( settings.database_url, echo=settings.is_development, + **_pool_kwargs(settings.database_url), ) async_session_maker = async_sessionmaker( diff --git a/tests/test_db_pool.py b/tests/test_db_pool.py new file mode 100644 index 0000000..fdb0657 --- /dev/null +++ b/tests/test_db_pool.py @@ -0,0 +1,47 @@ +"""Tests for the database connection-pool configuration (follow-up to #47). + +On Cloud Run, total DB connections ≈ (pool_size + max_overflow) × instances, +so the per-instance pool is bounded and configurable, sized against Cloud +SQL's max_connections. SQLite (dev/test) uses a StaticPool that rejects these +args, so they must be applied to Postgres only. +""" + +from __future__ import annotations + +from sqlalchemy.ext.asyncio import create_async_engine + +from app.config import settings +from app.database import _pool_kwargs + +POSTGRES_URL = "postgresql+asyncpg://u:p@h/db" + + +def test_pool_kwargs_empty_for_sqlite() -> None: + """SQLite's StaticPool rejects pool_size/max_overflow — must pass none.""" + assert _pool_kwargs("sqlite+aiosqlite:///:memory:") == {} + assert _pool_kwargs("sqlite:///./local.db") == {} + + +def test_pool_kwargs_set_for_postgres() -> None: + assert _pool_kwargs(POSTGRES_URL) == { + "pool_size": settings.db_pool_size, + "max_overflow": settings.db_max_overflow, + "pool_timeout": settings.db_pool_timeout, + "pool_recycle": settings.db_pool_recycle, + "pool_pre_ping": settings.db_pool_pre_ping, + } + + +def test_postgres_engine_applies_configured_pool_size() -> None: + """asyncpg never connects at construction, so building a throwaway engine + is safe and confirms the kwargs actually bound the pool.""" + eng = create_async_engine(POSTGRES_URL, **_pool_kwargs(POSTGRES_URL)) + assert eng.pool.size() == settings.db_pool_size + + +def test_defaults_stay_conservative() -> None: + """Tripwire: keep the per-instance footprint modest so (pool × instances) + stays under Cloud SQL's ceiling. If you raise these intentionally, update + the formula in .env.example and --max-instances in deploy.yml too.""" + assert settings.db_pool_size + settings.db_max_overflow <= 10 + assert settings.db_pool_pre_ping is True