Skip to content

Commit b82495e

Browse files
authored
perf: turn pool_pre_ping off by default and rely on postgres_retry (#88)
* test: cover handlers against lost connections and the pre-ping default The handler tests terminate the backend a request session holds, the endpoint tests terminate an idle pooled connection on each engine. Both fail until the handlers retry inside a Transaction and pre-ping is off. * perf: turn pool_pre_ping off by default and rely on postgres_retry Every handler is decorated with postgres_retry and runs its queries inside a request-scoped db_retry.Transaction, committing before it leaves the block. Repositories switch to auto_commit=False so the handler owns the whole transaction. Requires db-retry 0.8.0. * refactor: move each handler's database work into a use case app/use_cases/ holds one dataclass per operation, retried with postgres_retry and running inside its Transaction, as in chat-app. ioc splits into Database, Repositories and UseCases groups, and handlers only call a use case and convert the result. * test: replace the per-use-case connection-loss table with one test Every use case has the same retry and Transaction shape, so ListDecksUseCase stands in for all of them. It runs against a connection lost while held by the session and one lost while idle in the pool. It only reads, so the committed fixture data and the truncation go away. * test: drop the connection-loss test db-retry already tests a pooled connection closed while idle being retried through a Transaction against a real Postgres. The template test only ran the same library code through a trivial use case.
1 parent 0d6473e commit b82495e

21 files changed

Lines changed: 291 additions & 46 deletions

‎AGENTS.md‎

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,27 +30,57 @@ CI (`.github/workflows/main.yml`) runs `ruff format --check`, `ruff check --no-f
3030

3131
`app/__main__.py` boots Granian pointing at `app.application:build_app` (factory). `build_app()` in `app/application.py`:
3232

33-
1. Creates a `modern_di.Container` with the `Dependencies` group from `app/ioc.py`.
33+
1. Creates a `modern_di.Container` with `ioc.ALL_GROUPS` from `app/ioc.py`.
3434
2. Builds a `FastAPIBootstrapper` from `settings.api_bootstrapper_config`, injecting the SQLAlchemy OpenTelemetry instrumentor.
3535
3. `modern_di_fastapi.setup_di(app, container)` wires DI scopes onto the FastAPI app.
3636
4. Includes one `ROUTER` per resource (`app.api.decks.ROUTER`, `app.api.cards.ROUTER`) under `/api` via `include_routers`. Add a new resource by creating `app/api/<name>.py` with its own `APIRouter`-based `ROUTER` and registering it there.
3737
5. Registers `DuplicateKeyError` → 422 handler from `app/exceptions.py`.
3838

3939
### DI scopes (modern-di)
4040

41-
`app/ioc.py` defines providers:
41+
`app/ioc.py` splits providers into three groups, collected in `ALL_GROUPS`.
42+
43+
`Database`:
4244
- `database_engine` — singleton-ish primary `AsyncEngine` (`create_primary_sa_engine`, `close_sa_engine` finalizer).
43-
- `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 the pre-ping is a bare `;`) and sets `default_transaction_read_only=on`, so Postgres rejects writes on it. Each statement sees its own snapshot, so a multi-query read (e.g. `fetch_with_cards`) can see a write that commits in between. Without a replica it is a second pool on the primary, so a process can hold up to `2 × db_pool_size` connections.
45+
- `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 multi-query read (e.g. `fetch_with_cards`) can see a write that commits in between. Without a replica it is a second pool on the primary, so a process can hold up to `2 × db_pool_size` connections.
4446
- `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.
4547
- `session` — `Scope.REQUEST`, bound to `dynamic_engine`, finalized by `close_session`.
46-
- `decks_repository`, `cards_repository` — `Scope.REQUEST`, depend on `session`, configured with `auto_commit=True` (commit happens at session close, not per call).
48+
- `transaction` — `Scope.REQUEST`, a `db_retry.Transaction` over `session`. Leaving its block rolls back anything uncommitted and closes the session.
49+
50+
`Repositories` (`Scope.REQUEST`): `decks_repository` and `cards_repository`, on `Database.session` with `auto_commit=False`. The use case commits through `transaction.commit()`.
51+
52+
`UseCases` (`Scope.REQUEST`): one provider per use case in `app/use_cases/`. modern-di fills each dataclass field by its type, so a use case needs no `kwargs`.
53+
54+
Endpoints inject a use case with `FromDI(UseCase)` from `modern_di_fastapi`, call it, and convert the result to a schema. They never touch repositories or the session. Add new providers to the matching group rather than constructing services manually in routes.
55+
56+
### Use cases and lost connections
57+
58+
`pool_pre_ping` is off by default (`DB_POOL_PRE_PING`). A pooled connection that died while idle is handled by `db_retry.postgres_retry` instead, which saves a round trip on every checkout. That only works if the retried function owns its whole transaction, so every use case has this shape:
59+
60+
```python
61+
@dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
62+
class ListDecksUseCase:
63+
transaction: Transaction
64+
decks_repository: DecksRepository
65+
66+
@postgres_retry
67+
async def __call__(self) -> Sequence[models.Deck]:
68+
async with self.transaction:
69+
decks: typing.Final = await self.decks_repository.get_many()
70+
await self.transaction.commit()
71+
return decks
72+
```
73+
74+
- Every query runs inside `async with self.transaction:`. A query before the block leaves the session in a failed transaction on a lost connection, and the retry then fails with `PendingRollbackError`.
75+
- Commit before leaving the block, reads included, and before any early return. Otherwise `__aexit__` rolls back, which expires the loaded rows.
76+
- A retry runs the whole use case again. If the connection drops during `COMMIT`, the write may already have landed, so a non-idempotent write such as `CreateDeckUseCase` can be applied twice.
4777

