Skip to content
11 changes: 10 additions & 1 deletion backend/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
from pydantic import SecretStr, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

from core.env_paths import ENV_FILE_PATHS, operator_env_file_paths
from core.runtime_secrets import (
validate_auth_session_hmac_secret_value,
)
from core.env_paths import ENV_FILE_PATHS, operator_env_file_paths
from core.url_validation import (
parse_allowed_hosts,
validate_https_url_host_details,
Expand Down Expand Up @@ -64,6 +64,15 @@ def parse_allowed_cors_origins(raw_origins: str) -> list[str]:
class Settings(BaseSettings):
DATABASE_URL: str
READONLY_DATABASE_URL: str | None = None
# Connection-pool tuning. Sizing values default to None (SQLAlchemy
# defaults) so behavior is unchanged until an operator sets them.
# pre_ping detects dead connections at checkout; recycle avoids
# server-side idle timeouts killing pooled connections.
DB_POOL_SIZE: int | None = None
DB_MAX_OVERFLOW: int | None = None
DB_POOL_TIMEOUT_SECONDS: int | None = None
DB_POOL_RECYCLE_SECONDS: int = 1800
DB_POOL_PRE_PING: bool = True
DEBUG: bool = False
RUNTIME_ENVIRONMENT: str = "production"
AUTH_SESSION_HMAC_SECRET: SecretStr | None = None
Expand Down
29 changes: 27 additions & 2 deletions backend/db/session.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,36 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from core.config import settings

engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG)

def _engine_kwargs() -> dict:
"""Engine kwargs shared by the primary and read-only engines.

Pool sizing values are only passed when configured, so unset settings keep
SQLAlchemy defaults (and stay compatible with pool classes that do not
accept sizing, e.g. SQLite's pools in tests). pre_ping validates pooled
connections at checkout so a restarted/failed-over database does not
surface as user-facing errors on stale connections; recycle proactively
replaces connections older than the configured age.
"""
kwargs: dict = {
"echo": settings.DEBUG,
"pool_pre_ping": settings.DB_POOL_PRE_PING,
"pool_recycle": settings.DB_POOL_RECYCLE_SECONDS,
}
if settings.DB_POOL_SIZE is not None:
kwargs["pool_size"] = settings.DB_POOL_SIZE
if settings.DB_MAX_OVERFLOW is not None:
kwargs["max_overflow"] = settings.DB_MAX_OVERFLOW
if settings.DB_POOL_TIMEOUT_SECONDS is not None:
kwargs["pool_timeout"] = settings.DB_POOL_TIMEOUT_SECONDS
return kwargs


engine = create_async_engine(settings.DATABASE_URL, **_engine_kwargs())
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)

readonly_database_url = settings.READONLY_DATABASE_URL or settings.DATABASE_URL
readonly_engine = create_async_engine(readonly_database_url, echo=settings.DEBUG)
readonly_engine = create_async_engine(readonly_database_url, **_engine_kwargs())
AsyncReadOnlySessionLocal = async_sessionmaker(readonly_engine, expire_on_commit=False)


Expand Down
2 changes: 1 addition & 1 deletion backend/scripts/start_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT))

