Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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

Expand Down
12 changes: 12 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
22 changes: 22 additions & 0 deletions app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
47 changes: 47 additions & 0 deletions tests/test_db_pool.py
Original file line number Diff line number Diff line change
@@ -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