48-
Endpoints inject repositories with `FromDI(Repository)` from `modern_di_fastapi`. Add new providers to `Dependencies` rather than constructing services manually in routes.
78+
`app/use_cases/` is exempt from ruff's `TC` rules because modern-di reads the dataclass field annotations at runtime.
4979

5080
### Database layer
5181

5282
- `app/models.py` — `BigIntAuditBase` from `advanced_alchemy` (auto `id`, `created_at`, `updated_at`). The module aliases `orm_registry.metadata` onto `orm.DeclarativeBase.metadata` so Alembic autogenerate sees both. New models go here.
53-
- `app/repositories.py` — Subclass `SQLAlchemyAsyncRepositoryService[Model]` with a nested `BaseRepository(SQLAlchemyAsyncRepository[Model])`. Routes use the service methods (`list`, `get_one_or_none`, `create`, `update`, `create_many`, `upsert_many`).
83+
- `app/repositories.py` — Subclass `SQLAlchemyAsyncRepositoryService[Model]` with a nested `BaseRepository(SQLAlchemyAsyncRepository[Model])`. Use cases call the service methods (`get_many`, `get_one`, `create`, `update`, `create_many`, `upsert_many`).
5484
- `app/resources/db.py` — engine factories live here and are DI creators: modern-di resolves their parameter annotations at runtime, so `pyproject.toml` exempts `app/resources/` from ruff's `TC` rules. `create_session` passes `join_transaction_mode="create_savepoint"`. This is inert in production (the session binds to an engine) but enables the test rollback pattern below: when a test binds the session to a connection already in a transaction, the session owns its own savepoint so the outer transaction survives commits — do not "fix" it.
5585
- `migrations/env.py` swaps the asyncpg driver for the sync `postgresql` driver and uses `app.models.METADATA` as `target_metadata`.
5686

@@ -63,7 +93,7 @@ Endpoints inject repositories with `FromDI(Repository)` from `modern_di_fastapi`
6393
`tests/conftest.py` provides the test isolation pattern — read it before adding fixtures:
6494

6595
- `app` fixture builds a fresh app via `LifespanManager`.
66-
- `db_session` opens a connection, begins a transaction, and **overrides `Dependencies.dynamic_engine`** with the connection itself, so reads and writes share one rolled-back transaction. Each session built against that connection uses `join_transaction_mode="create_savepoint"`, so `auto_commit` releases the session's own savepoint while the outer transaction is rolled back at teardown — each test starts clean.
96+
- `db_session` opens a connection, begins a transaction, and **overrides `Database.dynamic_engine`** with the connection itself, so reads and writes share one rolled-back transaction. Each session built against that connection uses `join_transaction_mode="create_savepoint"`, so `transaction.commit()` releases the session's own savepoint while the outer transaction is rolled back at teardown — each test starts clean.
6797
- `set_async_session_in_base_sqlalchemy_factory` wires `db_session` into `SQLAlchemyFactory.__async_session__` so `polyfactory` factories in `tests/factories.py` (`DeckModelFactory`, `CardModelFactory`) persist via the rolled-back session. Test modules that use these factories opt in with `pytestmark = [pytest.mark.usefixtures("set_async_session_in_base_sqlalchemy_factory")]`.
6898

6999
`pytest.ini_options` sets `asyncio_mode = "auto"` — async tests do not need `@pytest.mark.asyncio`. Coverage runs by default with `--cov-fail-under=100`, so `just test` fails unless tests cover every line of new code.

‎app/api/cards.py‎

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33
import fastapi
44
from modern_di_fastapi import FromDI
55

6-
from app import models, schemas
7-
from app.repositories import CardsRepository
6+
from app import schemas
7+
from app.use_cases.create_cards import CreateCardsUseCase
8+
from app.use_cases.get_card import GetCardUseCase
9+
from app.use_cases.list_cards import ListCardsUseCase
10+
from app.use_cases.update_cards import UpdateCardsUseCase
811

912

1013
ROUTER: typing.Final = fastapi.APIRouter()
@@ -13,36 +16,32 @@
1316
@ROUTER.get("/decks/{deck_id}/cards/")
1417
async def list_cards(
1518
deck_id: int,
16-
cards_repository: CardsRepository = FromDI(CardsRepository),
19+
list_cards_use_case: ListCardsUseCase = FromDI(ListCardsUseCase),
1720
) -> schemas.Cards:
18-
objects = await cards_repository.list_for_deck(deck_id)
19-
return schemas.Cards.from_models(objects)
21+
return schemas.Cards.from_models(await list_cards_use_case(deck_id=deck_id))
2022

2123

2224
@ROUTER.get("/cards/{card_id}/")
2325
async def get_card(
2426
card_id: int,
25-
cards_repository: CardsRepository = FromDI(CardsRepository),
27+
get_card_use_case: GetCardUseCase = FromDI(GetCardUseCase),
2628
) -> schemas.Card:
27-
instance = await cards_repository.get_one(models.Card.id == card_id)
28-
return schemas.Card.model_validate(instance)
29+
return schemas.Card.model_validate(await get_card_use_case(card_id=card_id))
2930

3031

3132
@ROUTER.post("/decks/{deck_id}/cards/")
3233
async def create_cards(
3334
deck_id: int,
3435
data: list[schemas.CardCreate],
35-
cards_repository: CardsRepository = FromDI(CardsRepository),
36+
create_cards_use_case: CreateCardsUseCase = FromDI(CreateCardsUseCase),
3637
) -> schemas.Cards:
37-
objects = await cards_repository.add_cards(deck_id, data)
38-
return schemas.Cards.from_models(objects)
38+
return schemas.Cards.from_models(await create_cards_use_case(deck_id=deck_id, data=data))
3939

4040

4141
@ROUTER.put("/decks/{deck_id}/cards/")
4242
async def update_cards(
4343
deck_id: int,
4444
data: list[schemas.Card],
45-
cards_repository: CardsRepository = FromDI(CardsRepository),
45+
update_cards_use_case: UpdateCardsUseCase = FromDI(UpdateCardsUseCase),
4646
) -> schemas.Cards:
47-
objects = await cards_repository.upsert_cards(deck_id, data)
48-
return schemas.Cards.from_models(objects)
47+
return schemas.Cards.from_models(await update_cards_use_case(deck_id=deck_id, data=data))

‎app/api/decks.py‎

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,43 +4,42 @@
44
from modern_di_fastapi import FromDI
55

66
from app import schemas
7-
from app.repositories import DecksRepository
7+
from app.use_cases.create_deck import CreateDeckUseCase
8+
from app.use_cases.get_deck import GetDeckUseCase
9+
from app.use_cases.list_decks import ListDecksUseCase
10+
from app.use_cases.update_deck import UpdateDeckUseCase
811

912

1013
ROUTER: typing.Final = fastapi.APIRouter()
1114

1215

1316
@ROUTER.get("/decks/")
1417
async def list_decks(
15-
decks_repository: DecksRepository = FromDI(DecksRepository),
18+
list_decks_use_case: ListDecksUseCase = FromDI(ListDecksUseCase),
1619
) -> schemas.Decks:
17-
objects = await decks_repository.get_many()
18-
return schemas.Decks.from_models(objects)
20+
return schemas.Decks.from_models(await list_decks_use_case())
1921

2022

2123
@ROUTER.get("/decks/{deck_id}/")
2224
async def get_deck(
2325
deck_id: int,
24-
decks_repository: DecksRepository = FromDI(DecksRepository),
26+
get_deck_use_case: GetDeckUseCase = FromDI(GetDeckUseCase),
2527
) -> schemas.DeckWithCards:
26-
instance = await decks_repository.fetch_with_cards(deck_id)
27-
return schemas.DeckWithCards.model_validate(instance)
28+
return schemas.DeckWithCards.model_validate(await get_deck_use_case(deck_id=deck_id))
2829

2930

3031
@ROUTER.put("/decks/{deck_id}/")
3132
async def update_deck(
3233
deck_id: int,
3334
data: schemas.DeckCreate,
34-
decks_repository: DecksRepository = FromDI(DecksRepository),
35+
update_deck_use_case: UpdateDeckUseCase = FromDI(UpdateDeckUseCase),
3536
) -> schemas.Deck:
36-
instance = await decks_repository.update(data=data.model_dump(), item_id=deck_id)
37-
return schemas.Deck.model_validate(instance)
37+
return schemas.Deck.model_validate(await update_deck_use_case(deck_id=deck_id, data=data))
3838

3939

4040
@ROUTER.post("/decks/")
4141
async def create_deck(
4242
data: schemas.DeckCreate,
43-
decks_repository: DecksRepository = FromDI(DecksRepository),
43+
create_deck_use_case: CreateDeckUseCase = FromDI(CreateDeckUseCase),
4444
) -> schemas.Deck:
45-
instance = await decks_repository.create(data.model_dump())
46-
return schemas.Deck.model_validate(instance)
45+
return schemas.Deck.model_validate(await create_deck_use_case(data=data))

‎app/application.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def include_routers(app: fastapi.FastAPI) -> None:
2222

2323

2424
def build_app() -> fastapi.FastAPI:
25-
di_container = modern_di.Container(groups=[ioc.Dependencies])
25+
di_container = modern_di.Container(groups=ioc.ALL_GROUPS)
2626
bootstrap_config = dataclasses.replace(
2727
settings.api_bootstrapper_config,
2828
opentelemetry_instrumentors=[SQLAlchemyInstrumentor()],

‎app/ioc.py‎

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import typing
2+
3+
from db_retry import Transaction
14
from modern_di import Group, Scope, providers
25

36
from app.repositories import CardsRepository, DecksRepository
@@ -9,9 +12,17 @@
912
create_read_sa_engine,
1013
create_session,
1114
)
15+
from app.use_cases.create_cards import CreateCardsUseCase
16+
from app.use_cases.create_deck import CreateDeckUseCase
17+
from app.use_cases.get_card import GetCardUseCase
18+
from app.use_cases.get_deck import GetDeckUseCase
19+
from app.use_cases.list_cards import ListCardsUseCase
20+
from app.use_cases.list_decks import ListDecksUseCase
21+
from app.use_cases.update_cards import UpdateCardsUseCase
22+
from app.use_cases.update_deck import UpdateDeckUseCase
1223

1324

14-
class Dependencies(Group):
25+
class Database(Group):
1526
database_engine = providers.Factory(
1627
creator=create_primary_sa_engine,
1728
cache=providers.CacheSettings(finalizer=close_sa_engine),
@@ -33,14 +44,33 @@ class Dependencies(Group):
3344
cache=providers.CacheSettings(finalizer=close_session),
3445
kwargs={"engine": dynamic_engine},
3546
)
47+
transaction = providers.Factory(
48+
scope=Scope.REQUEST,
49+
creator=Transaction,
50+
kwargs={"session": session},
51+
)
3652

53+
54+
class Repositories(Group, scope=Scope.REQUEST):
3755
decks_repository = providers.Factory(
38-
scope=Scope.REQUEST,
3956
creator=DecksRepository,
40-
kwargs={"auto_commit": True, "session": session},
57+
kwargs={"session": Database.session, "auto_commit": False},
4158
)
4259
cards_repository = providers.Factory(
43-
scope=Scope.REQUEST,
4460
creator=CardsRepository,
45-
kwargs={"auto_commit": True, "session": session},
61+
kwargs={"session": Database.session, "auto_commit": False},
4662
)
63+
64+
65+
class UseCases(Group, scope=Scope.REQUEST):
66+
list_decks_use_case = providers.Factory(creator=ListDecksUseCase)
67+
get_deck_use_case = providers.Factory(creator=GetDeckUseCase)
68+
update_deck_use_case = providers.Factory(creator=UpdateDeckUseCase)
69+
create_deck_use_case = providers.Factory(creator=CreateDeckUseCase)
70+
list_cards_use_case = providers.Factory(creator=ListCardsUseCase)
71+
get_card_use_case = providers.Factory(creator=GetCardUseCase)
72+
create_cards_use_case = providers.Factory(creator=CreateCardsUseCase)
73+
update_cards_use_case = providers.Factory(creator=UpdateCardsUseCase)
74+
75+
76+
ALL_GROUPS: typing.Final[list[type[Group]]] = [Database, Repositories, UseCases]

‎app/settings.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class Settings(pydantic_settings.BaseSettings):
1414
db_replica_dsn: str = ""
1515
db_pool_size: int = 5
1616
db_max_overflow: int = 0
17-
db_pool_pre_ping: bool = True
17+
db_pool_pre_ping: bool = False
1818

1919
app_host: str = "0.0.0.0" # noqa: S104
2020
app_port: int = 8000

‎app/use_cases/__init__.py‎

Whitespace-only changes.

‎app/use_cases/create_cards.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import dataclasses
2+
import typing
3+
from collections.abc import Sequence
4+
5+
from db_retry import Transaction, postgres_retry
6+
7+
from app import models, schemas
8+
from app.repositories import CardsRepository
9+
10+
11+
@dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
12+
class CreateCardsUseCase:
13+
transaction: Transaction
14+
cards_repository: CardsRepository
15+
16+
@postgres_retry
17+
async def __call__(self, *, deck_id: int, data: list[schemas.CardCreate]) -> Sequence[models.Card]:
18+
async with self.transaction:
19+
cards: typing.Final = await self.cards_repository.add_cards(deck_id, data)
20+
await self.transaction.commit()
21+
return cards

‎app/use_cases/create_deck.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import dataclasses
2+
import typing
3+
4+
from db_retry import Transaction, postgres_retry
5+
6+
from app import models, schemas
7+
from app.repositories import DecksRepository
8+
9+
10+
@dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
11+
class CreateDeckUseCase:
12+
transaction: Transaction
13+
decks_repository: DecksRepository
14+
15+
@postgres_retry
16+
async def __call__(self, *, data: schemas.DeckCreate) -> models.Deck:
17+
async with self.transaction:
18+
deck: typing.Final = await self.decks_repository.create(data.model_dump())
19+
await self.transaction.commit()
20+
return deck

‎app/use_cases/get_card.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import dataclasses
2+
import typing
3+
4+
from db_retry import Transaction, postgres_retry
5+
6+
from app import models
7+
from app.repositories import CardsRepository
8+
9+
10+
@dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
11+
class GetCardUseCase:
12+
transaction: Transaction
13+
cards_repository: CardsRepository
14+
15+
@postgres_retry
16+
async def __call__(self, *, card_id: int) -> models.Card:
17+
async with self.transaction:
18+
card: typing.Final = await self.cards_repository.get_one(models.Card.id == card_id)
19+
await self.transaction.commit()
20+
return card

0 commit comments

Comments
 (0)