simple-module-db 0.0.29__tar.gz → 0.0.31__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.29 → simple_module_db-0.0.31}/.gitignore +1 -0
- {simple_module_db-0.0.29 → simple_module_db-0.0.31}/PKG-INFO +3 -3
- {simple_module_db-0.0.29 → simple_module_db-0.0.31}/pyproject.toml +2 -2
- {simple_module_db-0.0.29 → simple_module_db-0.0.31}/simple_module_db/__init__.py +3 -0
- simple_module_db-0.0.31/simple_module_db/deps.py +49 -0
- {simple_module_db-0.0.29 → simple_module_db-0.0.31}/simple_module_db/migrations.py +35 -6
- simple_module_db-0.0.31/simple_module_db/transaction.py +215 -0
- {simple_module_db-0.0.29 → simple_module_db-0.0.31}/tests/_models.py +11 -0
- {simple_module_db-0.0.29 → simple_module_db-0.0.31}/tests/test_migrations.py +43 -0
- simple_module_db-0.0.31/tests/test_transaction.py +231 -0
- simple_module_db-0.0.29/simple_module_db/deps.py +0 -62
- {simple_module_db-0.0.29 → simple_module_db-0.0.31}/LICENSE +0 -0
- {simple_module_db-0.0.29 → simple_module_db-0.0.31}/README.md +0 -0
- {simple_module_db-0.0.29 → simple_module_db-0.0.31}/simple_module_db/audit.py +0 -0
- {simple_module_db-0.0.29 → simple_module_db-0.0.31}/simple_module_db/base.py +0 -0
- {simple_module_db-0.0.29 → simple_module_db-0.0.31}/simple_module_db/listeners.py +0 -0
- {simple_module_db-0.0.29 → simple_module_db-0.0.31}/simple_module_db/mixins.py +0 -0
- {simple_module_db-0.0.29 → simple_module_db-0.0.31}/simple_module_db/provider.py +0 -0
- {simple_module_db-0.0.29 → simple_module_db-0.0.31}/simple_module_db/py.typed +0 -0
- {simple_module_db-0.0.29 → simple_module_db-0.0.31}/simple_module_db/session.py +0 -0
- {simple_module_db-0.0.29 → simple_module_db-0.0.31}/tests/_audit_models.py +0 -0
- {simple_module_db-0.0.29 → simple_module_db-0.0.31}/tests/conftest.py +0 -0
- {simple_module_db-0.0.29 → simple_module_db-0.0.31}/tests/test_audit.py +0 -0
- {simple_module_db-0.0.29 → simple_module_db-0.0.31}/tests/test_base.py +0 -0
- {simple_module_db-0.0.29 → simple_module_db-0.0.31}/tests/test_db_logging.py +0 -0
- {simple_module_db-0.0.29 → simple_module_db-0.0.31}/tests/test_mixins.py +0 -0
- {simple_module_db-0.0.29 → simple_module_db-0.0.31}/tests/test_multi_tenancy.py +0 -0
- {simple_module_db-0.0.29 → simple_module_db-0.0.31}/tests/test_session.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_db
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.31
|
|
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.31
|
|
29
29
|
Requires-Dist: sqlalchemy[asyncio]>=2.0
|
|
30
30
|
Requires-Dist: sqlmodel>=0.0.22
|
|
31
31
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_db"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.31"
|
|
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.31",
|
|
32
32
|
"sqlalchemy[asyncio]>=2.0",
|
|
33
33
|
"sqlmodel>=0.0.22",
|
|
34
34
|
]
|
|
@@ -13,10 +13,12 @@ from simple_module_db.migrations import (
|
|
|
13
13
|
from simple_module_db.mixins import AuditMixin, MultiTenantMixin, SoftDeleteMixin, VersionedMixin
|
|
14
14
|
from simple_module_db.provider import DatabaseProvider, detect_provider
|
|
15
15
|
from simple_module_db.session import DatabaseState, init_db
|
|
16
|
+
from simple_module_db.transaction import CommitBeforeResponseMiddleware, finalize_session
|
|
16
17
|
|
|
17
18
|
__all__ = [
|
|
18
19
|
"AuditMixin",
|
|
19
20
|
"AuditRecord",
|
|
21
|
+
"CommitBeforeResponseMiddleware",
|
|
20
22
|
"DatabaseProvider",
|
|
21
23
|
"DatabaseState",
|
|
22
24
|
"MultiTenantMixin",
|
|
@@ -27,6 +29,7 @@ __all__ = [
|
|
|
27
29
|
"create_module_base",
|
|
28
30
|
"current_tenant_id",
|
|
29
31
|
"detect_provider",
|
|
32
|
+
"finalize_session",
|
|
30
33
|
"get_db",
|
|
31
34
|
"init_db",
|
|
32
35
|
"make_include_object",
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""FastAPI dependencies for database access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import AsyncGenerator
|
|
7
|
+
|
|
8
|
+
from fastapi import Request
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
|
|
11
|
+
from simple_module_db.transaction import (
|
|
12
|
+
SESSION_START_KEY,
|
|
13
|
+
finalize_session,
|
|
14
|
+
register_request_session,
|
|
15
|
+
rollback_session,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def get_db(request: Request) -> AsyncGenerator[AsyncSession, None]:
|
|
20
|
+
"""Yield an async database session, auto-closing on exit.
|
|
21
|
+
|
|
22
|
+
Commits only when the session has pending writes (``new``, ``dirty``,
|
|
23
|
+
or ``deleted`` objects); read-only handlers exit via ``rollback``.
|
|
24
|
+
|
|
25
|
+
The commit itself normally happens in
|
|
26
|
+
:class:`~simple_module_db.transaction.CommitBeforeResponseMiddleware`,
|
|
27
|
+
which fires while the response is still in the server's hands. The
|
|
28
|
+
finalize below is the fallback for when that middleware isn't in the
|
|
29
|
+
stack, and a no-op when it already ran — FastAPI runs this exit code
|
|
30
|
+
*after* the response has been delivered, so a client that immediately
|
|
31
|
+
reads back what it just wrote would otherwise race the commit and lose
|
|
32
|
+
(GH #257).
|
|
33
|
+
|
|
34
|
+
Usage in FastAPI endpoints::
|
|
35
|
+
|
|
36
|
+
@router.get("/items")
|
|
37
|
+
async def list_items(db: AsyncSession = Depends(get_db)):
|
|
38
|
+
...
|
|
39
|
+
"""
|
|
40
|
+
factory = request.app.state.sm.db.session_factory
|
|
41
|
+
async with factory() as session:
|
|
42
|
+
session.info[SESSION_START_KEY] = time.perf_counter()
|
|
43
|
+
register_request_session(request.scope, session)
|
|
44
|
+
try:
|
|
45
|
+
yield session
|
|
46
|
+
await finalize_session(session)
|
|
47
|
+
except Exception:
|
|
48
|
+
await rollback_session(session)
|
|
49
|
+
raise
|
|
@@ -132,6 +132,13 @@ def make_process_revision_directives(
|
|
|
132
132
|
not strictly required (dropping the table drops the index) but it keeps
|
|
133
133
|
autogen output readable.
|
|
134
134
|
|
|
135
|
+
The injection is idempotent, and has to be: a dialect that *can* reflect
|
|
136
|
+
expression-based indexes (PostgreSQL) already emits the index itself, so
|
|
137
|
+
re-adding it unconditionally produced a migration with two identical
|
|
138
|
+
``create_index`` calls that failed on first run with ``DuplicateTable``.
|
|
139
|
+
Any index already named in the op tree — at any nesting depth — is left
|
|
140
|
+
alone, which keeps this dialect-agnostic rather than special-casing SQLite.
|
|
141
|
+
|
|
135
142
|
Call as::
|
|
136
143
|
|
|
137
144
|
context.configure(
|
|
@@ -164,10 +171,34 @@ def _index_is_expression_based(index: Index) -> bool:
|
|
|
164
171
|
return any(not isinstance(expr, Column) for expr in index.expressions)
|
|
165
172
|
|
|
166
173
|
|
|
167
|
-
def
|
|
168
|
-
|
|
169
|
-
|
|
174
|
+
def _iter_ops_recursive(container):
|
|
175
|
+
"""Yield every op under ``container``, descending into nested op groups.
|
|
176
|
+
|
|
177
|
+
Autogenerate does not emit a flat op list: index operations for a table are
|
|
178
|
+
grouped inside a ``ModifyTableOps`` container alongside the top-level
|
|
179
|
+
``CreateTableOp``/``DropTableOp``. A dedup check that only looks at
|
|
180
|
+
``container.ops`` therefore sees no ``CreateIndexOp`` at all and re-injects
|
|
181
|
+
an index the dialect already emitted — which is exactly how a dialect that
|
|
182
|
+
*can* reflect expression-based indexes (PostgreSQL) ended up with a
|
|
183
|
+
duplicate ``CREATE INDEX`` in its initial migration.
|
|
184
|
+
"""
|
|
185
|
+
for op in container.ops:
|
|
186
|
+
yield op
|
|
187
|
+
if hasattr(op, "ops"):
|
|
188
|
+
yield from _iter_ops_recursive(op)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _existing_index_names(container, op_type) -> set[str | None]:
|
|
192
|
+
"""Names of every ``op_type`` index op already present anywhere under ``container``."""
|
|
193
|
+
return {
|
|
194
|
+
getattr(op, "index_name", None)
|
|
195
|
+
for op in _iter_ops_recursive(container)
|
|
196
|
+
if isinstance(op, op_type)
|
|
170
197
|
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _inject_create_index_after_create_table(upgrade_ops, expression_indexes) -> None:
|
|
201
|
+
existing_index_names = _existing_index_names(upgrade_ops, CreateIndexOp)
|
|
171
202
|
new_ops: list = []
|
|
172
203
|
for op in upgrade_ops.ops:
|
|
173
204
|
new_ops.append(op)
|
|
@@ -188,9 +219,7 @@ def _inject_create_index_after_create_table(upgrade_ops, expression_indexes) ->
|
|
|
188
219
|
|
|
189
220
|
|
|
190
221
|
def _inject_drop_index_before_drop_table(downgrade_ops, expression_indexes) -> None:
|
|
191
|
-
existing_drop_names =
|
|
192
|
-
getattr(op, "index_name", None) for op in downgrade_ops.ops if isinstance(op, DropIndexOp)
|
|
193
|
-
}
|
|
222
|
+
existing_drop_names = _existing_index_names(downgrade_ops, DropIndexOp)
|
|
194
223
|
new_ops: list = []
|
|
195
224
|
for op in downgrade_ops.ops:
|
|
196
225
|
if isinstance(op, DropTableOp):
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Commit the request's unit of work before the response leaves the server.
|
|
2
|
+
|
|
3
|
+
``get_db`` is a FastAPI ``yield`` dependency, and FastAPI runs a yield
|
|
4
|
+
dependency's exit code *after* the response has been delivered. Committing
|
|
5
|
+
there means a client that creates a row and immediately references it by id in
|
|
6
|
+
a second request loses the race: the create's ``201`` reaches the client before
|
|
7
|
+
the create's commit runs, so the follow-up request opens a fresh session, finds
|
|
8
|
+
nothing, and 404s. It is deterministic rather than flaky — the follow-up
|
|
9
|
+
request reliably beats the post-response commit — which is why a seed script
|
|
10
|
+
publishes 0 pages on its first pass and all of them on a second, identical one.
|
|
11
|
+
See GH #257.
|
|
12
|
+
|
|
13
|
+
This module moves the commit to the last point that is still *inside* the
|
|
14
|
+
request: the ASGI ``http.response.start`` message. Nothing has reached the
|
|
15
|
+
client yet, so a failure there can still become a 500, and every caller —
|
|
16
|
+
seeds, provisioning scripts, integration tests, single-run k8s jobs — gets the
|
|
17
|
+
guarantee without changing a line of endpoint code.
|
|
18
|
+
|
|
19
|
+
A session is finalized exactly once. Whichever runs first wins and the other
|
|
20
|
+
becomes a no-op, so the framework stays correct when the middleware is absent
|
|
21
|
+
(a bare ``get_db``, a WebSocket, a test calling the dependency directly) and on
|
|
22
|
+
the error path, where FastAPI unwinds the dependency — rolling it back —
|
|
23
|
+
*before* the error response is sent.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
import time
|
|
31
|
+
|
|
32
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
33
|
+
|
|
34
|
+
from simple_module_db.listeners import SESSION_HAS_WRITES_KEY
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger("simple_module.db")
|
|
37
|
+
|
|
38
|
+
REQUEST_SESSIONS_KEY = "sm_db_sessions"
|
|
39
|
+
"""Key under ASGI ``scope["state"]`` holding the sessions opened this request."""
|
|
40
|
+
|
|
41
|
+
SESSION_START_KEY = "sm_db_started_at"
|
|
42
|
+
"""``session.info`` key carrying the perf-counter reading taken at open."""
|
|
43
|
+
|
|
44
|
+
_FINALIZED_KEY = "sm_db_finalized"
|
|
45
|
+
|
|
46
|
+
_INTERNAL_ERROR_BODY = json.dumps({"detail": "Internal Server Error"}).encode()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _elapsed_ms(session: AsyncSession) -> float:
|
|
50
|
+
start = session.info.get(SESSION_START_KEY)
|
|
51
|
+
return round((time.perf_counter() - start) * 1000, 2) if start else 0.0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _has_pending(session: AsyncSession) -> bool:
|
|
55
|
+
"""Whether ``session`` holds work that still needs committing.
|
|
56
|
+
|
|
57
|
+
``has_writes`` is stamped by the after_flush listener and survives the flush
|
|
58
|
+
emptying ``session.new``/``.dirty``/``.deleted`` — the raw collections alone
|
|
59
|
+
would report a flushed-but-uncommitted write as read-only.
|
|
60
|
+
"""
|
|
61
|
+
return bool(
|
|
62
|
+
session.info.get(SESSION_HAS_WRITES_KEY) or session.new or session.dirty or session.deleted
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _settle(session: AsyncSession) -> None:
|
|
67
|
+
"""Mark the session finalized and clear the pending-write marker.
|
|
68
|
+
|
|
69
|
+
Clearing matters: the marker is sticky, so without this a later finalize
|
|
70
|
+
would try to re-commit a session whose work has already landed.
|
|
71
|
+
"""
|
|
72
|
+
session.info[_FINALIZED_KEY] = True
|
|
73
|
+
session.info.pop(SESSION_HAS_WRITES_KEY, None)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def finalize_session(session: AsyncSession) -> None:
|
|
77
|
+
"""Commit ``session`` if it has pending writes, else roll it back.
|
|
78
|
+
|
|
79
|
+
Safe to call more than once, and deliberately **re-armable** rather than
|
|
80
|
+
one-shot. Work can still be done after the response has started — Starlette
|
|
81
|
+
runs ``BackgroundTasks`` once the body is sent, and a ``StreamingResponse``
|
|
82
|
+
writes its body after ``http.response.start`` — and both share this request's
|
|
83
|
+
session. A one-shot claim would let the middleware's commit consume the
|
|
84
|
+
session and then silently drop those later writes: they would flush and
|
|
85
|
+
never commit. So each call commits whatever is pending *at that moment*, and
|
|
86
|
+
``get_db``'s exit code still runs afterwards to catch the rest.
|
|
87
|
+
|
|
88
|
+
Read-only handlers exit via ``rollback`` — one round-trip cheaper than
|
|
89
|
+
``commit``, and it keeps read-only queries from showing up as writes in
|
|
90
|
+
query logs / ``pg_stat_statements``. A repeat call with nothing new pending
|
|
91
|
+
returns immediately rather than paying for a second rollback.
|
|
92
|
+
|
|
93
|
+
On commit failure the session is rolled back before the error propagates,
|
|
94
|
+
so the caller never has to reason about a half-finalized session.
|
|
95
|
+
"""
|
|
96
|
+
if not _has_pending(session):
|
|
97
|
+
if session.info.get(_FINALIZED_KEY):
|
|
98
|
+
return
|
|
99
|
+
_settle(session)
|
|
100
|
+
await session.rollback()
|
|
101
|
+
logger.debug(
|
|
102
|
+
"db.session.read_only",
|
|
103
|
+
extra={"operation": "read_only_rollback", "db_duration_ms": _elapsed_ms(session)},
|
|
104
|
+
)
|
|
105
|
+
return
|
|
106
|
+
try:
|
|
107
|
+
await session.commit()
|
|
108
|
+
except Exception:
|
|
109
|
+
await session.rollback()
|
|
110
|
+
raise
|
|
111
|
+
finally:
|
|
112
|
+
# Settled either way: a failed commit must not be retried by the
|
|
113
|
+
# fallback finalize in get_db's exit code.
|
|
114
|
+
_settle(session)
|
|
115
|
+
logger.info(
|
|
116
|
+
"db.session.commit",
|
|
117
|
+
extra={"operation": "commit", "db_duration_ms": _elapsed_ms(session)},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def rollback_session(session: AsyncSession) -> None:
|
|
122
|
+
"""Roll ``session`` back and drop its pending work. Idempotent."""
|
|
123
|
+
if session.info.get(_FINALIZED_KEY) and not _has_pending(session):
|
|
124
|
+
return
|
|
125
|
+
_settle(session)
|
|
126
|
+
await session.rollback()
|
|
127
|
+
logger.warning(
|
|
128
|
+
"db.session.rollback",
|
|
129
|
+
extra={"operation": "rollback", "db_duration_ms": _elapsed_ms(session)},
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def register_request_session(scope: dict, session: AsyncSession) -> None:
|
|
134
|
+
"""Enlist ``session`` for commit-before-response, if the middleware is installed.
|
|
135
|
+
|
|
136
|
+
A no-op when it isn't: ``get_db`` still commits in its own exit code, just
|
|
137
|
+
later. That keeps the dependency usable on its own — in a WebSocket handler,
|
|
138
|
+
a background task, or a test that never builds the middleware stack.
|
|
139
|
+
"""
|
|
140
|
+
sessions = scope.get("state", {}).get(REQUEST_SESSIONS_KEY)
|
|
141
|
+
if sessions is None:
|
|
142
|
+
return
|
|
143
|
+
sessions.append(session)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class CommitBeforeResponseMiddleware:
|
|
147
|
+
"""Finalize this request's DB sessions before the response is transmitted.
|
|
148
|
+
|
|
149
|
+
Pure ASGI rather than ``BaseHTTPMiddleware`` because the hook point is the
|
|
150
|
+
``send`` channel, not the response object: intercepting
|
|
151
|
+
``http.response.start`` is what lets the commit land before any byte is
|
|
152
|
+
written, and lets a commit failure still be turned into a 500.
|
|
153
|
+
|
|
154
|
+
Install this **innermost** (add it first — Starlette's ``add_middleware`` is
|
|
155
|
+
LIFO) so its wrapper is the first to see the response and the commit happens
|
|
156
|
+
as early as possible.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def __init__(self, app):
|
|
160
|
+
self.app = app
|
|
161
|
+
|
|
162
|
+
async def __call__(self, scope, receive, send) -> None:
|
|
163
|
+
if scope["type"] != "http":
|
|
164
|
+
await self.app(scope, receive, send)
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
sessions: list[AsyncSession] = []
|
|
168
|
+
scope.setdefault("state", {})[REQUEST_SESSIONS_KEY] = sessions
|
|
169
|
+
aborted = False
|
|
170
|
+
|
|
171
|
+
async def send_wrapper(message) -> None:
|
|
172
|
+
nonlocal aborted
|
|
173
|
+
if aborted:
|
|
174
|
+
# The response we replaced is still streaming its body into a
|
|
175
|
+
# channel we've already closed off. Drop it.
|
|
176
|
+
return
|
|
177
|
+
if message["type"] == "http.response.start" and sessions:
|
|
178
|
+
try:
|
|
179
|
+
# A request normally has exactly one session (FastAPI caches
|
|
180
|
+
# the dependency). With several — Depends(get_db,
|
|
181
|
+
# use_cache=False) — a failure part-way leaves the earlier
|
|
182
|
+
# commits durable while the client sees a 500. There is no
|
|
183
|
+
# cross-session atomicity to recover here short of two-phase
|
|
184
|
+
# commit; the log line below is what makes it diagnosable.
|
|
185
|
+
for session in sessions:
|
|
186
|
+
await finalize_session(session)
|
|
187
|
+
except Exception:
|
|
188
|
+
aborted = True
|
|
189
|
+
logger.exception(
|
|
190
|
+
"db.session.commit_failed", extra={"operation": "commit_failed"}
|
|
191
|
+
)
|
|
192
|
+
await _send_internal_error(send)
|
|
193
|
+
return
|
|
194
|
+
await send(message)
|
|
195
|
+
|
|
196
|
+
await self.app(scope, receive, send_wrapper)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def _send_internal_error(send) -> None:
|
|
200
|
+
"""Replace the not-yet-sent response with a 500.
|
|
201
|
+
|
|
202
|
+
Reachable only from ``http.response.start``, so nothing has been written
|
|
203
|
+
and this cannot collide with a partially-sent response.
|
|
204
|
+
"""
|
|
205
|
+
await send(
|
|
206
|
+
{
|
|
207
|
+
"type": "http.response.start",
|
|
208
|
+
"status": 500,
|
|
209
|
+
"headers": [
|
|
210
|
+
(b"content-type", b"application/json"),
|
|
211
|
+
(b"content-length", str(len(_INTERNAL_ERROR_BODY)).encode()),
|
|
212
|
+
],
|
|
213
|
+
}
|
|
214
|
+
)
|
|
215
|
+
await send({"type": "http.response.body", "body": _INTERNAL_ERROR_BODY})
|
|
@@ -27,3 +27,14 @@ class _TenantSoftItem(_TenantBase, MultiTenantMixin, SoftDeleteMixin, table=True
|
|
|
27
27
|
__tablename__ = "mt_test_soft_item"
|
|
28
28
|
id: int | None = Field(default=None, primary_key=True)
|
|
29
29
|
name: str = Field(max_length=100)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_TxnBase = create_module_base("txn_test")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _TxnThing(_TxnBase, table=True): # ty: ignore[unsupported-base]
|
|
36
|
+
"""Plain table for exercising when the request's unit of work commits."""
|
|
37
|
+
|
|
38
|
+
__tablename__ = "txn_test_thing"
|
|
39
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
40
|
+
name: str = Field(max_length=100)
|
|
@@ -188,6 +188,49 @@ class TestProcessRevisionDirectives:
|
|
|
188
188
|
index_ops = [op for op in directives[0].upgrade_ops.ops if isinstance(op, CreateIndexOp)]
|
|
189
189
|
assert len(index_ops) == 1
|
|
190
190
|
|
|
191
|
+
def test_does_not_double_inject_when_nested_in_modify_table_ops(self):
|
|
192
|
+
"""Autogenerate does not hand us a flat op list: index ops for a table
|
|
193
|
+
arrive inside a ``ModifyTableOps`` group next to the ``CreateTableOp``.
|
|
194
|
+
A dedup check that only scans the top level sees no ``CreateIndexOp``
|
|
195
|
+
there and re-injects the index PostgreSQL already emitted, producing a
|
|
196
|
+
migration that dies with ``DuplicateTable`` on its first run."""
|
|
197
|
+
from alembic.operations.ops import CreateIndexOp, ModifyTableOps
|
|
198
|
+
from simple_module_db.migrations import make_process_revision_directives
|
|
199
|
+
|
|
200
|
+
meta = self._build_meta()
|
|
201
|
+
idx = next(iter(meta.tables["things"].indexes))
|
|
202
|
+
nested = ModifyTableOps("things", ops=[CreateIndexOp.from_index(idx)])
|
|
203
|
+
directives = self._build_directives("things", nested)
|
|
204
|
+
make_process_revision_directives(meta)(None, None, directives)
|
|
205
|
+
|
|
206
|
+
assert self._count_index_ops(directives[0].upgrade_ops, CreateIndexOp) == 1
|
|
207
|
+
|
|
208
|
+
def test_does_not_double_inject_drop_nested_in_modify_table_ops(self):
|
|
209
|
+
"""Same nesting applies to the downgrade: the reflected ``DropIndexOp``
|
|
210
|
+
sits inside a ``ModifyTableOps`` group before the ``DropTableOp``."""
|
|
211
|
+
from alembic.operations.ops import DropIndexOp, ModifyTableOps
|
|
212
|
+
from simple_module_db.migrations import make_process_revision_directives
|
|
213
|
+
|
|
214
|
+
meta = self._build_meta()
|
|
215
|
+
directives = self._build_directives("things")
|
|
216
|
+
directives[0].downgrade_ops.ops.insert(
|
|
217
|
+
0, ModifyTableOps("things", ops=[DropIndexOp("ix_things_email_lower", "things")])
|
|
218
|
+
)
|
|
219
|
+
make_process_revision_directives(meta)(None, None, directives)
|
|
220
|
+
|
|
221
|
+
assert self._count_index_ops(directives[0].downgrade_ops, DropIndexOp) == 1
|
|
222
|
+
|
|
223
|
+
@staticmethod
|
|
224
|
+
def _count_index_ops(container, op_type) -> int:
|
|
225
|
+
"""Count ``op_type`` ops anywhere under ``container``, nesting included."""
|
|
226
|
+
total = 0
|
|
227
|
+
for op in container.ops:
|
|
228
|
+
if isinstance(op, op_type):
|
|
229
|
+
total += 1
|
|
230
|
+
if hasattr(op, "ops"):
|
|
231
|
+
total += TestProcessRevisionDirectives._count_index_ops(op, op_type)
|
|
232
|
+
return total
|
|
233
|
+
|
|
191
234
|
def test_ignores_column_based_indexes(self):
|
|
192
235
|
"""Plain column indexes are already handled correctly by autogenerate;
|
|
193
236
|
the hook must not touch them."""
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Tests for commit-before-response (GH #257).
|
|
2
|
+
|
|
3
|
+
A client that creates a row and immediately references it by id used to get a
|
|
4
|
+
deterministic 404: FastAPI runs a ``yield`` dependency's exit code *after* the
|
|
5
|
+
response is delivered, so the create's ``201`` beat the create's commit and the
|
|
6
|
+
follow-up request opened a fresh session that saw nothing.
|
|
7
|
+
|
|
8
|
+
These use a **file-backed** SQLite database on purpose. An in-memory SQLite URL
|
|
9
|
+
shares one connection across every session, which makes uncommitted writes
|
|
10
|
+
visible to the "second request" and hides the very race under test.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import AsyncGenerator
|
|
16
|
+
from types import SimpleNamespace
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
import pytest
|
|
20
|
+
from _models import _TxnBase, _TxnThing
|
|
21
|
+
from fastapi import BackgroundTasks, Depends, FastAPI
|
|
22
|
+
from fastapi.responses import StreamingResponse
|
|
23
|
+
from simple_module_db.deps import get_db
|
|
24
|
+
from simple_module_db.listeners import register_listeners
|
|
25
|
+
from simple_module_db.session import init_db
|
|
26
|
+
from simple_module_db.transaction import CommitBeforeResponseMiddleware
|
|
27
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
28
|
+
from sqlmodel import select
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _build_app(db_state, *, with_middleware: bool = True) -> FastAPI:
|
|
32
|
+
"""A miniature host: create flushes only, read opens its own session."""
|
|
33
|
+
app = FastAPI()
|
|
34
|
+
if with_middleware:
|
|
35
|
+
app.add_middleware(CommitBeforeResponseMiddleware)
|
|
36
|
+
app.state.sm = SimpleNamespace(db=db_state)
|
|
37
|
+
|
|
38
|
+
@app.post("/things", status_code=201)
|
|
39
|
+
async def create(name: str, db: AsyncSession = Depends(get_db)):
|
|
40
|
+
# Deliberately no commit() — get_db owns the unit of work, which is
|
|
41
|
+
# exactly the pattern the framework documents for service code.
|
|
42
|
+
thing = _TxnThing(name=name)
|
|
43
|
+
db.add(thing)
|
|
44
|
+
await db.flush()
|
|
45
|
+
return {"id": thing.id}
|
|
46
|
+
|
|
47
|
+
@app.get("/things/{thing_id}")
|
|
48
|
+
async def read(thing_id: int, db: AsyncSession = Depends(get_db)):
|
|
49
|
+
found = (
|
|
50
|
+
await db.execute(select(_TxnThing).where(_TxnThing.id == thing_id))
|
|
51
|
+
).scalar_one_or_none()
|
|
52
|
+
if found is None:
|
|
53
|
+
return {"found": False}
|
|
54
|
+
return {"found": True, "name": found.name}
|
|
55
|
+
|
|
56
|
+
@app.post("/boom", status_code=201)
|
|
57
|
+
async def boom(db: AsyncSession = Depends(get_db)):
|
|
58
|
+
db.add(_TxnThing(name="doomed"))
|
|
59
|
+
await db.flush()
|
|
60
|
+
raise RuntimeError("endpoint blew up after writing")
|
|
61
|
+
|
|
62
|
+
return app
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.fixture
|
|
66
|
+
async def db_state(tmp_path) -> AsyncGenerator[object, None]:
|
|
67
|
+
state = init_db(f"sqlite+aiosqlite:///{tmp_path / 'txn.db'}")
|
|
68
|
+
try:
|
|
69
|
+
# The after_flush listener is what marks a session as having writes
|
|
70
|
+
# once flush() has emptied new/dirty/deleted — create_app registers it,
|
|
71
|
+
# so the fixture must too or every request looks read-only.
|
|
72
|
+
register_listeners(state)
|
|
73
|
+
async with state.engine.begin() as conn:
|
|
74
|
+
await conn.run_sync(_TxnBase.metadata.create_all)
|
|
75
|
+
yield state
|
|
76
|
+
finally:
|
|
77
|
+
await state.engine.dispose()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def _client(app) -> httpx.AsyncClient:
|
|
81
|
+
return httpx.AsyncClient(
|
|
82
|
+
transport=httpx.ASGITransport(app=app, raise_app_exceptions=False),
|
|
83
|
+
base_url="http://test",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TestCommitBeforeResponse:
|
|
88
|
+
async def test_created_row_is_readable_on_the_very_next_request(self, db_state):
|
|
89
|
+
"""The client-visible contract: no second pass, no retry loop.
|
|
90
|
+
|
|
91
|
+
Note this one does *not* fail without the fix — httpx's in-process ASGI
|
|
92
|
+
transport awaits the whole request, teardown included, before issuing
|
|
93
|
+
the next one, so the follow-up can never beat the commit here. It
|
|
94
|
+
documents the intended behaviour; the ordering guarantee is pinned by
|
|
95
|
+
``test_write_is_durable_before_the_response_is_delivered`` below, which
|
|
96
|
+
does fail without the middleware.
|
|
97
|
+
"""
|
|
98
|
+
async with await _client(_build_app(db_state)) as client:
|
|
99
|
+
created = await client.post("/things", params={"name": "page-1"})
|
|
100
|
+
assert created.status_code == 201
|
|
101
|
+
thing_id = created.json()["id"]
|
|
102
|
+
|
|
103
|
+
found = await client.get(f"/things/{thing_id}")
|
|
104
|
+
|
|
105
|
+
assert found.json() == {"found": True, "name": "page-1"}
|
|
106
|
+
|
|
107
|
+
async def test_write_is_durable_before_the_response_is_delivered(self, db_state):
|
|
108
|
+
"""Stronger than the round-trip above: assert against a connection that
|
|
109
|
+
the request never touched, at the moment the response is emitted."""
|
|
110
|
+
app = _build_app(db_state)
|
|
111
|
+
visible_at_response_start: list[bool] = []
|
|
112
|
+
|
|
113
|
+
class Probe:
|
|
114
|
+
def __init__(self, inner):
|
|
115
|
+
self.inner = inner
|
|
116
|
+
|
|
117
|
+
async def __call__(self, scope, receive, send):
|
|
118
|
+
async def spy(message):
|
|
119
|
+
if message["type"] == "http.response.start":
|
|
120
|
+
async with db_state.session_factory() as other:
|
|
121
|
+
rows = (await other.execute(select(_TxnThing))).scalars().all()
|
|
122
|
+
visible_at_response_start.append(bool(rows))
|
|
123
|
+
await send(message)
|
|
124
|
+
|
|
125
|
+
await self.inner(scope, receive, spy)
|
|
126
|
+
|
|
127
|
+
async with await _client(Probe(app)) as client:
|
|
128
|
+
assert (await client.post("/things", params={"name": "durable"})).status_code == 201
|
|
129
|
+
|
|
130
|
+
assert visible_at_response_start == [True], (
|
|
131
|
+
"row was not committed by the time the response left the server"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
async def test_endpoint_exception_still_rolls_back(self, db_state):
|
|
135
|
+
"""The middleware must not turn a failed request's writes into a commit."""
|
|
136
|
+
async with await _client(_build_app(db_state)) as client:
|
|
137
|
+
assert (await client.post("/boom")).status_code == 500
|
|
138
|
+
|
|
139
|
+
async with db_state.session_factory() as session:
|
|
140
|
+
assert (await session.execute(select(_TxnThing))).scalars().all() == []
|
|
141
|
+
|
|
142
|
+
async def test_commit_failure_becomes_a_500_and_persists_nothing(self, db_state, monkeypatch):
|
|
143
|
+
"""A commit that blows up at response.start replaces the response rather
|
|
144
|
+
than shipping a 201 for work that never landed.
|
|
145
|
+
|
|
146
|
+
The failure is injected at ``AsyncSession.commit`` rather than at
|
|
147
|
+
``finalize_session``: stubbing the finalizer would bypass its bookkeeping,
|
|
148
|
+
leaving get_db's fallback free to commit the row the test claims was
|
|
149
|
+
lost — the assertion would pass while the guarantee was broken.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
async def explode(self, *args, **kwargs):
|
|
153
|
+
raise RuntimeError("commit failed")
|
|
154
|
+
|
|
155
|
+
monkeypatch.setattr(AsyncSession, "commit", explode)
|
|
156
|
+
|
|
157
|
+
async with await _client(_build_app(db_state)) as client:
|
|
158
|
+
response = await client.post("/things", params={"name": "nope"})
|
|
159
|
+
|
|
160
|
+
assert response.status_code == 500
|
|
161
|
+
assert response.json() == {"detail": "Internal Server Error"}
|
|
162
|
+
|
|
163
|
+
monkeypatch.undo()
|
|
164
|
+
async with db_state.session_factory() as session:
|
|
165
|
+
assert (await session.execute(select(_TxnThing))).scalars().all() == []
|
|
166
|
+
|
|
167
|
+
async def test_background_task_writes_still_commit(self, db_state):
|
|
168
|
+
"""Starlette runs BackgroundTasks after the body is sent, on this same
|
|
169
|
+
session. Finalizing at response.start must not consume the session and
|
|
170
|
+
strand them — they flushed but never committed, losing writes silently."""
|
|
171
|
+
app = _build_app(db_state)
|
|
172
|
+
|
|
173
|
+
@app.post("/with-task", status_code=202)
|
|
174
|
+
async def with_task(bg: BackgroundTasks, db: AsyncSession = Depends(get_db)):
|
|
175
|
+
db.add(_TxnThing(name="before-response"))
|
|
176
|
+
await db.flush()
|
|
177
|
+
|
|
178
|
+
async def task():
|
|
179
|
+
db.add(_TxnThing(name="from-background-task"))
|
|
180
|
+
await db.flush()
|
|
181
|
+
|
|
182
|
+
bg.add_task(task)
|
|
183
|
+
return {"ok": True}
|
|
184
|
+
|
|
185
|
+
async with await _client(app) as client:
|
|
186
|
+
assert (await client.post("/with-task")).status_code == 202
|
|
187
|
+
|
|
188
|
+
async with db_state.session_factory() as session:
|
|
189
|
+
names = sorted(t.name for t in (await session.execute(select(_TxnThing))).scalars())
|
|
190
|
+
assert names == ["before-response", "from-background-task"]
|
|
191
|
+
|
|
192
|
+
async def test_streaming_response_body_writes_still_commit(self, db_state):
|
|
193
|
+
"""A StreamingResponse writes its body *after* response.start, so work
|
|
194
|
+
done while streaming lands after the middleware's commit."""
|
|
195
|
+
app = _build_app(db_state)
|
|
196
|
+
|
|
197
|
+
@app.get("/stream")
|
|
198
|
+
async def stream(db: AsyncSession = Depends(get_db)):
|
|
199
|
+
db.add(_TxnThing(name="before-stream"))
|
|
200
|
+
await db.flush()
|
|
201
|
+
|
|
202
|
+
async def body():
|
|
203
|
+
yield b"chunk"
|
|
204
|
+
db.add(_TxnThing(name="during-stream"))
|
|
205
|
+
await db.flush()
|
|
206
|
+
|
|
207
|
+
return StreamingResponse(body())
|
|
208
|
+
|
|
209
|
+
async with await _client(app) as client:
|
|
210
|
+
assert (await client.get("/stream")).status_code == 200
|
|
211
|
+
|
|
212
|
+
async with db_state.session_factory() as session:
|
|
213
|
+
names = sorted(t.name for t in (await session.execute(select(_TxnThing))).scalars())
|
|
214
|
+
assert names == ["before-stream", "during-stream"]
|
|
215
|
+
|
|
216
|
+
async def test_still_commits_without_the_middleware(self, db_state):
|
|
217
|
+
"""get_db keeps its own fallback finalize, so the dependency works
|
|
218
|
+
standalone — in a WebSocket handler, or a test that builds no stack."""
|
|
219
|
+
app = _build_app(db_state, with_middleware=False)
|
|
220
|
+
async with await _client(app) as client:
|
|
221
|
+
assert (await client.post("/things", params={"name": "solo"})).status_code == 201
|
|
222
|
+
|
|
223
|
+
async with db_state.session_factory() as session:
|
|
224
|
+
names = [t.name for t in (await session.execute(select(_TxnThing))).scalars().all()]
|
|
225
|
+
assert names == ["solo"]
|
|
226
|
+
|
|
227
|
+
async def test_read_only_request_is_not_committed(self, db_state):
|
|
228
|
+
"""Read-only handlers still exit via rollback — one round-trip cheaper."""
|
|
229
|
+
app = _build_app(db_state)
|
|
230
|
+
async with await _client(app) as client:
|
|
231
|
+
assert (await client.get("/things/999")).json() == {"found": False}
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
"""FastAPI dependencies for database access."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import logging
|
|
6
|
-
import time
|
|
7
|
-
from collections.abc import AsyncGenerator
|
|
8
|
-
|
|
9
|
-
from fastapi import Request
|
|
10
|
-
from sqlalchemy.ext.asyncio import AsyncSession
|
|
11
|
-
|
|
12
|
-
from simple_module_db.listeners import SESSION_HAS_WRITES_KEY
|
|
13
|
-
|
|
14
|
-
_db_logger = logging.getLogger("simple_module.db")
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
async def get_db(request: Request) -> AsyncGenerator[AsyncSession, None]:
|
|
18
|
-
"""Yield an async database session, auto-closing on exit.
|
|
19
|
-
|
|
20
|
-
Commits only when the session has pending writes (``new``, ``dirty``,
|
|
21
|
-
or ``deleted`` objects). Read-only handlers still open an implicit
|
|
22
|
-
transaction but exit via ``rollback`` — that's one round-trip
|
|
23
|
-
cheaper than ``commit`` and keeps read-only queries from showing up
|
|
24
|
-
as writes in query logs / ``pg_stat_statements``.
|
|
25
|
-
|
|
26
|
-
Usage in FastAPI endpoints::
|
|
27
|
-
|
|
28
|
-
@router.get("/items")
|
|
29
|
-
async def list_items(db: AsyncSession = Depends(get_db)):
|
|
30
|
-
...
|
|
31
|
-
"""
|
|
32
|
-
factory = request.app.state.sm.db.session_factory
|
|
33
|
-
start = time.perf_counter()
|
|
34
|
-
async with factory() as session:
|
|
35
|
-
try:
|
|
36
|
-
yield session
|
|
37
|
-
# ``has_writes`` is set by the after_flush listener and
|
|
38
|
-
# survives the flush emptying session.new/.dirty/.deleted.
|
|
39
|
-
has_pending = bool(
|
|
40
|
-
session.info.get(SESSION_HAS_WRITES_KEY)
|
|
41
|
-
or session.new
|
|
42
|
-
or session.dirty
|
|
43
|
-
or session.deleted
|
|
44
|
-
)
|
|
45
|
-
if has_pending:
|
|
46
|
-
await session.commit()
|
|
47
|
-
op, log_message = "commit", "db.session.commit"
|
|
48
|
-
log = _db_logger.info
|
|
49
|
-
else:
|
|
50
|
-
await session.rollback()
|
|
51
|
-
op, log_message = "read_only_rollback", "db.session.read_only"
|
|
52
|
-
log = _db_logger.debug
|
|
53
|
-
duration_ms = round((time.perf_counter() - start) * 1000, 2)
|
|
54
|
-
log(log_message, extra={"operation": op, "db_duration_ms": duration_ms})
|
|
55
|
-
except Exception:
|
|
56
|
-
await session.rollback()
|
|
57
|
-
duration_ms = round((time.perf_counter() - start) * 1000, 2)
|
|
58
|
-
_db_logger.warning(
|
|
59
|
-
"db.session.rollback",
|
|
60
|
-
extra={"operation": "rollback", "db_duration_ms": duration_ms},
|
|
61
|
-
)
|
|
62
|
-
raise
|
|
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
|