Skip to content

Commit ef142f7

Browse files
authored
feat: serve GET and HEAD from an autocommit, read-only engine (#84)
Closes #83
1 parent 117e53d commit ef142f7

4 files changed

Lines changed: 56 additions & 33 deletions

File tree

‎AGENTS.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ CI (`.github/workflows/main.yml`) runs `ruff format --check`, `ruff check --no-f
4040

4141
`app/ioc.py` defines providers:
4242
- `database_engine` — singleton-ish primary `AsyncEngine` (`create_primary_sa_engine`, `close_sa_engine` finalizer).
43-
- `database_replica_engine` — singleton-ish replica `AsyncEngine` built from `DB_REPLICA_DSN`, or `None` when it is unset.
44-
- `dynamic_engine` — `Scope.REQUEST`; `choose_sa_engine` returns the replica for `GET`/`HEAD` requests when one is configured, the primary otherwise (including when there is no request). GET handlers must not write, and a read right after a write may see replica lag.
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.
44+
- `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.
4545
- `session` — `Scope.REQUEST`, bound to `dynamic_engine`, finalized by `close_session`.
4646
- `decks_repository`, `cards_repository` — `Scope.REQUEST`, depend on `session`, configured with `auto_commit=True` (commit happens at session close, not per call).
4747

‎app/ioc.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
close_sa_engine,
77
close_session,
88
create_primary_sa_engine,
9-
create_replica_sa_engine,
9+
create_read_sa_engine,
1010
create_session,
1111
)
1212

@@ -17,15 +17,15 @@ class Dependencies(Group):
1717
cache=providers.CacheSettings(finalizer=close_sa_engine),
1818
bound_type=None,
1919
)
20-
database_replica_engine = providers.Factory(
21-
creator=create_replica_sa_engine,
20+
database_read_engine = providers.Factory(
21+
creator=create_read_sa_engine,
2222
cache=providers.CacheSettings(finalizer=close_sa_engine),
2323
bound_type=None,
2424
)
2525
dynamic_engine = providers.Factory(
2626
scope=Scope.REQUEST,
2727
creator=choose_sa_engine,
28-
kwargs={"primary_engine": database_engine, "replica_engine": database_replica_engine},
28+
kwargs={"primary_engine": database_engine, "read_engine": database_read_engine},
2929
)
3030
session = providers.Factory(
3131
scope=Scope.REQUEST,

‎app/resources/db.py‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,41 +12,45 @@
1212
logger = logging.getLogger(__name__)
1313

1414

15-
REPLICA_METHODS: typing.Final = frozenset({"GET", "HEAD"})
15+
READ_METHODS: typing.Final = frozenset({"GET", "HEAD"})
1616

1717

18-
def create_sa_engine(url: URL) -> sa.AsyncEngine:
18+
def create_sa_engine(url: URL, *, read_only: bool = False) -> sa.AsyncEngine:
1919
return sa.create_async_engine(
2020
url=url,
2121
echo=settings.service_debug,
2222
echo_pool=settings.service_debug,
2323
pool_size=settings.db_pool_size,
2424
pool_pre_ping=settings.db_pool_pre_ping,
2525
max_overflow=settings.db_max_overflow,
26+
isolation_level="AUTOCOMMIT" if read_only else None,
27+
connect_args={"server_settings": {"default_transaction_read_only": "on"}} if read_only else {},
2628
)
2729

2830

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

3234

33-
def create_replica_sa_engine() -> sa.AsyncEngine | None:
34-
return create_sa_engine(make_url(settings.db_replica_dsn)) if settings.db_replica_dsn else None
35+
def create_read_sa_engine() -> sa.AsyncEngine:
36+
return create_sa_engine(
37+
make_url(settings.db_replica_dsn) if settings.db_replica_dsn else settings.db_dsn_parsed,
38+
read_only=True,
39+
)
3540

3641

37-
async def close_sa_engine(engine: sa.AsyncEngine | None) -> None:
38-
if engine:
39-
await engine.dispose()
42+
async def close_sa_engine(engine: sa.AsyncEngine) -> None:
43+
await engine.dispose()
4044

4145

4246
def choose_sa_engine(
4347
*,
4448
primary_engine: sa.AsyncEngine,
45-
replica_engine: sa.AsyncEngine | None,
49+
read_engine: sa.AsyncEngine,
4650
request: fastapi.Request | None = None,
4751
) -> sa.AsyncEngine:
48-
if replica_engine and request and request.method in REPLICA_METHODS:
49-
return replica_engine
52+
if request and request.method in READ_METHODS:
53+
return read_engine
5054
return primary_engine
5155

5256

‎tests/test_db_routing.py‎

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
import fastapi
44
import pytest
5+
import sqlalchemy as sa
56
from modern_di import Scope
7+
from sqlalchemy.exc import DBAPIError
68

7-
from app import ioc
8-
from app.resources.db import create_replica_sa_engine, create_sa_engine
9+
from app import ioc, models
10+
from app.resources.db import create_read_sa_engine, create_sa_engine
911
from app.settings import settings
1012

1113

@@ -23,9 +25,9 @@ async def primary_engine(di_container: modern_di.Container) -> typing.AsyncItera
2325

2426

2527
@pytest.fixture
26-
async def replica_engine(di_container: modern_di.Container) -> typing.AsyncIterator[AsyncEngine]:
27-
engine = create_sa_engine(settings.db_dsn_parsed)
28-
di_container.override(ioc.Dependencies.database_replica_engine, engine)
28+
async def read_engine(di_container: modern_di.Container) -> typing.AsyncIterator[AsyncEngine]:
29+
engine = create_read_sa_engine()
30+
di_container.override(ioc.Dependencies.database_read_engine, engine)
2931
yield engine
3032
await engine.dispose()
3133

@@ -37,36 +39,53 @@ def resolve_engine(di_container: modern_di.Container, method: str | None) -> Asy
3739

3840

3941
@pytest.mark.parametrize("method", ["GET", "HEAD"])
40-
def test_safe_methods_use_replica(
41-
di_container: modern_di.Container, primary_engine: AsyncEngine, replica_engine: AsyncEngine, method: str
42+
def test_safe_methods_use_read_engine(
43+
di_container: modern_di.Container, primary_engine: AsyncEngine, read_engine: AsyncEngine, method: str
4244
) -> None:
4345
engine = resolve_engine(di_container, method)
44-
assert engine is replica_engine
46+
assert engine is read_engine
4547
assert engine is not primary_engine
4648

4749

4850
@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH", "DELETE"])
4951
def test_write_methods_use_primary(
50-
di_container: modern_di.Container, primary_engine: AsyncEngine, replica_engine: AsyncEngine, method: str
52+
di_container: modern_di.Container, primary_engine: AsyncEngine, read_engine: AsyncEngine, method: str
5153
) -> None:
5254
engine = resolve_engine(di_container, method)
5355
assert engine is primary_engine
54-
assert engine is not replica_engine
56+
assert engine is not read_engine
5557

5658

57-
@pytest.mark.usefixtures("replica_engine")
59+
@pytest.mark.usefixtures("read_engine")
5860
def test_no_request_uses_primary(di_container: modern_di.Container, primary_engine: AsyncEngine) -> None:
5961
assert resolve_engine(di_container, None) is primary_engine
6062

6163

62-
def test_no_replica_configured_uses_primary(di_container: modern_di.Container, primary_engine: AsyncEngine) -> None:
63-
assert di_container.resolve_provider(ioc.Dependencies.database_replica_engine) is None
64-
assert resolve_engine(di_container, "GET") is primary_engine
64+
async def test_read_engine_built_from_primary_dsn_without_replica() -> None:
65+
engine = create_read_sa_engine()
66+
assert engine.url == settings.db_dsn_parsed
67+
await engine.dispose()
6568

6669

67-
async def test_replica_engine_built_from_replica_dsn(monkeypatch: pytest.MonkeyPatch) -> None:
70+
async def test_read_engine_built_from_replica_dsn(monkeypatch: pytest.MonkeyPatch) -> None:
6871
monkeypatch.setattr(settings, "db_replica_dsn", "postgresql+asyncpg://postgres:password@replica/postgres")
69-
engine = create_replica_sa_engine()
70-
assert engine
72+
engine = create_read_sa_engine()
7173
assert engine.url.host == "replica"
7274
await engine.dispose()
75+
76+
77+
async def test_read_engine_runs_each_statement_in_its_own_transaction() -> None:
78+
engine = create_read_sa_engine()
79+
async with engine.connect() as connection:
80+
first = await connection.scalar(sa.text("SELECT transaction_timestamp()"))
81+
second = await connection.scalar(sa.text("SELECT transaction_timestamp()"))
82+
assert first != second
83+
await engine.dispose()
84+
85+
86+
async def test_read_engine_rejects_writes() -> None:
87+
engine = create_read_sa_engine()
88+
async with engine.connect() as connection:
89+
with pytest.raises(DBAPIError, match="read-only transaction"):
90+
await connection.execute(sa.insert(models.Deck).values(name="read only"))
91+
await engine.dispose()

0 commit comments

Comments
 (0)