simple-module-db 0.0.32__tar.gz → 0.0.34__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/PKG-INFO +8 -6
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/README.md +6 -4
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/pyproject.toml +2 -2
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/simple_module_db/__init__.py +4 -1
- simple_module_db-0.0.34/simple_module_db/callbacks.py +47 -0
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/simple_module_db/deps.py +3 -3
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/simple_module_db/session.py +18 -2
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/simple_module_db/transaction.py +8 -0
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/tests/test_session.py +2 -1
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/tests/test_transaction.py +70 -8
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/.gitignore +0 -0
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/LICENSE +0 -0
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/simple_module_db/audit.py +0 -0
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/simple_module_db/base.py +0 -0
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/simple_module_db/listeners.py +0 -0
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/simple_module_db/migrations.py +0 -0
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/simple_module_db/mixins.py +0 -0
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/simple_module_db/provider.py +0 -0
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/simple_module_db/py.typed +0 -0
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/simple_module_db/search.py +0 -0
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/tests/_audit_models.py +0 -0
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/tests/_models.py +0 -0
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/tests/conftest.py +0 -0
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/tests/test_audit.py +0 -0
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/tests/test_base.py +0 -0
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/tests/test_db_logging.py +0 -0
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/tests/test_migrations.py +0 -0
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/tests/test_mixins.py +0 -0
- {simple_module_db-0.0.32 → simple_module_db-0.0.34}/tests/test_multi_tenancy.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_db
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.34
|
|
4
4
|
Summary: Per-module SQLModel Base, async session, standard mixins (Audit, SoftDelete, MultiTenant, Versioned) for simple_module
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -25,7 +25,7 @@ Requires-Dist: aiosqlite>=0.20
|
|
|
25
25
|
Requires-Dist: alembic>=1.14
|
|
26
26
|
Requires-Dist: asyncpg>=0.30
|
|
27
27
|
Requires-Dist: psycopg2-binary>=2.9
|
|
28
|
-
Requires-Dist: simple-module-core==0.0.
|
|
28
|
+
Requires-Dist: simple-module-core==0.0.34
|
|
29
29
|
Requires-Dist: sqlalchemy[asyncio]>=2.0
|
|
30
30
|
Requires-Dist: sqlmodel>=0.0.22
|
|
31
31
|
Description-Content-Type: text/markdown
|
|
@@ -43,7 +43,8 @@ pip install simple_module_db
|
|
|
43
43
|
## What it provides
|
|
44
44
|
|
|
45
45
|
- `create_module_base("<module_name>")` — a module-scoped declarative `Base` with its own `MetaData`. All modules share the host's single schema, so `__tablename__` should be prefixed with the module name (e.g. `users_user`) to avoid collisions.
|
|
46
|
-
- Per-request
|
|
46
|
+
- Per-request `RequestSession` (`get_db`) that commits pending writes before the response and rolls back read-only requests.
|
|
47
|
+
- `session.on_commit(callback)` for synchronous or asynchronous cache refreshes that must only run after a successful request commit.
|
|
47
48
|
- Mixins in `simple_module_db.mixins`: `AuditMixin` (created_at/updated_at), `SoftDeleteMixin` (auto-filtered unless `stmt.execution_options(include_deleted=True)`), `MultiTenantMixin`, `VersionedMixin`.
|
|
48
49
|
- `DatabaseState` container used by the framework to avoid global mutable state.
|
|
49
50
|
|
|
@@ -66,12 +67,13 @@ class Order(Base, AuditMixin, SoftDeleteMixin, table=True):
|
|
|
66
67
|
In a service:
|
|
67
68
|
|
|
68
69
|
```python
|
|
69
|
-
from simple_module_db import get_db
|
|
70
|
+
from simple_module_db import RequestSession, get_db
|
|
70
71
|
|
|
71
|
-
async def create_order(session = Depends(get_db), ...):
|
|
72
|
+
async def create_order(session: RequestSession = Depends(get_db), ...):
|
|
72
73
|
order = Order(customer_id=..., total_cents=...)
|
|
73
74
|
session.add(order)
|
|
74
|
-
await session.flush() # assigns order.id;
|
|
75
|
+
await session.flush() # assigns order.id; commit happens before the response
|
|
76
|
+
session.on_commit(order_cache.refresh)
|
|
75
77
|
return order
|
|
76
78
|
```
|
|
77
79
|
|
|
@@ -11,7 +11,8 @@ pip install simple_module_db
|
|
|
11
11
|
## What it provides
|
|
12
12
|
|
|
13
13
|
- `create_module_base("<module_name>")` — a module-scoped declarative `Base` with its own `MetaData`. All modules share the host's single schema, so `__tablename__` should be prefixed with the module name (e.g. `users_user`) to avoid collisions.
|
|
14
|
-
- Per-request
|
|
14
|
+
- Per-request `RequestSession` (`get_db`) that commits pending writes before the response and rolls back read-only requests.
|
|
15
|
+
- `session.on_commit(callback)` for synchronous or asynchronous cache refreshes that must only run after a successful request commit.
|
|
15
16
|
- Mixins in `simple_module_db.mixins`: `AuditMixin` (created_at/updated_at), `SoftDeleteMixin` (auto-filtered unless `stmt.execution_options(include_deleted=True)`), `MultiTenantMixin`, `VersionedMixin`.
|
|
16
17
|
- `DatabaseState` container used by the framework to avoid global mutable state.
|
|
17
18
|
|
|
@@ -34,12 +35,13 @@ class Order(Base, AuditMixin, SoftDeleteMixin, table=True):
|
|
|
34
35
|
In a service:
|
|
35
36
|
|
|
36
37
|
```python
|
|
37
|
-
from simple_module_db import get_db
|
|
38
|
+
from simple_module_db import RequestSession, get_db
|
|
38
39
|
|
|
39
|
-
async def create_order(session = Depends(get_db), ...):
|
|
40
|
+
async def create_order(session: RequestSession = Depends(get_db), ...):
|
|
40
41
|
order = Order(customer_id=..., total_cents=...)
|
|
41
42
|
session.add(order)
|
|
42
|
-
await session.flush() # assigns order.id;
|
|
43
|
+
await session.flush() # assigns order.id; commit happens before the response
|
|
44
|
+
session.on_commit(order_cache.refresh)
|
|
43
45
|
return order
|
|
44
46
|
```
|
|
45
47
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_db"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.34"
|
|
4
4
|
description = "Per-module SQLModel Base, async session, standard mixins (Audit, SoftDelete, MultiTenant, Versioned) for simple_module"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -28,7 +28,7 @@ dependencies = [
|
|
|
28
28
|
# The async app code goes through asyncpg; migration env.py rewrites the
|
|
29
29
|
# URL to +psycopg2 because Alembic's online migrations are sync-only.
|
|
30
30
|
"psycopg2-binary>=2.9",
|
|
31
|
-
"simple_module_core==0.0.
|
|
31
|
+
"simple_module_core==0.0.34",
|
|
32
32
|
"sqlalchemy[asyncio]>=2.0",
|
|
33
33
|
"sqlmodel>=0.0.22",
|
|
34
34
|
]
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from simple_module_db.audit import AuditRecord
|
|
4
4
|
from simple_module_db.base import create_module_base
|
|
5
|
+
from simple_module_db.callbacks import OnCommitCallback
|
|
5
6
|
from simple_module_db.deps import get_db
|
|
6
7
|
from simple_module_db.listeners import TenantIsolationError, current_tenant_id
|
|
7
8
|
from simple_module_db.migrations import (
|
|
@@ -13,7 +14,7 @@ from simple_module_db.migrations import (
|
|
|
13
14
|
from simple_module_db.mixins import AuditMixin, MultiTenantMixin, SoftDeleteMixin, VersionedMixin
|
|
14
15
|
from simple_module_db.provider import DatabaseProvider, detect_provider
|
|
15
16
|
from simple_module_db.search import LIKE_ESCAPE_CHAR, like_contains_pattern, like_prefix_pattern
|
|
16
|
-
from simple_module_db.session import DatabaseState, init_db
|
|
17
|
+
from simple_module_db.session import DatabaseState, RequestSession, init_db
|
|
17
18
|
from simple_module_db.transaction import CommitBeforeResponseMiddleware, finalize_session
|
|
18
19
|
|
|
19
20
|
__all__ = [
|
|
@@ -24,6 +25,8 @@ __all__ = [
|
|
|
24
25
|
"DatabaseProvider",
|
|
25
26
|
"DatabaseState",
|
|
26
27
|
"MultiTenantMixin",
|
|
28
|
+
"OnCommitCallback",
|
|
29
|
+
"RequestSession",
|
|
27
30
|
"SoftDeleteMixin",
|
|
28
31
|
"TenantIsolationError",
|
|
29
32
|
"VersionedMixin",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Post-commit callbacks for framework-managed database sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
|
|
11
|
+
type OnCommitCallback = Callable[[], Awaitable[None] | None]
|
|
12
|
+
|
|
13
|
+
_CALLBACKS_KEY = "sm_db_on_commit_callbacks"
|
|
14
|
+
logger = logging.getLogger("simple_module.db")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register_on_commit(session: AsyncSession, callback: OnCommitCallback) -> None:
|
|
18
|
+
"""Queue ``callback`` for this session's next successful finalization."""
|
|
19
|
+
if not callable(callback):
|
|
20
|
+
raise TypeError("on_commit callback must be callable")
|
|
21
|
+
callbacks = session.info.setdefault(_CALLBACKS_KEY, [])
|
|
22
|
+
callbacks.append(callback)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def discard_on_commit_callbacks(session: AsyncSession) -> None:
|
|
26
|
+
"""Discard callbacks belonging to a transaction that did not commit."""
|
|
27
|
+
session.info.pop(_CALLBACKS_KEY, None)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def run_on_commit_callbacks(session: AsyncSession) -> None:
|
|
31
|
+
"""Run and remove queued callbacks after a successful commit.
|
|
32
|
+
|
|
33
|
+
The transaction is already durable, so callback failures are logged and do
|
|
34
|
+
not turn a successful database mutation into a misleading failed response.
|
|
35
|
+
Remaining callbacks still run.
|
|
36
|
+
"""
|
|
37
|
+
callbacks: list[OnCommitCallback] = session.info.pop(_CALLBACKS_KEY, [])
|
|
38
|
+
for callback in callbacks:
|
|
39
|
+
try:
|
|
40
|
+
result = callback()
|
|
41
|
+
if inspect.isawaitable(result):
|
|
42
|
+
await result
|
|
43
|
+
except Exception:
|
|
44
|
+
logger.exception(
|
|
45
|
+
"db.session.on_commit_failed",
|
|
46
|
+
extra={"operation": "on_commit_failed"},
|
|
47
|
+
)
|
|
@@ -6,8 +6,8 @@ import time
|
|
|
6
6
|
from collections.abc import AsyncGenerator
|
|
7
7
|
|
|
8
8
|
from fastapi import Request
|
|
9
|
-
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
9
|
|
|
10
|
+
from simple_module_db.session import RequestSession
|
|
11
11
|
from simple_module_db.transaction import (
|
|
12
12
|
SESSION_START_KEY,
|
|
13
13
|
finalize_session,
|
|
@@ -16,7 +16,7 @@ from simple_module_db.transaction import (
|
|
|
16
16
|
)
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
async def get_db(request: Request) -> AsyncGenerator[
|
|
19
|
+
async def get_db(request: Request) -> AsyncGenerator[RequestSession, None]:
|
|
20
20
|
"""Yield an async database session, auto-closing on exit.
|
|
21
21
|
|
|
22
22
|
Commits only when the session has pending writes (``new``, ``dirty``,
|
|
@@ -34,7 +34,7 @@ async def get_db(request: Request) -> AsyncGenerator[AsyncSession, None]:
|
|
|
34
34
|
Usage in FastAPI endpoints::
|
|
35
35
|
|
|
36
36
|
@router.get("/items")
|
|
37
|
-
async def list_items(db:
|
|
37
|
+
async def list_items(db: RequestSession = Depends(get_db)):
|
|
38
38
|
...
|
|
39
39
|
"""
|
|
40
40
|
factory = request.app.state.sm.db.session_factory
|
|
@@ -13,15 +13,28 @@ from sqlalchemy.ext.asyncio import (
|
|
|
13
13
|
)
|
|
14
14
|
from sqlalchemy.orm import Session
|
|
15
15
|
|
|
16
|
+
from simple_module_db.callbacks import OnCommitCallback, register_on_commit
|
|
16
17
|
from simple_module_db.provider import DatabaseProvider, detect_provider
|
|
17
18
|
|
|
18
19
|
|
|
20
|
+
class RequestSession(AsyncSession):
|
|
21
|
+
"""Async session with hooks for the request-owned transaction."""
|
|
22
|
+
|
|
23
|
+
def on_commit(self, callback: OnCommitCallback) -> None:
|
|
24
|
+
"""Run ``callback`` after this request's next successful commit.
|
|
25
|
+
|
|
26
|
+
Callbacks may be synchronous or asynchronous. They are discarded when
|
|
27
|
+
the request rolls back or has no writes.
|
|
28
|
+
"""
|
|
29
|
+
register_on_commit(self, callback)
|
|
30
|
+
|
|
31
|
+
|
|
19
32
|
@dataclass
|
|
20
33
|
class DatabaseState:
|
|
21
34
|
"""Holds all database state for a single application instance."""
|
|
22
35
|
|
|
23
36
|
engine: AsyncEngine
|
|
24
|
-
session_factory: async_sessionmaker[
|
|
37
|
+
session_factory: async_sessionmaker[RequestSession]
|
|
25
38
|
sync_session_class: type[Session] = field(repr=False, default=Session)
|
|
26
39
|
audit_callback: Callable | None = field(default=None, repr=False)
|
|
27
40
|
_listeners_registered: bool = field(default=False, repr=False)
|
|
@@ -67,7 +80,10 @@ def init_db(
|
|
|
67
80
|
# Scoped Session subclass so event listeners only fire for this engine's sessions
|
|
68
81
|
scoped_session_class = type("ScopedSession", (Session,), {})
|
|
69
82
|
session_factory = async_sessionmaker(
|
|
70
|
-
engine,
|
|
83
|
+
engine,
|
|
84
|
+
class_=RequestSession,
|
|
85
|
+
expire_on_commit=False,
|
|
86
|
+
sync_session_class=scoped_session_class,
|
|
71
87
|
)
|
|
72
88
|
|
|
73
89
|
return DatabaseState(
|
|
@@ -31,6 +31,10 @@ import time
|
|
|
31
31
|
|
|
32
32
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
33
33
|
|
|
34
|
+
from simple_module_db.callbacks import (
|
|
35
|
+
discard_on_commit_callbacks,
|
|
36
|
+
run_on_commit_callbacks,
|
|
37
|
+
)
|
|
34
38
|
from simple_module_db.listeners import SESSION_HAS_WRITES_KEY
|
|
35
39
|
|
|
36
40
|
logger = logging.getLogger("simple_module.db")
|
|
@@ -97,6 +101,7 @@ async def finalize_session(session: AsyncSession) -> None:
|
|
|
97
101
|
if session.info.get(_FINALIZED_KEY):
|
|
98
102
|
return
|
|
99
103
|
_settle(session)
|
|
104
|
+
discard_on_commit_callbacks(session)
|
|
100
105
|
await session.rollback()
|
|
101
106
|
logger.debug(
|
|
102
107
|
"db.session.read_only",
|
|
@@ -106,6 +111,7 @@ async def finalize_session(session: AsyncSession) -> None:
|
|
|
106
111
|
try:
|
|
107
112
|
await session.commit()
|
|
108
113
|
except Exception:
|
|
114
|
+
discard_on_commit_callbacks(session)
|
|
109
115
|
await session.rollback()
|
|
110
116
|
raise
|
|
111
117
|
finally:
|
|
@@ -116,10 +122,12 @@ async def finalize_session(session: AsyncSession) -> None:
|
|
|
116
122
|
"db.session.commit",
|
|
117
123
|
extra={"operation": "commit", "db_duration_ms": _elapsed_ms(session)},
|
|
118
124
|
)
|
|
125
|
+
await run_on_commit_callbacks(session)
|
|
119
126
|
|
|
120
127
|
|
|
121
128
|
async def rollback_session(session: AsyncSession) -> None:
|
|
122
129
|
"""Roll ``session`` back and drop its pending work. Idempotent."""
|
|
130
|
+
discard_on_commit_callbacks(session)
|
|
123
131
|
if session.info.get(_FINALIZED_KEY) and not _has_pending(session):
|
|
124
132
|
return
|
|
125
133
|
_settle(session)
|
|
@@ -6,7 +6,7 @@ import contextlib
|
|
|
6
6
|
from unittest.mock import MagicMock
|
|
7
7
|
|
|
8
8
|
from simple_module_db.deps import get_db
|
|
9
|
-
from simple_module_db.session import DatabaseState, init_db
|
|
9
|
+
from simple_module_db.session import DatabaseState, RequestSession, init_db
|
|
10
10
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
11
|
|
|
12
12
|
|
|
@@ -58,6 +58,7 @@ class TestGetDbDependency:
|
|
|
58
58
|
gen = get_db(mock_request)
|
|
59
59
|
session = await gen.__anext__()
|
|
60
60
|
assert isinstance(session, AsyncSession)
|
|
61
|
+
assert isinstance(session, RequestSession)
|
|
61
62
|
|
|
62
63
|
with contextlib.suppress(StopAsyncIteration):
|
|
63
64
|
await gen.__anext__()
|
|
@@ -20,6 +20,7 @@ import pytest
|
|
|
20
20
|
from _models import _TxnBase, _TxnThing
|
|
21
21
|
from fastapi import BackgroundTasks, Depends, FastAPI
|
|
22
22
|
from fastapi.responses import StreamingResponse
|
|
23
|
+
from simple_module_db import OnCommitCallback, RequestSession
|
|
23
24
|
from simple_module_db.deps import get_db
|
|
24
25
|
from simple_module_db.listeners import register_listeners
|
|
25
26
|
from simple_module_db.session import init_db
|
|
@@ -28,7 +29,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
28
29
|
from sqlmodel import select
|
|
29
30
|
|
|
30
31
|
|
|
31
|
-
def _build_app(
|
|
32
|
+
def _build_app(
|
|
33
|
+
db_state,
|
|
34
|
+
*,
|
|
35
|
+
with_middleware: bool = True,
|
|
36
|
+
on_commit: OnCommitCallback | None = None,
|
|
37
|
+
) -> FastAPI:
|
|
32
38
|
"""A miniature host: create flushes only, read opens its own session."""
|
|
33
39
|
app = FastAPI()
|
|
34
40
|
if with_middleware:
|
|
@@ -36,12 +42,14 @@ def _build_app(db_state, *, with_middleware: bool = True) -> FastAPI:
|
|
|
36
42
|
app.state.sm = SimpleNamespace(db=db_state)
|
|
37
43
|
|
|
38
44
|
@app.post("/things", status_code=201)
|
|
39
|
-
async def create(name: str, db:
|
|
45
|
+
async def create(name: str, db: RequestSession = Depends(get_db)):
|
|
40
46
|
# Deliberately no commit() — get_db owns the unit of work, which is
|
|
41
47
|
# exactly the pattern the framework documents for service code.
|
|
42
48
|
thing = _TxnThing(name=name)
|
|
43
49
|
db.add(thing)
|
|
44
50
|
await db.flush()
|
|
51
|
+
if on_commit is not None:
|
|
52
|
+
db.on_commit(on_commit)
|
|
45
53
|
return {"id": thing.id}
|
|
46
54
|
|
|
47
55
|
@app.get("/things/{thing_id}")
|
|
@@ -54,9 +62,11 @@ def _build_app(db_state, *, with_middleware: bool = True) -> FastAPI:
|
|
|
54
62
|
return {"found": True, "name": found.name}
|
|
55
63
|
|
|
56
64
|
@app.post("/boom", status_code=201)
|
|
57
|
-
async def boom(db:
|
|
65
|
+
async def boom(db: RequestSession = Depends(get_db)):
|
|
58
66
|
db.add(_TxnThing(name="doomed"))
|
|
59
67
|
await db.flush()
|
|
68
|
+
if on_commit is not None:
|
|
69
|
+
db.on_commit(on_commit)
|
|
60
70
|
raise RuntimeError("endpoint blew up after writing")
|
|
61
71
|
|
|
62
72
|
return app
|
|
@@ -131,11 +141,54 @@ class TestCommitBeforeResponse:
|
|
|
131
141
|
"row was not committed by the time the response left the server"
|
|
132
142
|
)
|
|
133
143
|
|
|
134
|
-
async def
|
|
135
|
-
"""
|
|
136
|
-
|
|
144
|
+
async def test_on_commit_runs_after_the_write_is_durable(self, db_state):
|
|
145
|
+
"""A cache refresh can only observe state committed in another session."""
|
|
146
|
+
observed: list[list[str]] = []
|
|
147
|
+
|
|
148
|
+
async def refresh_cache():
|
|
149
|
+
async with db_state.session_factory() as other:
|
|
150
|
+
rows = (await other.execute(select(_TxnThing))).scalars().all()
|
|
151
|
+
observed.append([row.name for row in rows])
|
|
152
|
+
|
|
153
|
+
async with await _client(_build_app(db_state, on_commit=refresh_cache)) as client:
|
|
154
|
+
assert (await client.post("/things", params={"name": "committed"})).status_code == 201
|
|
155
|
+
|
|
156
|
+
assert observed == [["committed"]]
|
|
157
|
+
|
|
158
|
+
async def test_on_commit_failure_does_not_skip_later_callbacks(self, db_state):
|
|
159
|
+
"""The commit is durable, so callback errors are logged rather than returned as 500s."""
|
|
160
|
+
called: list[bool] = []
|
|
161
|
+
app = _build_app(db_state)
|
|
162
|
+
|
|
163
|
+
@app.post("/callback-errors", status_code=201)
|
|
164
|
+
async def callback_errors(db: RequestSession = Depends(get_db)):
|
|
165
|
+
db.add(_TxnThing(name="durable-despite-cache-error"))
|
|
166
|
+
await db.flush()
|
|
167
|
+
|
|
168
|
+
def fail_refresh():
|
|
169
|
+
raise RuntimeError("cache unavailable")
|
|
170
|
+
|
|
171
|
+
db.on_commit(fail_refresh)
|
|
172
|
+
db.on_commit(lambda: called.append(True))
|
|
173
|
+
return {"ok": True}
|
|
174
|
+
|
|
175
|
+
async with await _client(app) as client:
|
|
176
|
+
response = await client.post("/callback-errors")
|
|
177
|
+
|
|
178
|
+
assert response.status_code == 201
|
|
179
|
+
assert called == [True]
|
|
180
|
+
async with db_state.session_factory() as session:
|
|
181
|
+
names = [row.name for row in (await session.execute(select(_TxnThing))).scalars()]
|
|
182
|
+
assert names == ["durable-despite-cache-error"]
|
|
183
|
+
|
|
184
|
+
async def test_endpoint_exception_rolls_back_without_on_commit(self, db_state):
|
|
185
|
+
"""Callbacks for work discarded by an endpoint failure never run."""
|
|
186
|
+
called: list[bool] = []
|
|
187
|
+
app = _build_app(db_state, on_commit=lambda: called.append(True))
|
|
188
|
+
async with await _client(app) as client:
|
|
137
189
|
assert (await client.post("/boom")).status_code == 500
|
|
138
190
|
|
|
191
|
+
assert called == []
|
|
139
192
|
async with db_state.session_factory() as session:
|
|
140
193
|
assert (await session.execute(select(_TxnThing))).scalars().all() == []
|
|
141
194
|
|
|
@@ -154,11 +207,14 @@ class TestCommitBeforeResponse:
|
|
|
154
207
|
|
|
155
208
|
monkeypatch.setattr(AsyncSession, "commit", explode)
|
|
156
209
|
|
|
157
|
-
|
|
210
|
+
called: list[bool] = []
|
|
211
|
+
app = _build_app(db_state, on_commit=lambda: called.append(True))
|
|
212
|
+
async with await _client(app) as client:
|
|
158
213
|
response = await client.post("/things", params={"name": "nope"})
|
|
159
214
|
|
|
160
215
|
assert response.status_code == 500
|
|
161
216
|
assert response.json() == {"detail": "Internal Server Error"}
|
|
217
|
+
assert called == []
|
|
162
218
|
|
|
163
219
|
monkeypatch.undo()
|
|
164
220
|
async with db_state.session_factory() as session:
|
|
@@ -216,10 +272,16 @@ class TestCommitBeforeResponse:
|
|
|
216
272
|
async def test_still_commits_without_the_middleware(self, db_state):
|
|
217
273
|
"""get_db keeps its own fallback finalize, so the dependency works
|
|
218
274
|
standalone — in a WebSocket handler, or a test that builds no stack."""
|
|
219
|
-
|
|
275
|
+
called: list[bool] = []
|
|
276
|
+
app = _build_app(
|
|
277
|
+
db_state,
|
|
278
|
+
with_middleware=False,
|
|
279
|
+
on_commit=lambda: called.append(True),
|
|
280
|
+
)
|
|
220
281
|
async with await _client(app) as client:
|
|
221
282
|
assert (await client.post("/things", params={"name": "solo"})).status_code == 201
|
|
222
283
|
|
|
284
|
+
assert called == [True]
|
|
223
285
|
async with db_state.session_factory() as session:
|
|
224
286
|
names = [t.name for t in (await session.execute(select(_TxnThing))).scalars().all()]
|
|
225
287
|
assert names == ["solo"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|