from core.env_paths import ENV_FILE_PATHS, expand_operator_path # noqa: E402
from core.runtime_secrets import validate_auth_session_hmac_secret_value # noqa: E402
from core.env_paths import ENV_FILE_PATHS, expand_operator_path # noqa: E402
from core.url_validation import ( # noqa: E402
parse_allowed_hosts,
validate_https_url_host_details,
Expand Down
22 changes: 10 additions & 12 deletions backend/services/email_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,18 +412,16 @@ class _PinnedImplicitTlsSMTP(aiosmtplib.SMTP):

def __init__(self, *, tls_server_hostname: str, **kwargs):
self._tls_server_hostname = tls_server_hostname
# aiosmtplib validates that a socket-backed implicit-TLS client has a
# hostname (it raises "If using a socket with TLS, hostname is
# required"). The pinned client connects an already-resolved socket and
# supplies SNI via ``tls_server_hostname`` in ``_create_connection``, so
# mirror that value into ``hostname`` to satisfy validation without a
# second DNS lookup.
if (
kwargs.get("sock") is not None
and kwargs.get("use_tls")
and kwargs.get("hostname") is None
):
kwargs["hostname"] = tls_server_hostname
# Keep socket-backed clients from falling back to aiosmtplib's
# hostname="localhost" default. The socket is already connected to the
# validated address; SNI is supplied separately during TLS setup.
if kwargs.get("sock") is not None:
kwargs.setdefault("port", None)
kwargs.setdefault("socket_path", None)
if kwargs.get("use_tls") and kwargs.get("hostname") is None:
kwargs["hostname"] = tls_server_hostname
else:
kwargs.setdefault("hostname", None)
super().__init__(**kwargs)

async def _create_connection(self, timeout: float | None):
Expand Down
108 changes: 78 additions & 30 deletions backend/tests/test_db_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,70 +15,118 @@ def _import_isolated_db_session():
sys.modules["db.session"] = original


def test_readonly_session_uses_replica_url_when_configured(monkeypatch):
created_urls: list[str] = []

def fake_create_async_engine(url, *, echo=False):
created_urls.append(url)
return SimpleNamespace(url=url, echo=echo)
def _settings_namespace(**overrides) -> SimpleNamespace:
values = {
"DATABASE_URL": "postgresql+asyncpg://primary/db",
"READONLY_DATABASE_URL": None,
"DEBUG": False,
"DB_POOL_SIZE": None,
"DB_MAX_OVERFLOW": None,
"DB_POOL_TIMEOUT_SECONDS": None,
"DB_POOL_RECYCLE_SECONDS": 1800,
"DB_POOL_PRE_PING": True,
}
values.update(overrides)
return SimpleNamespace(**values)


def _install_fakes(monkeypatch, created: list[tuple[str, dict]]):
def fake_create_async_engine(url, **kwargs):
created.append((url, kwargs))
return SimpleNamespace(url=url, kwargs=kwargs)

def fake_sessionmaker(engine, *, expire_on_commit=False):
return SimpleNamespace(engine=engine, expire_on_commit=expire_on_commit)

monkeypatch.setattr("sqlalchemy.ext.asyncio.create_async_engine", fake_create_async_engine)
monkeypatch.setattr(
"sqlalchemy.ext.asyncio.create_async_engine", fake_create_async_engine
)
monkeypatch.setattr("sqlalchemy.ext.asyncio.async_sessionmaker", fake_sessionmaker)


def test_readonly_session_uses_replica_url_when_configured(monkeypatch):
created: list[tuple[str, dict]] = []
_install_fakes(monkeypatch, created)

import core.config as config

monkeypatch.setattr(
config,
"settings",
SimpleNamespace(
DATABASE_URL="postgresql+asyncpg://primary/db",
_settings_namespace(
READONLY_DATABASE_URL="postgresql+asyncpg://replica/db",
DEBUG=False,
),
)

reloaded = _import_isolated_db_session()

assert created_urls == [
assert [url for url, _kwargs in created] == [
"postgresql+asyncpg://primary/db",
"postgresql+asyncpg://replica/db",
]
assert reloaded.AsyncReadOnlySessionLocal.engine.url == "postgresql+asyncpg://replica/db"


def test_readonly_session_falls_back_to_primary_url_without_replica(monkeypatch):
created_urls: list[str] = []
created: list[tuple[str, dict]] = []
_install_fakes(monkeypatch, created)

def fake_create_async_engine(url, *, echo=False):
created_urls.append(url)
return SimpleNamespace(url=url, echo=echo)
import core.config as config

def fake_sessionmaker(engine, *, expire_on_commit=False):
return SimpleNamespace(engine=engine, expire_on_commit=expire_on_commit)
monkeypatch.setattr(config, "settings", _settings_namespace())

monkeypatch.setattr("sqlalchemy.ext.asyncio.create_async_engine", fake_create_async_engine)
monkeypatch.setattr("sqlalchemy.ext.asyncio.async_sessionmaker", fake_sessionmaker)
reloaded = _import_isolated_db_session()

assert [url for url, _kwargs in created] == [
"postgresql+asyncpg://primary/db",
"postgresql+asyncpg://primary/db",
]
assert reloaded.AsyncReadOnlySessionLocal.engine.url == "postgresql+asyncpg://primary/db"
assert hasattr(reloaded, "get_readonly_db")


def test_engines_enable_pre_ping_and_recycle_by_default(monkeypatch):
created: list[tuple[str, dict]] = []
_install_fakes(monkeypatch, created)

import core.config as config

monkeypatch.setattr(config, "settings", _settings_namespace())

_import_isolated_db_session()

for _url, kwargs in created:
assert kwargs["pool_pre_ping"] is True
assert kwargs["pool_recycle"] == 1800
# Sizing left unset -> SQLAlchemy defaults, SQLite-compatible.
assert "pool_size" not in kwargs
assert "max_overflow" not in kwargs
assert "pool_timeout" not in kwargs


def test_engines_apply_configured_pool_sizing(monkeypatch):
created: list[tuple[str, dict]] = []
_install_fakes(monkeypatch, created)

import core.config as config

monkeypatch.setattr(
config,
"settings",
SimpleNamespace(
DATABASE_URL="postgresql+asyncpg://primary/db",
READONLY_DATABASE_URL=None,
DEBUG=False,
_settings_namespace(
DB_POOL_SIZE=20,
DB_MAX_OVERFLOW=10,
DB_POOL_TIMEOUT_SECONDS=5,
DB_POOL_RECYCLE_SECONDS=900,
DB_POOL_PRE_PING=False,
),
)

reloaded = _import_isolated_db_session()
_import_isolated_db_session()

assert created_urls == [
"postgresql+asyncpg://primary/db",
"postgresql+asyncpg://primary/db",
]
assert reloaded.AsyncReadOnlySessionLocal.engine.url == "postgresql+asyncpg://primary/db"
assert hasattr(reloaded, "get_readonly_db")
for _url, kwargs in created:
assert kwargs["pool_size"] == 20
assert kwargs["max_overflow"] == 10
assert kwargs["pool_timeout"] == 5
assert kwargs["pool_recycle"] == 900
assert kwargs["pool_pre_ping"] is False
1 change: 1 addition & 0 deletions backend/tests/test_email_client_smtp.py
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,7 @@ def test_implicit_tls_smtp_client_keeps_original_tls_hostname():

assert client.sock is smtp_socket
assert client.use_tls is True
assert client.hostname == "smtp.example.com"
assert getattr(client, "_tls_server_hostname") == "smtp.example.com"
finally:
smtp_socket.close()
Expand Down
Loading