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
9 changes: 6 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,13 @@ CI (`.github/workflows/main.yml`) runs `ruff format --check`, `ruff check --no-f

### DI scopes (modern-di)

`app/ioc.py` splits providers into three groups, collected in `ALL_GROUPS`.
`app/ioc.py` splits providers into four groups, collected in `ALL_GROUPS`.

`Config`:
- `settings`: cached `Settings`, overridable in tests (see Settings below).

`Database`:
- `database_engine`: singleton-ish primary `AsyncEngine` (`create_primary_sa_engine`, `close_sa_engine` finalizer).
- `database_engine`: singleton-ish primary `AsyncEngine` (`create_sa_engine` with the URL from `Config.settings.derive(...)`, `close_sa_engine` finalizer).
- `database_read_engine`: singleton-ish read `AsyncEngine` built from `DB_REPLICA_DSN`, or from `DB_DSN` when it is unset. It runs in `AUTOCOMMIT` (no BEGIN/ROLLBACK per request) and sets `default_transaction_read_only=on`, so Postgres rejects writes on it. Each statement sees its own snapshot, so a read that runs several queries can see a write that commits in between. Load related rows in the same query (`fetch_with_cards` uses `joinedload`). Without a replica it is a second pool on the primary, so a process can hold up to `2 × db_pool_size` connections.
- `dynamic_engine`: `Scope.REQUEST`; `choose_sa_engine` returns the read engine for `GET`/`HEAD` requests, the primary otherwise (including when there is no request). GET handlers cannot write, and a read right after a write may see replica lag.
- `session`: `Scope.REQUEST`, bound to `dynamic_engine`, finalized by `close_session`.
Expand Down Expand Up @@ -95,7 +98,7 @@ class ListDecksUseCase:

### Settings

`app/settings.py`: `pydantic_settings.BaseSettings`. Env vars are unprefixed (`DB_DSN`, `DB_REPLICA_DSN`, `SERVICE_DEBUG`, `SERVICE_ENVIRONMENT`, `LOG_LEVEL`, `APP_HOST`, `APP_PORT`, `OPENTELEMETRY_ENDPOINT`, `SENTRY_DSN`, `CORS_ALLOWED_ORIGINS`, `APP_WORKERS`, `DB_POOL_TIMEOUT`, `DB_POOL_RECYCLE`, ...). `api_bootstrapper_config` produces a `FastAPIConfig` for `lite-bootstrap`. `LOG_LEVEL` is case-insensitive and sets both Granian's log level and the app's root logger.
`app/settings.py`: `pydantic_settings.BaseSettings`. Env vars are unprefixed (`DB_DSN`, `DB_REPLICA_DSN`, `SERVICE_DEBUG`, `SERVICE_ENVIRONMENT`, `LOG_LEVEL`, `APP_HOST`, `APP_PORT`, `OPENTELEMETRY_ENDPOINT`, `SENTRY_DSN`, `CORS_ALLOWED_ORIGINS`, `APP_WORKERS`, `DB_POOL_TIMEOUT`, `DB_POOL_RECYCLE`, ...). `api_bootstrapper_config` produces a `FastAPIConfig` for `lite-bootstrap`. `LOG_LEVEL` is case-insensitive and sets both Granian's log level and the app's root logger. Components get settings from the container through `ioc.Config.settings`. The engines take the whole object and get their URL from `Config.settings.derive(...)`, so a test can change their config with `container.override(ioc.Config.settings, Settings(...))` on a container that has not built the engines yet, since the engines are cached. `app/__main__.py`, `migrations/env.py` and `build_app` read the module-level `settings` instead, because they run outside the container or before it exists.

### Tests

Expand Down
17 changes: 12 additions & 5 deletions app/ioc.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@
choose_sa_engine,
close_sa_engine,
close_session,
create_primary_sa_engine,
create_read_sa_engine,
create_sa_engine,
create_session,
read_engine_url,
)
from app.settings import Settings
from app.use_cases.create_cards import CreateCardsUseCase
from app.use_cases.create_deck import CreateDeckUseCase
from app.use_cases.get_card import GetCardUseCase
Expand All @@ -23,16 +24,22 @@
from app.use_cases.update_deck import UpdateDeckUseCase


class Config(Group):
settings = providers.Factory(creator=Settings, cache=True)


class Database(Group):
database_engine = providers.Factory(
creator=create_primary_sa_engine,
creator=create_sa_engine,
cache=providers.CacheSettings(finalizer=close_sa_engine),
bound_type=None,
kwargs={"settings": Config.settings, "url": Config.settings.derive(lambda s: s.db_dsn_parsed)},
)
database_read_engine = providers.Factory(
creator=create_read_sa_engine,
creator=create_sa_engine,
cache=providers.CacheSettings(finalizer=close_sa_engine),
bound_type=None,
kwargs={"settings": Config.settings, "url": Config.settings.derive(read_engine_url), "read_only": True},
)
dynamic_engine = providers.Factory(
scope=Scope.REQUEST,
Expand Down Expand Up @@ -85,4 +92,4 @@ class UseCases(Group, scope=Scope.REQUEST):
update_cards_use_case = providers.Factory(creator=UpdateCardsUseCase)


ALL_GROUPS: typing.Final[list[type[Group]]] = [Database, Repositories, UseCases]
ALL_GROUPS: typing.Final[list[type[Group]]] = [Config, Database, Repositories, UseCases]
15 changes: 4 additions & 11 deletions app/resources/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from sqlalchemy.engine.url import URL, make_url
from sqlalchemy.ext import asyncio as sa

from app.settings import settings
from app.settings import Settings


logger = logging.getLogger(__name__)
Expand All @@ -16,7 +16,7 @@
READ_METHODS: typing.Final = frozenset({"GET", "HEAD"})


def create_sa_engine(url: URL, *, read_only: bool = False) -> sa.AsyncEngine:
def create_sa_engine(*, settings: Settings, url: URL, read_only: bool = False) -> sa.AsyncEngine:
return sa.create_async_engine(
url=url,
echo=settings.service_debug,
Expand All @@ -31,15 +31,8 @@ def create_sa_engine(url: URL, *, read_only: bool = False) -> sa.AsyncEngine:
)


def create_primary_sa_engine() -> sa.AsyncEngine:
return create_sa_engine(settings.db_dsn_parsed)


def create_read_sa_engine() -> sa.AsyncEngine:
return create_sa_engine(
make_url(settings.db_replica_dsn) if settings.db_replica_dsn else settings.db_dsn_parsed,
read_only=True,
)
def read_engine_url(settings: Settings) -> URL:
return make_url(settings.db_replica_dsn) if settings.db_replica_dsn else settings.db_dsn_parsed


def autocommit_isolation_level() -> IsolationLevel | None:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ license = "MIT"
dependencies = [
"fastapi>=0.76",
"lite-bootstrap[fastapi-all]",
"modern-di>=4.3.0,<5",
"modern-di-fastapi>=4,<5",
"advanced-alchemy",
"pydantic-settings",
Expand Down
7 changes: 4 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@

from app import ioc
from app.application import build_app
from app.resources.db import create_primary_sa_engine, create_session
from app.resources.db import create_sa_engine, create_session
from app.settings import settings


if typing.TYPE_CHECKING:
Expand Down Expand Up @@ -49,7 +50,7 @@ async def di_container(app: fastapi.FastAPI) -> typing.AsyncIterator[modern_di.C

@pytest.fixture
async def db_session(di_container: modern_di.Container) -> typing.AsyncIterator[AsyncSession]:
engine = create_primary_sa_engine()
engine = create_sa_engine(settings=settings, url=settings.db_dsn_parsed)
connection = await engine.connect()
transaction = await connection.begin()
di_container.override(ioc.Database.dynamic_engine, connection)
Expand Down Expand Up @@ -96,7 +97,7 @@ def record(*args: object) -> None:

@pytest.fixture
async def committing_engine(di_container: modern_di.Container) -> typing.AsyncIterator[AsyncEngine]:
engine = create_primary_sa_engine()
engine = create_sa_engine(settings=settings, url=settings.db_dsn_parsed)
di_container.override(ioc.Database.dynamic_engine, engine)
try:
yield engine
Expand Down
5 changes: 3 additions & 2 deletions tests/test_create_deck.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@

from app import models, schemas
from app.repositories import DecksRepository
from app.resources.db import create_primary_sa_engine, create_session
from app.resources.db import create_sa_engine, create_session
from app.settings import settings
from app.use_cases.create_deck import CreateDeckUseCase


Expand All @@ -26,7 +27,7 @@ async def _wait_until_blocked_on_a_lock(engine: AsyncEngine, pid: int) -> None:


async def test_create_deck_returns_the_deck_a_concurrent_request_created_with_the_same_key() -> None:
engine: typing.Final = create_primary_sa_engine()
engine: typing.Final = create_sa_engine(settings=settings, url=settings.db_dsn_parsed)
idempotency_key: typing.Final = uuid.uuid4()
try:
async with engine.connect() as concurrent_request:
Expand Down
36 changes: 21 additions & 15 deletions tests/test_db_routing.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,33 @@
import typing

import fastapi
import modern_di
import pytest
import sqlalchemy as sa
from modern_di import Scope
from sqlalchemy.exc import DBAPIError

from app import ioc, models
from app.resources.db import create_read_sa_engine, create_sa_engine
from app.settings import settings
from app.resources.db import create_sa_engine, read_engine_url
from app.settings import Settings, settings


if typing.TYPE_CHECKING:
import modern_di
from sqlalchemy.engine.url import URL
from sqlalchemy.ext.asyncio import AsyncEngine


@pytest.fixture
async def primary_engine(di_container: modern_di.Container) -> typing.AsyncIterator[AsyncEngine]:
engine = create_sa_engine(settings.db_dsn_parsed)
engine = create_sa_engine(settings=settings, url=settings.db_dsn_parsed)
di_container.override(ioc.Database.database_engine, engine)
yield engine
await engine.dispose()


@pytest.fixture
async def read_engine(di_container: modern_di.Container) -> typing.AsyncIterator[AsyncEngine]:
engine = create_read_sa_engine()
engine = create_sa_engine(settings=settings, url=read_engine_url(settings), read_only=True)
di_container.override(ioc.Database.database_read_engine, engine)
yield engine
await engine.dispose()
Expand Down Expand Up @@ -61,21 +62,26 @@ def test_no_request_uses_primary(di_container: modern_di.Container, primary_engi
assert resolve_engine(di_container, None) is primary_engine


async def resolve_read_engine_url(app_settings: Settings) -> URL:
container: typing.Final = modern_di.Container(groups=ioc.ALL_GROUPS)
container.override(ioc.Config.settings, app_settings)
try:
return container.resolve_provider(ioc.Database.database_read_engine).url
finally:
await container.close_async()


async def test_read_engine_built_from_primary_dsn_without_replica() -> None:
engine = create_read_sa_engine()
assert engine.url == settings.db_dsn_parsed
await engine.dispose()
assert await resolve_read_engine_url(Settings(db_replica_dsn="")) == settings.db_dsn_parsed


async def test_read_engine_built_from_replica_dsn(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "db_replica_dsn", "postgresql+asyncpg://postgres:password@replica/postgres")
engine = create_read_sa_engine()
assert engine.url.host == "replica"
await engine.dispose()
async def test_read_engine_built_from_overridden_replica_dsn() -> None:
replica_settings: typing.Final = Settings(db_replica_dsn="postgresql+asyncpg://postgres:password@replica/postgres")
assert (await resolve_read_engine_url(replica_settings)).host == "replica"


async def test_read_engine_runs_each_statement_in_its_own_transaction() -> None:
engine = create_read_sa_engine()
engine = create_sa_engine(settings=settings, url=read_engine_url(settings), read_only=True)
async with engine.connect() as connection:
first = await connection.scalar(sa.text("SELECT transaction_timestamp()"))
second = await connection.scalar(sa.text("SELECT transaction_timestamp()"))
Expand All @@ -84,7 +90,7 @@ async def test_read_engine_runs_each_statement_in_its_own_transaction() -> None:


async def test_read_engine_rejects_writes() -> None:
engine = create_read_sa_engine()
engine = create_sa_engine(settings=settings, url=read_engine_url(settings), read_only=True)
async with engine.connect() as connection:
with pytest.raises(DBAPIError, match="read-only transaction"):
await connection.execute(sa.insert(models.Deck).values(name="read only"))
Expand Down
11 changes: 4 additions & 7 deletions tests/test_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
from fastapi import status

from app import ioc
from app.resources.db import create_primary_sa_engine
from app.settings import settings
from app.resources.db import create_sa_engine
from app.settings import Settings
from tests import factories


Expand All @@ -26,13 +26,10 @@
async def test_exhausted_pool_returns_503(
client: AsyncClient,
di_container: modern_di.Container,
monkeypatch: pytest.MonkeyPatch,
request_kwargs: dict[str, typing.Any],
) -> None:
monkeypatch.setattr(settings, "db_pool_size", 1)
monkeypatch.setattr(settings, "db_max_overflow", 0)
monkeypatch.setattr(settings, "db_pool_timeout", 0.1)
engine: typing.Final = create_primary_sa_engine()
pool_settings: typing.Final = Settings(db_pool_size=1, db_max_overflow=0, db_pool_timeout=0.1)
engine: typing.Final = create_sa_engine(settings=pool_settings, url=pool_settings.db_dsn_parsed)
di_container.override(ioc.Database.dynamic_engine, engine)
try:
async with engine.connect():
Expand Down
8 changes: 5 additions & 3 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading