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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ class ListDecksUseCase:

**Migrations**: `migrations/env.py` reads `app.models.METADATA` and rewrites the DSN driver from `postgresql+asyncpg` to `postgresql+psycopg` (Alembic runs on sync psycopg 3). Always run autogen against an upgraded DB; `just migration` enforces this.

**Settings** (`app/settings.py`): `pydantic_settings.BaseSettings` reads from env vars (see `docker-compose.yml` for `SERVICE_DEBUG`, `SERVICE_ENVIRONMENT`, `DB_DSN`; `DB_REPLICA_DSN` is optional). Pool and server sizing uses `DB_POOL_SIZE`, `DB_MAX_OVERFLOW`, `DB_POOL_TIMEOUT`, `DB_POOL_RECYCLE` and `APP_WORKERS` (see **Pool and server sizing**). `api_bootstrapper_config` builds the `LitestarConfig` consumed by `lite-bootstrap`.
**Settings** (`app/settings.py`): `pydantic_settings.BaseSettings` reads from env vars (see `docker-compose.yml` for `SERVICE_DEBUG`, `SERVICE_ENVIRONMENT`, `DB_DSN`; `DB_REPLICA_DSN` is optional). Pool and server sizing uses `DB_POOL_SIZE`, `DB_MAX_OVERFLOW`, `DB_POOL_TIMEOUT`, `DB_POOL_RECYCLE` and `APP_WORKERS` (see **Pool and server sizing**). `api_bootstrapper_config` builds the `LitestarConfig` consumed by `lite-bootstrap`. 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.

## Conventions

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 = [
"litestar",
"lite-bootstrap[litestar-all]>=1.13",
"modern-di>=4.3.0,<5",
"modern-di-litestar>=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 @@ -10,7 +10,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 @@ -50,7 +51,7 @@ async def di_container(app: litestar.Litestar) -> typing.AsyncIterator[modern_di

@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 @@ -97,7 +98,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 @@ -8,7 +8,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 @@ -23,7 +24,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,34 +1,35 @@
import typing

import litestar
import modern_di
import pytest
import sqlalchemy as sa
from litestar.enums import HttpMethod
from litestar.testing import RequestFactory
from modern_di import Scope
from sqlalchemy.engine.url import URL
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.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 @@ -69,21 +70,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 @@ -92,7 +98,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 @@ -5,8 +5,8 @@
from litestar import status_codes as 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 @@ -25,13 +25,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