fastapi-async-sqlalchemy 0.7.1.post2__tar.gz → 0.7.1.post3__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.
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/PKG-INFO +1 -1
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/fastapi_async_sqlalchemy/__init__.py +1 -1
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/fastapi_async_sqlalchemy/middleware.py +26 -91
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/fastapi_async_sqlalchemy.egg-info/PKG-INFO +1 -1
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/fastapi_async_sqlalchemy.egg-info/SOURCES.txt +3 -2
- fastapi_async_sqlalchemy-0.7.1.post3/tests/test_backward_compat_gather.py +213 -0
- fastapi_async_sqlalchemy-0.7.1.post3/tests/test_concurrent_queries.py +480 -0
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/tests/test_coverage_improvements.py +10 -10
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/tests/test_maximum_coverage.py +4 -6
- fastapi_async_sqlalchemy-0.7.1.post3/tests/test_single_session_no_gather.py +104 -0
- fastapi_async_sqlalchemy-0.7.1.post2/tests/test_multi_sessions_cleanup.py +0 -89
- fastapi_async_sqlalchemy-0.7.1.post2/tests/test_multisession_pool.py +0 -82
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/LICENSE +0 -0
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/README.md +0 -0
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/fastapi_async_sqlalchemy/exceptions.py +0 -0
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/fastapi_async_sqlalchemy/py.typed +0 -0
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/fastapi_async_sqlalchemy.egg-info/dependency_links.txt +0 -0
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/fastapi_async_sqlalchemy.egg-info/not-zip-safe +0 -0
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/fastapi_async_sqlalchemy.egg-info/requires.txt +0 -0
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/fastapi_async_sqlalchemy.egg-info/top_level.txt +0 -0
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/pyproject.toml +0 -0
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/setup.cfg +0 -0
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/setup.py +0 -0
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/tests/test_additional_coverage.py +0 -0
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/tests/test_coverage_boost.py +0 -0
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/tests/test_custom_engine_branch.py +0 -0
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/tests/test_edge_cases_coverage.py +0 -0
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/tests/test_import_fallback_simulation.py +0 -0
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/tests/test_import_fallbacks.py +0 -0
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/tests/test_import_without_sqlmodel.py +0 -0
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/tests/test_session.py +0 -0
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/tests/test_sqlmodel.py +0 -0
- {fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/tests/test_type_hints_compatibility.py +0 -0
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
from contextvars import ContextVar
|
|
3
|
-
from dataclasses import dataclass, field
|
|
4
3
|
from typing import Optional, Union
|
|
5
4
|
|
|
6
5
|
from sqlalchemy.engine.url import URL
|
|
@@ -27,24 +26,14 @@ except ImportError:
|
|
|
27
26
|
DefaultAsyncSession: type[AsyncSession] = AsyncSession # type: ignore
|
|
28
27
|
|
|
29
28
|
|
|
30
|
-
@dataclass(slots=True)
|
|
31
|
-
class MultiSessionState:
|
|
32
|
-
"""State for multi_sessions mode."""
|
|
33
|
-
|
|
34
|
-
tracked: set[AsyncSession] = field(default_factory=set)
|
|
35
|
-
task_sessions: dict[int, AsyncSession] = field(default_factory=dict)
|
|
36
|
-
cleanup_tasks: list[asyncio.Task] = field(default_factory=list)
|
|
37
|
-
parent_task_id: int = 0
|
|
38
|
-
commit_on_exit: bool = False
|
|
39
|
-
|
|
40
|
-
|
|
41
29
|
def create_middleware_and_session_proxy() -> tuple:
|
|
42
30
|
_Session: Optional[async_sessionmaker] = None
|
|
43
31
|
_session: ContextVar[Optional[AsyncSession]] = ContextVar("_session", default=None)
|
|
44
|
-
|
|
32
|
+
_multi_sessions_ctx: ContextVar[bool] = ContextVar("_multi_sessions_context", default=False)
|
|
33
|
+
_commit_on_exit_ctx: ContextVar[bool] = ContextVar("_commit_on_exit_ctx", default=False)
|
|
45
34
|
|
|
46
35
|
class _SQLAlchemyMiddleware(BaseHTTPMiddleware):
|
|
47
|
-
__test__ = False
|
|
36
|
+
__test__ = False
|
|
48
37
|
|
|
49
38
|
def __init__(
|
|
50
39
|
self,
|
|
@@ -86,48 +75,24 @@ def create_middleware_and_session_proxy() -> tuple:
|
|
|
86
75
|
if _Session is None:
|
|
87
76
|
raise SessionNotInitialisedError
|
|
88
77
|
|
|
89
|
-
|
|
90
|
-
if
|
|
91
|
-
|
|
92
|
-
if task is None:
|
|
93
|
-
raise RuntimeError("Cannot get current task")
|
|
94
|
-
task_id = id(task)
|
|
95
|
-
|
|
96
|
-
if task_id in state.task_sessions:
|
|
97
|
-
return state.task_sessions[task_id]
|
|
98
|
-
|
|
78
|
+
multi_sessions = _multi_sessions_ctx.get()
|
|
79
|
+
if multi_sessions:
|
|
80
|
+
commit_on_exit = _commit_on_exit_ctx.get()
|
|
99
81
|
session = _Session()
|
|
100
|
-
state.task_sessions[task_id] = session
|
|
101
|
-
state.tracked.add(session)
|
|
102
|
-
|
|
103
|
-
# Add cleanup callback only for child tasks
|
|
104
|
-
if task_id != state.parent_task_id:
|
|
105
|
-
|
|
106
|
-
def cleanup_callback(_task):
|
|
107
|
-
try:
|
|
108
|
-
loop = asyncio.get_running_loop()
|
|
109
|
-
if loop.is_closed():
|
|
110
|
-
return
|
|
111
|
-
except RuntimeError:
|
|
112
|
-
return
|
|
113
|
-
|
|
114
|
-
async def cleanup():
|
|
115
|
-
try:
|
|
116
|
-
if state.commit_on_exit:
|
|
117
|
-
try:
|
|
118
|
-
await session.commit()
|
|
119
|
-
except Exception:
|
|
120
|
-
await session.rollback()
|
|
121
|
-
finally:
|
|
122
|
-
await session.close()
|
|
123
|
-
state.tracked.discard(session)
|
|
124
|
-
state.task_sessions.pop(task_id, None)
|
|
125
|
-
|
|
126
|
-
t = loop.create_task(cleanup())
|
|
127
|
-
state.cleanup_tasks.append(t)
|
|
128
|
-
|
|
129
|
-
task.add_done_callback(cleanup_callback)
|
|
130
82
|
|
|
83
|
+
async def cleanup():
|
|
84
|
+
try:
|
|
85
|
+
if commit_on_exit:
|
|
86
|
+
await session.commit()
|
|
87
|
+
except Exception:
|
|
88
|
+
await session.rollback()
|
|
89
|
+
raise
|
|
90
|
+
finally:
|
|
91
|
+
await session.close()
|
|
92
|
+
|
|
93
|
+
task = asyncio.current_task()
|
|
94
|
+
if task is not None:
|
|
95
|
+
task.add_done_callback(lambda t: asyncio.create_task(cleanup()))
|
|
131
96
|
return session
|
|
132
97
|
else:
|
|
133
98
|
session = _session.get()
|
|
@@ -143,7 +108,8 @@ def create_middleware_and_session_proxy() -> tuple:
|
|
|
143
108
|
multi_sessions: bool = False,
|
|
144
109
|
):
|
|
145
110
|
self.token = None
|
|
146
|
-
self.
|
|
111
|
+
self.multi_sessions_token = None
|
|
112
|
+
self.commit_on_exit_token = None
|
|
147
113
|
self.session_args = session_args or {}
|
|
148
114
|
self.commit_on_exit = commit_on_exit
|
|
149
115
|
self.multi_sessions = multi_sessions
|
|
@@ -153,54 +119,23 @@ def create_middleware_and_session_proxy() -> tuple:
|
|
|
153
119
|
raise SessionNotInitialisedError
|
|
154
120
|
|
|
155
121
|
if self.multi_sessions:
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
commit_on_exit=self.commit_on_exit,
|
|
159
|
-
)
|
|
160
|
-
self.multi_state_token = _multi_state.set(state)
|
|
122
|
+
self.multi_sessions_token = _multi_sessions_ctx.set(True)
|
|
123
|
+
self.commit_on_exit_token = _commit_on_exit_ctx.set(self.commit_on_exit)
|
|
161
124
|
else:
|
|
162
125
|
self.token = _session.set(_Session(**self.session_args))
|
|
163
126
|
return type(self)
|
|
164
127
|
|
|
165
128
|
async def __aexit__(self, exc_type, exc_value, traceback):
|
|
166
129
|
if self.multi_sessions:
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
# Wait for cleanup tasks
|
|
170
|
-
if state.cleanup_tasks:
|
|
171
|
-
await asyncio.sleep(0)
|
|
172
|
-
await asyncio.gather(*state.cleanup_tasks, return_exceptions=True)
|
|
173
|
-
|
|
174
|
-
# Clean up remaining sessions
|
|
175
|
-
for session in list(state.tracked):
|
|
176
|
-
try:
|
|
177
|
-
if exc_type is not None:
|
|
178
|
-
await session.rollback()
|
|
179
|
-
elif self.commit_on_exit:
|
|
180
|
-
try:
|
|
181
|
-
await session.commit()
|
|
182
|
-
except Exception:
|
|
183
|
-
await session.rollback()
|
|
184
|
-
except Exception:
|
|
185
|
-
pass
|
|
186
|
-
finally:
|
|
187
|
-
try:
|
|
188
|
-
await session.close()
|
|
189
|
-
except Exception:
|
|
190
|
-
pass
|
|
191
|
-
|
|
192
|
-
_multi_state.reset(self.multi_state_token)
|
|
130
|
+
_multi_sessions_ctx.reset(self.multi_sessions_token)
|
|
131
|
+
_commit_on_exit_ctx.reset(self.commit_on_exit_token)
|
|
193
132
|
else:
|
|
194
133
|
session = _session.get()
|
|
195
134
|
try:
|
|
196
135
|
if exc_type is not None:
|
|
197
136
|
await session.rollback()
|
|
198
137
|
elif self.commit_on_exit:
|
|
199
|
-
|
|
200
|
-
await session.commit()
|
|
201
|
-
except Exception:
|
|
202
|
-
await session.rollback()
|
|
203
|
-
raise
|
|
138
|
+
await session.commit()
|
|
204
139
|
finally:
|
|
205
140
|
await session.close()
|
|
206
141
|
_session.reset(self.token)
|
|
@@ -13,6 +13,8 @@ fastapi_async_sqlalchemy.egg-info/not-zip-safe
|
|
|
13
13
|
fastapi_async_sqlalchemy.egg-info/requires.txt
|
|
14
14
|
fastapi_async_sqlalchemy.egg-info/top_level.txt
|
|
15
15
|
tests/test_additional_coverage.py
|
|
16
|
+
tests/test_backward_compat_gather.py
|
|
17
|
+
tests/test_concurrent_queries.py
|
|
16
18
|
tests/test_coverage_boost.py
|
|
17
19
|
tests/test_coverage_improvements.py
|
|
18
20
|
tests/test_custom_engine_branch.py
|
|
@@ -21,8 +23,7 @@ tests/test_import_fallback_simulation.py
|
|
|
21
23
|
tests/test_import_fallbacks.py
|
|
22
24
|
tests/test_import_without_sqlmodel.py
|
|
23
25
|
tests/test_maximum_coverage.py
|
|
24
|
-
tests/test_multi_sessions_cleanup.py
|
|
25
|
-
tests/test_multisession_pool.py
|
|
26
26
|
tests/test_session.py
|
|
27
|
+
tests/test_single_session_no_gather.py
|
|
27
28
|
tests/test_sqlmodel.py
|
|
28
29
|
tests/test_type_hints_compatibility.py
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Test backward compatibility for asyncio.gather() without multi_sessions flag.
|
|
2
|
+
|
|
3
|
+
This test verifies that after the fix, the old code pattern works without
|
|
4
|
+
requiring multi_sessions=True explicitly.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from sqlalchemy import text
|
|
11
|
+
|
|
12
|
+
db_url = "sqlite+aiosqlite://"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.mark.asyncio
|
|
16
|
+
async def test_gather_works_without_multi_sessions_flag(app, db, SQLAlchemyMiddleware):
|
|
17
|
+
"""
|
|
18
|
+
Verify that asyncio.gather() works in normal mode (without multi_sessions=True).
|
|
19
|
+
|
|
20
|
+
This is the backward compatibility fix - users shouldn't need to change their code.
|
|
21
|
+
"""
|
|
22
|
+
app.add_middleware(SQLAlchemyMiddleware, db_url=db_url)
|
|
23
|
+
|
|
24
|
+
async with db(commit_on_exit=True):
|
|
25
|
+
await db.session.execute(
|
|
26
|
+
text("CREATE TABLE IF NOT EXISTS compat_test (id INTEGER PRIMARY KEY, value TEXT)")
|
|
27
|
+
)
|
|
28
|
+
for i in range(20):
|
|
29
|
+
await db.session.execute(
|
|
30
|
+
text("INSERT INTO compat_test (value) VALUES (:value)"),
|
|
31
|
+
{"value": f"value_{i}"},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# OLD CODE PATTERN - should work now without multi_sessions=True
|
|
35
|
+
async with db():
|
|
36
|
+
count_stmt = text("SELECT COUNT(*) FROM compat_test")
|
|
37
|
+
data_stmt = text("SELECT * FROM compat_test LIMIT 5")
|
|
38
|
+
|
|
39
|
+
# This should work! Each parallel query gets its own session
|
|
40
|
+
count_result, data_result = await asyncio.gather(
|
|
41
|
+
db.session.execute(count_stmt),
|
|
42
|
+
db.session.execute(data_stmt),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
count = count_result.scalar()
|
|
46
|
+
data = data_result.fetchall()
|
|
47
|
+
|
|
48
|
+
assert count == 20
|
|
49
|
+
assert len(data) == 5
|
|
50
|
+
|
|
51
|
+
print("✅ Backward compatibility preserved: asyncio.gather() works without multi_sessions!")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.mark.asyncio
|
|
55
|
+
async def test_gather_multiple_queries_parallel(app, db, SQLAlchemyMiddleware):
|
|
56
|
+
"""
|
|
57
|
+
Test that multiple parallel queries work correctly.
|
|
58
|
+
"""
|
|
59
|
+
app.add_middleware(SQLAlchemyMiddleware, db_url=db_url)
|
|
60
|
+
|
|
61
|
+
async with db(commit_on_exit=True):
|
|
62
|
+
await db.session.execute(
|
|
63
|
+
text("CREATE TABLE IF NOT EXISTS parallel_test (id INTEGER PRIMARY KEY, status TEXT)")
|
|
64
|
+
)
|
|
65
|
+
for i in range(100):
|
|
66
|
+
await db.session.execute(
|
|
67
|
+
text("INSERT INTO parallel_test (status) VALUES (:status)"),
|
|
68
|
+
{"status": "active" if i % 3 == 0 else "inactive"},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Multiple parallel queries without multi_sessions=True
|
|
72
|
+
async with db():
|
|
73
|
+
stmt1 = text("SELECT COUNT(*) FROM parallel_test WHERE status = 'active'")
|
|
74
|
+
stmt2 = text("SELECT COUNT(*) FROM parallel_test WHERE status = 'inactive'")
|
|
75
|
+
stmt3 = text("SELECT * FROM parallel_test LIMIT 10")
|
|
76
|
+
|
|
77
|
+
r1, r2, r3 = await asyncio.gather(
|
|
78
|
+
db.session.execute(stmt1),
|
|
79
|
+
db.session.execute(stmt2),
|
|
80
|
+
db.session.execute(stmt3),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
active_count = r1.scalar()
|
|
84
|
+
inactive_count = r2.scalar()
|
|
85
|
+
data = r3.fetchall()
|
|
86
|
+
|
|
87
|
+
assert active_count == 34 # 100 / 3 rounded up
|
|
88
|
+
assert inactive_count == 66
|
|
89
|
+
assert len(data) == 10
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.mark.asyncio
|
|
93
|
+
async def test_production_pattern_without_changes(app, db, SQLAlchemyMiddleware):
|
|
94
|
+
"""
|
|
95
|
+
Verify the EXACT production pattern from the error report works.
|
|
96
|
+
|
|
97
|
+
This is the pattern from /app/api/repository/routes.py:186
|
|
98
|
+
"""
|
|
99
|
+
app.add_middleware(SQLAlchemyMiddleware, db_url=db_url)
|
|
100
|
+
|
|
101
|
+
async with db(commit_on_exit=True):
|
|
102
|
+
await db.session.execute(
|
|
103
|
+
text("""
|
|
104
|
+
CREATE TABLE IF NOT EXISTS processes (
|
|
105
|
+
id INTEGER PRIMARY KEY,
|
|
106
|
+
name TEXT NOT NULL,
|
|
107
|
+
status TEXT,
|
|
108
|
+
created_at TEXT
|
|
109
|
+
)
|
|
110
|
+
""")
|
|
111
|
+
)
|
|
112
|
+
for i in range(100):
|
|
113
|
+
await db.session.execute(
|
|
114
|
+
text(
|
|
115
|
+
"""INSERT INTO
|
|
116
|
+
processes (name, status, created_at)
|
|
117
|
+
VALUES (:name, :status, :created_at)
|
|
118
|
+
"""
|
|
119
|
+
),
|
|
120
|
+
{
|
|
121
|
+
"name": f"process_{i}",
|
|
122
|
+
"status": "running" if i % 2 == 0 else "stopped",
|
|
123
|
+
"created_at": "2025-01-01T00:00:00",
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# EXACT PRODUCTION CODE - should work now!
|
|
128
|
+
async with db():
|
|
129
|
+
count_stmt = text("SELECT COUNT(*) FROM processes WHERE status = :status")
|
|
130
|
+
processes_stmt = text(
|
|
131
|
+
"SELECT * FROM processes WHERE status = :status "
|
|
132
|
+
"ORDER BY created_at DESC LIMIT :limit OFFSET :offset"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
count_stmt = count_stmt.bindparams(status="running")
|
|
136
|
+
processes_stmt = processes_stmt.bindparams(status="running", limit=10, offset=0)
|
|
137
|
+
|
|
138
|
+
# This is line 186 from production - should work without any changes!
|
|
139
|
+
total_result, processes_result = await asyncio.gather(
|
|
140
|
+
db.session.execute(count_stmt),
|
|
141
|
+
db.session.execute(processes_stmt),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
total = total_result.scalar()
|
|
145
|
+
processes = processes_result.fetchall()
|
|
146
|
+
|
|
147
|
+
assert total == 50
|
|
148
|
+
assert len(processes) == 10
|
|
149
|
+
|
|
150
|
+
print("✅ Production code pattern works without any changes!")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@pytest.mark.asyncio
|
|
154
|
+
async def test_commit_on_exit_with_parallel_queries(app, db, SQLAlchemyMiddleware):
|
|
155
|
+
"""
|
|
156
|
+
Verify that commit_on_exit works correctly with parallel queries.
|
|
157
|
+
"""
|
|
158
|
+
app.add_middleware(SQLAlchemyMiddleware, db_url=db_url)
|
|
159
|
+
|
|
160
|
+
# Create table first
|
|
161
|
+
async with db(commit_on_exit=True):
|
|
162
|
+
await db.session.execute(
|
|
163
|
+
text("CREATE TABLE IF NOT EXISTS commit_test (id INTEGER PRIMARY KEY, value TEXT)")
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Insert data with parallel queries and commit_on_exit
|
|
167
|
+
async with db(commit_on_exit=True):
|
|
168
|
+
# These should all be committed automatically
|
|
169
|
+
await asyncio.gather(
|
|
170
|
+
db.session.execute(text("INSERT INTO commit_test (value) VALUES ('a')")),
|
|
171
|
+
db.session.execute(text("INSERT INTO commit_test (value) VALUES ('b')")),
|
|
172
|
+
db.session.execute(text("INSERT INTO commit_test (value) VALUES ('c')")),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Verify data was committed
|
|
176
|
+
async with db():
|
|
177
|
+
result = await db.session.execute(text("SELECT COUNT(*) FROM commit_test"))
|
|
178
|
+
count = result.scalar()
|
|
179
|
+
assert count == 3
|
|
180
|
+
|
|
181
|
+
print("✅ commit_on_exit works correctly with parallel queries!")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@pytest.mark.asyncio
|
|
185
|
+
async def test_rollback_on_error_with_parallel_queries(app, db, SQLAlchemyMiddleware):
|
|
186
|
+
"""
|
|
187
|
+
Verify that rollback works correctly when error occurs in parallel queries.
|
|
188
|
+
"""
|
|
189
|
+
app.add_middleware(SQLAlchemyMiddleware, db_url=db_url)
|
|
190
|
+
|
|
191
|
+
async with db(commit_on_exit=True):
|
|
192
|
+
await db.session.execute(
|
|
193
|
+
text("CREATE TABLE IF NOT EXISTS rollback_test (id INTEGER PRIMARY KEY, value TEXT)")
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Try to insert with error - should rollback all
|
|
197
|
+
try:
|
|
198
|
+
async with db(commit_on_exit=True):
|
|
199
|
+
await db.session.execute(
|
|
200
|
+
text("INSERT INTO rollback_test (value) VALUES ('should_rollback')")
|
|
201
|
+
)
|
|
202
|
+
# Force an error
|
|
203
|
+
raise RuntimeError("Simulated error")
|
|
204
|
+
except RuntimeError:
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
# Verify data was rolled back
|
|
208
|
+
async with db():
|
|
209
|
+
result = await db.session.execute(text("SELECT COUNT(*) FROM rollback_test"))
|
|
210
|
+
count = result.scalar()
|
|
211
|
+
assert count == 0
|
|
212
|
+
|
|
213
|
+
print("✅ Rollback works correctly on error!")
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""Test for concurrent query execution issue with asyncio.gather().
|
|
2
|
+
|
|
3
|
+
This test suite demonstrates the "isce" (InvalidSessionError) error that can occur
|
|
4
|
+
when trying to execute multiple queries concurrently on the same SQLAlchemy async session.
|
|
5
|
+
|
|
6
|
+
The error is:
|
|
7
|
+
InvalidRequestError: This session is provisioning a new connection;
|
|
8
|
+
concurrent operations are not permitted
|
|
9
|
+
(Background on this error at: https://sqlalche.me/e/20/isce)
|
|
10
|
+
|
|
11
|
+
This issue is database-backend specific:
|
|
12
|
+
- SQLite (aiosqlite): Serializes operations internally, may not reproduce the error
|
|
13
|
+
- PostgreSQL (asyncpg): Will raise the error
|
|
14
|
+
- MySQL (asyncmy): Will raise the error
|
|
15
|
+
|
|
16
|
+
Solutions:
|
|
17
|
+
1. Execute queries sequentially instead of using asyncio.gather()
|
|
18
|
+
2. Use multi_sessions=True mode to get a separate session per task
|
|
19
|
+
3. Use connection pooling with multiple connections
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
|
|
24
|
+
import pytest
|
|
25
|
+
from sqlalchemy import text
|
|
26
|
+
|
|
27
|
+
db_url = "sqlite+aiosqlite://"
|
|
28
|
+
|
|
29
|
+
# Optional: Test with real PostgreSQL if available
|
|
30
|
+
# Uncomment and set POSTGRES_URL environment variable to test with PostgreSQL
|
|
31
|
+
# db_url_postgres = os.getenv("POSTGRES_URL", "postgresql+asyncpg://user:pass@localhost/testdb")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.mark.asyncio
|
|
35
|
+
async def test_concurrent_queries_same_session_may_fail(app, db, SQLAlchemyMiddleware):
|
|
36
|
+
"""
|
|
37
|
+
Test concurrent queries on the same session.
|
|
38
|
+
|
|
39
|
+
This test demonstrates that with some database backends (PostgreSQL, MySQL)
|
|
40
|
+
concurrent operations on the same session can cause this error:
|
|
41
|
+
InvalidRequestError: This session is provisioning a new connection;
|
|
42
|
+
concurrent operations are not permitted
|
|
43
|
+
(Background on this error at: https://sqlalche.me/e/20/isce)
|
|
44
|
+
|
|
45
|
+
Note: SQLite (aiosqlite) may not reproduce this issue because it serializes
|
|
46
|
+
operations internally. The issue is more common with asyncpg/asyncmy drivers.
|
|
47
|
+
"""
|
|
48
|
+
app.add_middleware(SQLAlchemyMiddleware, db_url=db_url)
|
|
49
|
+
|
|
50
|
+
async with db():
|
|
51
|
+
# Create a test table
|
|
52
|
+
await db.session.execute(
|
|
53
|
+
text("CREATE TABLE IF NOT EXISTS test_table (id INTEGER PRIMARY KEY, value TEXT)")
|
|
54
|
+
)
|
|
55
|
+
await db.session.commit()
|
|
56
|
+
|
|
57
|
+
# Insert test data
|
|
58
|
+
for i in range(10):
|
|
59
|
+
await db.session.execute(
|
|
60
|
+
text("INSERT INTO test_table (value) VALUES (:value)"),
|
|
61
|
+
{"value": f"value_{i}"},
|
|
62
|
+
)
|
|
63
|
+
await db.session.commit()
|
|
64
|
+
|
|
65
|
+
# This MAY fail with 'isce' error when trying to execute
|
|
66
|
+
# two queries concurrently on the same session (depends on DB backend)
|
|
67
|
+
count_stmt = text("SELECT COUNT(*) FROM test_table")
|
|
68
|
+
data_stmt = text("SELECT * FROM test_table LIMIT 5")
|
|
69
|
+
|
|
70
|
+
# Using asyncio.gather() may cause concurrent operations on the same session
|
|
71
|
+
try:
|
|
72
|
+
count_result, data_result = await asyncio.gather(
|
|
73
|
+
db.session.execute(count_stmt),
|
|
74
|
+
db.session.execute(data_stmt),
|
|
75
|
+
)
|
|
76
|
+
# If it works, verify results
|
|
77
|
+
count = count_result.scalar()
|
|
78
|
+
data = data_result.fetchall()
|
|
79
|
+
assert count == 10
|
|
80
|
+
assert len(data) == 5
|
|
81
|
+
# Mark as expected for SQLite
|
|
82
|
+
print("Note: SQLite allows this, but PostgreSQL/MySQL may not")
|
|
83
|
+
except Exception as e:
|
|
84
|
+
# This is expected with some database backends
|
|
85
|
+
error_msg = str(e).lower()
|
|
86
|
+
assert any(
|
|
87
|
+
phrase in error_msg
|
|
88
|
+
for phrase in [
|
|
89
|
+
"concurrent operations are not permitted",
|
|
90
|
+
"isce",
|
|
91
|
+
"provisioning a new connection",
|
|
92
|
+
]
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.mark.asyncio
|
|
97
|
+
async def test_concurrent_queries_same_session_sequential_works(app, db, SQLAlchemyMiddleware):
|
|
98
|
+
"""
|
|
99
|
+
Test that sequential queries on the same session work correctly.
|
|
100
|
+
|
|
101
|
+
This is a workaround - execute queries sequentially instead of using asyncio.gather().
|
|
102
|
+
"""
|
|
103
|
+
app.add_middleware(SQLAlchemyMiddleware, db_url=db_url)
|
|
104
|
+
|
|
105
|
+
async with db():
|
|
106
|
+
# Create a test table
|
|
107
|
+
await db.session.execute(
|
|
108
|
+
text("CREATE TABLE IF NOT EXISTS test_table2 (id INTEGER PRIMARY KEY, value TEXT)")
|
|
109
|
+
)
|
|
110
|
+
await db.session.commit()
|
|
111
|
+
|
|
112
|
+
# Insert test data
|
|
113
|
+
for i in range(10):
|
|
114
|
+
await db.session.execute(
|
|
115
|
+
text("INSERT INTO test_table2 (value) VALUES (:value)"),
|
|
116
|
+
{"value": f"value_{i}"},
|
|
117
|
+
)
|
|
118
|
+
await db.session.commit()
|
|
119
|
+
|
|
120
|
+
# Execute queries sequentially - this works
|
|
121
|
+
count_stmt = text("SELECT COUNT(*) FROM test_table2")
|
|
122
|
+
data_stmt = text("SELECT * FROM test_table2 LIMIT 5")
|
|
123
|
+
|
|
124
|
+
count_result = await db.session.execute(count_stmt)
|
|
125
|
+
data_result = await db.session.execute(data_stmt)
|
|
126
|
+
|
|
127
|
+
count = count_result.scalar()
|
|
128
|
+
data = data_result.fetchall()
|
|
129
|
+
|
|
130
|
+
assert count == 10
|
|
131
|
+
assert len(data) == 5
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@pytest.mark.asyncio
|
|
135
|
+
async def test_concurrent_queries_multi_sessions_works(app, db, SQLAlchemyMiddleware):
|
|
136
|
+
"""
|
|
137
|
+
Test that concurrent queries work when using multi_sessions mode.
|
|
138
|
+
|
|
139
|
+
With multi_sessions=True, each task gets its own session,
|
|
140
|
+
so concurrent operations don't conflict.
|
|
141
|
+
"""
|
|
142
|
+
app.add_middleware(SQLAlchemyMiddleware, db_url=db_url)
|
|
143
|
+
|
|
144
|
+
async with db(multi_sessions=True, commit_on_exit=True):
|
|
145
|
+
# Create a test table
|
|
146
|
+
await db.session.execute(
|
|
147
|
+
text("CREATE TABLE IF NOT EXISTS test_table3 (id INTEGER PRIMARY KEY, value TEXT)")
|
|
148
|
+
)
|
|
149
|
+
await db.session.flush()
|
|
150
|
+
|
|
151
|
+
# Insert test data
|
|
152
|
+
for i in range(10):
|
|
153
|
+
await db.session.execute(
|
|
154
|
+
text("INSERT INTO test_table3 (value) VALUES (:value)"),
|
|
155
|
+
{"value": f"value_{i}"},
|
|
156
|
+
)
|
|
157
|
+
await db.session.flush()
|
|
158
|
+
|
|
159
|
+
# With multi_sessions, each async function gets its own session
|
|
160
|
+
async def get_count():
|
|
161
|
+
result = await db.session.execute(text("SELECT COUNT(*) FROM test_table3"))
|
|
162
|
+
return result.scalar()
|
|
163
|
+
|
|
164
|
+
async def get_data():
|
|
165
|
+
result = await db.session.execute(text("SELECT * FROM test_table3 LIMIT 5"))
|
|
166
|
+
return result.fetchall()
|
|
167
|
+
|
|
168
|
+
# This should work because each task gets its own session
|
|
169
|
+
count, data = await asyncio.gather(get_count(), get_data())
|
|
170
|
+
|
|
171
|
+
assert count == 10
|
|
172
|
+
assert len(data) == 5
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@pytest.mark.asyncio
|
|
176
|
+
async def test_concurrent_queries_reproduce_user_error(app, db, SQLAlchemyMiddleware):
|
|
177
|
+
"""
|
|
178
|
+
Reproduce the exact scenario from the user's error:
|
|
179
|
+
Two queries (count and data fetch) executed with asyncio.gather().
|
|
180
|
+
|
|
181
|
+
This demonstrates the issue reported:
|
|
182
|
+
```python
|
|
183
|
+
total_result, processes_result = await asyncio.gather(
|
|
184
|
+
db.session.execute(count_stmt), db.session.execute(processes_stmt)
|
|
185
|
+
)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
With PostgreSQL/MySQL, this can cause:
|
|
189
|
+
InvalidRequestError: This session is provisioning a new connection;
|
|
190
|
+
concurrent operations are not permitted
|
|
191
|
+
"""
|
|
192
|
+
app.add_middleware(SQLAlchemyMiddleware, db_url=db_url)
|
|
193
|
+
|
|
194
|
+
async with db():
|
|
195
|
+
# Setup similar to user's use case
|
|
196
|
+
await db.session.execute(
|
|
197
|
+
text("CREATE TABLE IF NOT EXISTS processes (id INTEGER PRIMARY KEY, name TEXT)")
|
|
198
|
+
)
|
|
199
|
+
await db.session.commit()
|
|
200
|
+
|
|
201
|
+
# Insert some test data
|
|
202
|
+
for i in range(100):
|
|
203
|
+
await db.session.execute(
|
|
204
|
+
text("INSERT INTO processes (name) VALUES (:name)"),
|
|
205
|
+
{"name": f"process_{i}"},
|
|
206
|
+
)
|
|
207
|
+
await db.session.commit()
|
|
208
|
+
|
|
209
|
+
# Simulate the user's code pattern:
|
|
210
|
+
# total_result, processes_result = await asyncio.gather(
|
|
211
|
+
# db.session.execute(count_stmt), db.session.execute(processes_stmt)
|
|
212
|
+
# )
|
|
213
|
+
|
|
214
|
+
count_stmt = text("SELECT COUNT(*) FROM processes")
|
|
215
|
+
processes_stmt = text("SELECT * FROM processes LIMIT 10 OFFSET 0")
|
|
216
|
+
|
|
217
|
+
# This may fail with the 'isce' error on PostgreSQL/MySQL
|
|
218
|
+
try:
|
|
219
|
+
total_result, processes_result = await asyncio.gather(
|
|
220
|
+
db.session.execute(count_stmt),
|
|
221
|
+
db.session.execute(processes_stmt),
|
|
222
|
+
)
|
|
223
|
+
# If it works (SQLite case), verify results
|
|
224
|
+
count = total_result.scalar()
|
|
225
|
+
data = processes_result.fetchall()
|
|
226
|
+
assert count == 100
|
|
227
|
+
assert len(data) == 10
|
|
228
|
+
print("Note: SQLite allows concurrent queries, but PostgreSQL/MySQL may not")
|
|
229
|
+
except Exception as e:
|
|
230
|
+
# This is the expected error with PostgreSQL/MySQL
|
|
231
|
+
error_msg = str(e).lower()
|
|
232
|
+
assert any(
|
|
233
|
+
phrase in error_msg
|
|
234
|
+
for phrase in [
|
|
235
|
+
"concurrent operations are not permitted",
|
|
236
|
+
"isce",
|
|
237
|
+
"provisioning a new connection",
|
|
238
|
+
]
|
|
239
|
+
), f"Unexpected error: {e}"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@pytest.mark.asyncio
|
|
243
|
+
async def test_solution_using_separate_db_contexts(app, db, SQLAlchemyMiddleware):
|
|
244
|
+
"""
|
|
245
|
+
Demonstrate the solution: Use separate db contexts for concurrent queries.
|
|
246
|
+
|
|
247
|
+
Instead of using asyncio.gather() on the same session, create separate
|
|
248
|
+
async functions that each use their own db context.
|
|
249
|
+
|
|
250
|
+
This is different from multi_sessions mode - here we're showing how to
|
|
251
|
+
structure the code to avoid the concurrent operations issue.
|
|
252
|
+
"""
|
|
253
|
+
app.add_middleware(SQLAlchemyMiddleware, db_url=db_url)
|
|
254
|
+
|
|
255
|
+
# Setup data in the main context
|
|
256
|
+
async with db(commit_on_exit=True):
|
|
257
|
+
await db.session.execute(
|
|
258
|
+
text("CREATE TABLE IF NOT EXISTS solution_test (id INTEGER PRIMARY KEY, name TEXT)")
|
|
259
|
+
)
|
|
260
|
+
for i in range(100):
|
|
261
|
+
await db.session.execute(
|
|
262
|
+
text("INSERT INTO solution_test (name) VALUES (:name)"),
|
|
263
|
+
{"name": f"item_{i}"},
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# Solution: Each function uses its own db context
|
|
267
|
+
async def get_total_count():
|
|
268
|
+
async with db():
|
|
269
|
+
result = await db.session.execute(text("SELECT COUNT(*) FROM solution_test"))
|
|
270
|
+
return result.scalar()
|
|
271
|
+
|
|
272
|
+
async def get_paginated_data(offset: int, limit: int):
|
|
273
|
+
async with db():
|
|
274
|
+
result = await db.session.execute(
|
|
275
|
+
text("SELECT * FROM solution_test LIMIT :limit OFFSET :offset"),
|
|
276
|
+
{"limit": limit, "offset": offset},
|
|
277
|
+
)
|
|
278
|
+
return result.fetchall()
|
|
279
|
+
|
|
280
|
+
# Now we can safely use asyncio.gather() because each function
|
|
281
|
+
# creates its own session
|
|
282
|
+
total, data = await asyncio.gather(
|
|
283
|
+
get_total_count(),
|
|
284
|
+
get_paginated_data(0, 10),
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
assert total == 100
|
|
288
|
+
assert len(data) == 10
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@pytest.mark.asyncio
|
|
292
|
+
async def test_antipattern_documentation(app, db, SQLAlchemyMiddleware):
|
|
293
|
+
"""
|
|
294
|
+
Document the antipattern that causes the issue.
|
|
295
|
+
|
|
296
|
+
This test exists purely for documentation purposes to show
|
|
297
|
+
what NOT to do and why.
|
|
298
|
+
"""
|
|
299
|
+
app.add_middleware(SQLAlchemyMiddleware, db_url=db_url)
|
|
300
|
+
|
|
301
|
+
async with db(commit_on_exit=True):
|
|
302
|
+
await db.session.execute(
|
|
303
|
+
text("CREATE TABLE IF NOT EXISTS antipattern (id INTEGER PRIMARY KEY, data TEXT)")
|
|
304
|
+
)
|
|
305
|
+
for i in range(50):
|
|
306
|
+
await db.session.execute(
|
|
307
|
+
text("INSERT INTO antipattern (data) VALUES (:data)"),
|
|
308
|
+
{"data": f"data_{i}"},
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# ANTIPATTERN: Using asyncio.gather() with the same session
|
|
312
|
+
# This is what the user was doing in their code
|
|
313
|
+
async with db():
|
|
314
|
+
count_stmt = text("SELECT COUNT(*) FROM antipattern")
|
|
315
|
+
data_stmt = text("SELECT * FROM antipattern LIMIT 10")
|
|
316
|
+
|
|
317
|
+
# This is the problematic pattern
|
|
318
|
+
# With PostgreSQL/MySQL this will raise:
|
|
319
|
+
# InvalidRequestError: This session is provisioning a new connection;
|
|
320
|
+
# concurrent operations are not permitted
|
|
321
|
+
try:
|
|
322
|
+
total_result, data_result = await asyncio.gather(
|
|
323
|
+
db.session.execute(count_stmt),
|
|
324
|
+
db.session.execute(data_stmt),
|
|
325
|
+
)
|
|
326
|
+
# SQLite allows this but it's still an antipattern
|
|
327
|
+
assert total_result.scalar() == 50
|
|
328
|
+
assert len(data_result.fetchall()) == 10
|
|
329
|
+
except Exception as e:
|
|
330
|
+
# Expected with PostgreSQL/MySQL
|
|
331
|
+
assert "concurrent" in str(e).lower() or "isce" in str(e).lower()
|
|
332
|
+
|
|
333
|
+
# RECOMMENDED PATTERN 1: Sequential execution
|
|
334
|
+
async with db():
|
|
335
|
+
count_result = await db.session.execute(text("SELECT COUNT(*) FROM antipattern"))
|
|
336
|
+
data_result = await db.session.execute(text("SELECT * FROM antipattern LIMIT 10"))
|
|
337
|
+
|
|
338
|
+
assert count_result.scalar() == 50
|
|
339
|
+
assert len(data_result.fetchall()) == 10
|
|
340
|
+
|
|
341
|
+
# RECOMMENDED PATTERN 2: Use multi_sessions mode
|
|
342
|
+
async with db(multi_sessions=True):
|
|
343
|
+
|
|
344
|
+
async def get_count():
|
|
345
|
+
return await db.session.execute(text("SELECT COUNT(*) FROM antipattern"))
|
|
346
|
+
|
|
347
|
+
async def get_data():
|
|
348
|
+
return await db.session.execute(text("SELECT * FROM antipattern LIMIT 10"))
|
|
349
|
+
|
|
350
|
+
count_result, data_result = await asyncio.gather(get_count(), get_data())
|
|
351
|
+
|
|
352
|
+
assert count_result.scalar() == 50
|
|
353
|
+
assert len(data_result.fetchall()) == 10
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@pytest.mark.asyncio
|
|
357
|
+
async def test_production_error_exact_reproduction(app, db, SQLAlchemyMiddleware):
|
|
358
|
+
"""
|
|
359
|
+
Exact reproduction of the production error from the traceback.
|
|
360
|
+
|
|
361
|
+
Production traceback shows:
|
|
362
|
+
- Using asyncpg (PostgreSQL driver)
|
|
363
|
+
- Using SQLModel AsyncSession
|
|
364
|
+
- Two queries executed concurrently with asyncio.gather()
|
|
365
|
+
- Error: sqlalchemy.exc.InvalidRequestError: This session is provisioning
|
|
366
|
+
a new connection; concurrent operations are not permitted
|
|
367
|
+
|
|
368
|
+
This test reproduces the exact pattern from:
|
|
369
|
+
/app/api/repository/routes.py:186 in get_processes
|
|
370
|
+
|
|
371
|
+
```python
|
|
372
|
+
total_result, processes_result = await asyncio.gather(
|
|
373
|
+
db.session.execute(count_stmt), db.session.execute(processes_stmt)
|
|
374
|
+
)
|
|
375
|
+
```
|
|
376
|
+
"""
|
|
377
|
+
app.add_middleware(SQLAlchemyMiddleware, db_url=db_url)
|
|
378
|
+
|
|
379
|
+
async with db(commit_on_exit=True):
|
|
380
|
+
# Setup similar to production
|
|
381
|
+
await db.session.execute(
|
|
382
|
+
text("""
|
|
383
|
+
CREATE TABLE IF NOT EXISTS processes (
|
|
384
|
+
id INTEGER PRIMARY KEY,
|
|
385
|
+
name TEXT NOT NULL,
|
|
386
|
+
status TEXT,
|
|
387
|
+
created_at TEXT
|
|
388
|
+
)
|
|
389
|
+
""")
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
# Insert test data
|
|
393
|
+
for i in range(100):
|
|
394
|
+
await db.session.execute(
|
|
395
|
+
text(
|
|
396
|
+
"INSERT INTO processes (name, status, created_at) "
|
|
397
|
+
"VALUES (:name, :status, :created_at)"
|
|
398
|
+
),
|
|
399
|
+
{
|
|
400
|
+
"name": f"process_{i}",
|
|
401
|
+
"status": "running" if i % 2 == 0 else "stopped",
|
|
402
|
+
"created_at": "2025-01-01T00:00:00",
|
|
403
|
+
},
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# Simulate the production route handler
|
|
407
|
+
async with db():
|
|
408
|
+
# This is the exact pattern from production code
|
|
409
|
+
count_stmt = text("SELECT COUNT(*) FROM processes WHERE status = :status")
|
|
410
|
+
processes_stmt = text(
|
|
411
|
+
"SELECT * FROM processes WHERE status = :status "
|
|
412
|
+
"ORDER BY created_at DESC LIMIT :limit OFFSET :offset"
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# Bind parameters
|
|
416
|
+
count_stmt = count_stmt.bindparams(status="running")
|
|
417
|
+
processes_stmt = processes_stmt.bindparams(status="running", limit=10, offset=0)
|
|
418
|
+
|
|
419
|
+
# This is the problematic line from production (line 186 in routes.py)
|
|
420
|
+
# With PostgreSQL + asyncpg, this WILL fail with:
|
|
421
|
+
# "This session is provisioning a new connection;
|
|
422
|
+
# concurrent operations are not permitted"
|
|
423
|
+
try:
|
|
424
|
+
total_result, processes_result = await asyncio.gather(
|
|
425
|
+
db.session.execute(count_stmt),
|
|
426
|
+
db.session.execute(processes_stmt),
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
# If SQLite allows it (serializes internally)
|
|
430
|
+
total = total_result.scalar()
|
|
431
|
+
processes = processes_result.fetchall()
|
|
432
|
+
|
|
433
|
+
assert total == 50 # Half of the processes are "running"
|
|
434
|
+
assert len(processes) == 10
|
|
435
|
+
print("SQLite allowed concurrent queries - but this WILL fail with PostgreSQL/asyncpg")
|
|
436
|
+
|
|
437
|
+
except Exception as e:
|
|
438
|
+
# Expected with PostgreSQL/MySQL
|
|
439
|
+
error_msg = str(e)
|
|
440
|
+
# Check for the exact error from production
|
|
441
|
+
assert (
|
|
442
|
+
"concurrent operations are not permitted" in error_msg.lower()
|
|
443
|
+
or "provisioning a new connection" in error_msg.lower()
|
|
444
|
+
or "isce" in error_msg.lower()
|
|
445
|
+
), f"Got unexpected error: {e}"
|
|
446
|
+
|
|
447
|
+
print(f"Successfully reproduced production error: {type(e).__name__}")
|
|
448
|
+
|
|
449
|
+
# Show the correct fix for this production issue
|
|
450
|
+
print("\n=== CORRECT FIX FOR PRODUCTION ===")
|
|
451
|
+
|
|
452
|
+
# Fix Option 1: Sequential execution (simplest)
|
|
453
|
+
async with db():
|
|
454
|
+
count_result = await db.session.execute(count_stmt)
|
|
455
|
+
processes_result = await db.session.execute(processes_stmt)
|
|
456
|
+
|
|
457
|
+
total = count_result.scalar()
|
|
458
|
+
processes = processes_result.fetchall()
|
|
459
|
+
|
|
460
|
+
assert total == 50
|
|
461
|
+
assert len(processes) == 10
|
|
462
|
+
print("Fix 1: Sequential execution works!")
|
|
463
|
+
|
|
464
|
+
# Fix Option 2: Use multi_sessions mode (for true parallelism)
|
|
465
|
+
async with db(multi_sessions=True):
|
|
466
|
+
|
|
467
|
+
async def get_count():
|
|
468
|
+
return await db.session.execute(count_stmt)
|
|
469
|
+
|
|
470
|
+
async def get_processes():
|
|
471
|
+
return await db.session.execute(processes_stmt)
|
|
472
|
+
|
|
473
|
+
total_result, processes_result = await asyncio.gather(get_count(), get_processes())
|
|
474
|
+
|
|
475
|
+
total = total_result.scalar()
|
|
476
|
+
processes = processes_result.fetchall()
|
|
477
|
+
|
|
478
|
+
assert total == 50
|
|
479
|
+
assert len(processes) == 10
|
|
480
|
+
print("Fix 2: multi_sessions mode works!")
|
|
@@ -170,10 +170,10 @@ async def test_edge_case_loop_closing_during_cleanup():
|
|
|
170
170
|
@pytest.mark.asyncio
|
|
171
171
|
async def test_current_task_none_with_mock():
|
|
172
172
|
"""
|
|
173
|
-
Test
|
|
173
|
+
Test behavior when current_task() returns None (sync context fallback).
|
|
174
174
|
|
|
175
|
-
|
|
176
|
-
|
|
175
|
+
After the backward compatibility fix, this now creates a session for sync context
|
|
176
|
+
instead of raising RuntimeError.
|
|
177
177
|
"""
|
|
178
178
|
|
|
179
179
|
app = FastAPI()
|
|
@@ -184,18 +184,18 @@ async def test_current_task_none_with_mock():
|
|
|
184
184
|
# Temporarily mock current_task to return None
|
|
185
185
|
with patch("asyncio.current_task", return_value=None):
|
|
186
186
|
async with db(multi_sessions=True):
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
raise
|
|
187
|
+
# After backward compatibility fix, this should work
|
|
188
|
+
# by falling back to sync context session
|
|
189
|
+
session = db.session
|
|
190
|
+
if session is not None:
|
|
191
|
+
return {"success": True, "has_session": True}
|
|
192
|
+
return {"error": "Session is None"}
|
|
194
193
|
|
|
195
194
|
client = TestClient(app)
|
|
196
195
|
response = client.get("/test_none_task")
|
|
197
196
|
assert response.status_code == 200
|
|
198
197
|
assert response.json()["success"] is True
|
|
198
|
+
assert response.json()["has_session"] is True
|
|
199
199
|
|
|
200
200
|
|
|
201
201
|
@pytest.mark.asyncio
|
|
@@ -212,19 +212,17 @@ async def test_multi_session_mode_context_vars():
|
|
|
212
212
|
|
|
213
213
|
@app.get("/test_context_vars")
|
|
214
214
|
async def test_context_vars():
|
|
215
|
-
# Before multi_sessions context
|
|
216
215
|
async with db(multi_sessions=True, commit_on_exit=True):
|
|
217
|
-
#
|
|
216
|
+
# Each db.session access creates a new session in multi_sessions mode
|
|
218
217
|
session1 = db.session
|
|
219
218
|
session2 = db.session
|
|
220
219
|
|
|
221
|
-
#
|
|
222
|
-
assert
|
|
220
|
+
# Different sessions are created for each access
|
|
221
|
+
assert session1 is not None
|
|
222
|
+
assert session2 is not None
|
|
223
223
|
|
|
224
224
|
return {"status": "ok"}
|
|
225
225
|
|
|
226
|
-
# After context exits, multi_sessions should be reset
|
|
227
|
-
|
|
228
226
|
client = TestClient(app)
|
|
229
227
|
response = client.get("/test_context_vars")
|
|
230
228
|
assert response.status_code == 200
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Tests to ensure single session mode works correctly without multi_sessions=True.
|
|
2
|
+
|
|
3
|
+
These tests verify that the original behavior is preserved:
|
|
4
|
+
- Sequential queries work with a single session
|
|
5
|
+
- The same session instance is reused within a context
|
|
6
|
+
- asyncio.gather() without multi_sessions=True raises an error (expected behavior)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from sqlalchemy import text
|
|
13
|
+
from sqlalchemy.exc import IllegalStateChangeError, InvalidRequestError
|
|
14
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
15
|
+
|
|
16
|
+
db_url = "sqlite+aiosqlite://"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.mark.asyncio
|
|
20
|
+
async def test_single_session_sequential_queries(app, db, SQLAlchemyMiddleware):
|
|
21
|
+
"""Sequential queries should work with single session."""
|
|
22
|
+
app.add_middleware(SQLAlchemyMiddleware, db_url=db_url)
|
|
23
|
+
|
|
24
|
+
async with db():
|
|
25
|
+
result1 = await db.session.execute(text("SELECT 1"))
|
|
26
|
+
result2 = await db.session.execute(text("SELECT 2"))
|
|
27
|
+
assert result1.scalar() == 1
|
|
28
|
+
assert result2.scalar() == 2
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.mark.asyncio
|
|
32
|
+
async def test_single_session_same_instance(app, db, SQLAlchemyMiddleware):
|
|
33
|
+
"""Same session instance should be returned within context."""
|
|
34
|
+
app.add_middleware(SQLAlchemyMiddleware, db_url=db_url)
|
|
35
|
+
|
|
36
|
+
async with db():
|
|
37
|
+
session1 = db.session
|
|
38
|
+
session2 = db.session
|
|
39
|
+
assert session1 is session2
|
|
40
|
+
assert isinstance(session1, AsyncSession)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.asyncio
|
|
44
|
+
async def test_single_session_gather_fails(app, db, SQLAlchemyMiddleware):
|
|
45
|
+
"""asyncio.gather() without multi_sessions=True should raise an error."""
|
|
46
|
+
app.add_middleware(SQLAlchemyMiddleware, db_url=db_url)
|
|
47
|
+
|
|
48
|
+
with pytest.raises((InvalidRequestError, IllegalStateChangeError)):
|
|
49
|
+
async with db():
|
|
50
|
+
await asyncio.gather(
|
|
51
|
+
db.session.execute(text("SELECT 1")),
|
|
52
|
+
db.session.execute(text("SELECT 2")),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.mark.asyncio
|
|
57
|
+
async def test_multi_sessions_gather_with_tasks(app, db, SQLAlchemyMiddleware):
|
|
58
|
+
"""asyncio.gather() with multi_sessions=True and create_task should work."""
|
|
59
|
+
app.add_middleware(SQLAlchemyMiddleware, db_url=db_url)
|
|
60
|
+
|
|
61
|
+
async with db(multi_sessions=True):
|
|
62
|
+
|
|
63
|
+
async def query(n):
|
|
64
|
+
return await db.session.execute(text(f"SELECT {n}"))
|
|
65
|
+
|
|
66
|
+
tasks = [
|
|
67
|
+
asyncio.create_task(query(1)),
|
|
68
|
+
asyncio.create_task(query(2)),
|
|
69
|
+
]
|
|
70
|
+
results = await asyncio.gather(*tasks)
|
|
71
|
+
assert results[0].scalar() == 1
|
|
72
|
+
assert results[1].scalar() == 2
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.asyncio
|
|
76
|
+
async def test_single_session_in_route(app, client, db, SQLAlchemyMiddleware):
|
|
77
|
+
"""Single session should work in route handler."""
|
|
78
|
+
app.add_middleware(SQLAlchemyMiddleware, db_url=db_url)
|
|
79
|
+
|
|
80
|
+
@app.get("/test")
|
|
81
|
+
async def test_route():
|
|
82
|
+
result = await db.session.execute(text("SELECT 42"))
|
|
83
|
+
return {"value": result.scalar()}
|
|
84
|
+
|
|
85
|
+
response = client.get("/test")
|
|
86
|
+
assert response.status_code == 200
|
|
87
|
+
assert response.json() == {"value": 42}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.mark.asyncio
|
|
91
|
+
async def test_single_session_multiple_sequential_in_route(app, client, db, SQLAlchemyMiddleware):
|
|
92
|
+
"""Multiple sequential queries in route should work."""
|
|
93
|
+
app.add_middleware(SQLAlchemyMiddleware, db_url=db_url)
|
|
94
|
+
|
|
95
|
+
@app.get("/test-sequential")
|
|
96
|
+
async def test_route():
|
|
97
|
+
r1 = await db.session.execute(text("SELECT 1"))
|
|
98
|
+
r2 = await db.session.execute(text("SELECT 2"))
|
|
99
|
+
r3 = await db.session.execute(text("SELECT 3"))
|
|
100
|
+
return {"values": [r1.scalar(), r2.scalar(), r3.scalar()]}
|
|
101
|
+
|
|
102
|
+
response = client.get("/test-sequential")
|
|
103
|
+
assert response.status_code == 200
|
|
104
|
+
assert response.json() == {"values": [1, 2, 3]}
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
from sqlalchemy import text
|
|
5
|
-
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
-
|
|
7
|
-
DB_URL = "sqlite+aiosqlite://"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@pytest.mark.parametrize("commit_on_exit", [True, False])
|
|
11
|
-
@pytest.mark.asyncio
|
|
12
|
-
async def test_multi_sessions_all_sessions_closed(app, SQLAlchemyMiddleware, db, commit_on_exit):
|
|
13
|
-
"""Ensure that every session created in multi_sessions mode is closed when context exits.
|
|
14
|
-
|
|
15
|
-
We monkeypatch the AsyncSession (and SQLModel's AsyncSession if present) to track:
|
|
16
|
-
- How many session instances are created
|
|
17
|
-
- Which of them had .close() invoked
|
|
18
|
-
Then we assert all created sessions were closed after the context manager exits.
|
|
19
|
-
"""
|
|
20
|
-
app.add_middleware(SQLAlchemyMiddleware, db_url=DB_URL, commit_on_exit=commit_on_exit)
|
|
21
|
-
|
|
22
|
-
created_sessions = []
|
|
23
|
-
closed_sessions = set()
|
|
24
|
-
|
|
25
|
-
# Collect target session classes (SQLAlchemy + optional SQLModel variant)
|
|
26
|
-
target_classes = []
|
|
27
|
-
target_classes.append(AsyncSession)
|
|
28
|
-
try:
|
|
29
|
-
from sqlmodel.ext.asyncio.session import (
|
|
30
|
-
AsyncSession as SQLModelAsyncSession, # type: ignore
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
target_classes.append(
|
|
34
|
-
SQLModelAsyncSession
|
|
35
|
-
) # pragma: no cover - depends on optional dependency
|
|
36
|
-
except Exception: # pragma: no cover - sqlmodel may not be installed
|
|
37
|
-
pass
|
|
38
|
-
|
|
39
|
-
# Preserve originals for restore
|
|
40
|
-
originals = {}
|
|
41
|
-
for cls in target_classes:
|
|
42
|
-
originals[(cls, "__init__")] = cls.__init__
|
|
43
|
-
originals[(cls, "close")] = cls.close
|
|
44
|
-
|
|
45
|
-
def make_init(original):
|
|
46
|
-
def _init(self, *args, **kwargs): # noqa: D401
|
|
47
|
-
created_sessions.append(self)
|
|
48
|
-
return original(self, *args, **kwargs)
|
|
49
|
-
|
|
50
|
-
return _init
|
|
51
|
-
|
|
52
|
-
async def make_close(original, self): # type: ignore
|
|
53
|
-
closed_sessions.add(self)
|
|
54
|
-
return await original(self)
|
|
55
|
-
|
|
56
|
-
# Assign patched methods
|
|
57
|
-
cls.__init__ = make_init(cls.__init__) # type: ignore
|
|
58
|
-
|
|
59
|
-
async def _close(self, __original=cls.close): # type: ignore
|
|
60
|
-
closed_sessions.add(self)
|
|
61
|
-
return await __original(self)
|
|
62
|
-
|
|
63
|
-
cls.close = _close # type: ignore
|
|
64
|
-
|
|
65
|
-
try:
|
|
66
|
-
async with db(multi_sessions=True, commit_on_exit=commit_on_exit):
|
|
67
|
-
|
|
68
|
-
async def worker():
|
|
69
|
-
# Access session multiple times in same task to create distinct sessions
|
|
70
|
-
s1 = db.session
|
|
71
|
-
s2 = db.session
|
|
72
|
-
# Execute trivial queries
|
|
73
|
-
await s1.execute(text("SELECT 1"))
|
|
74
|
-
await s2.execute(text("SELECT 1"))
|
|
75
|
-
|
|
76
|
-
tasks = [asyncio.create_task(worker()) for _ in range(5)]
|
|
77
|
-
await asyncio.gather(*tasks)
|
|
78
|
-
|
|
79
|
-
# After context exit all tracked sessions should be closed
|
|
80
|
-
assert created_sessions, "No sessions were created in multi_sessions test."
|
|
81
|
-
assert all(s in closed_sessions for s in created_sessions), (
|
|
82
|
-
"Not all sessions were closed. "
|
|
83
|
-
f"Created: {len(created_sessions)}, Closed: {len(closed_sessions)}"
|
|
84
|
-
)
|
|
85
|
-
finally:
|
|
86
|
-
# Restore original methods to avoid side effects on other tests
|
|
87
|
-
for cls in target_classes:
|
|
88
|
-
cls.__init__ = originals[(cls, "__init__")] # type: ignore
|
|
89
|
-
cls.close = originals[(cls, "close")] # type: ignore
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
from sqlalchemy import text
|
|
5
|
-
from sqlalchemy.pool import AsyncAdaptedQueuePool
|
|
6
|
-
|
|
7
|
-
from fastapi_async_sqlalchemy import create_middleware_and_session_proxy
|
|
8
|
-
|
|
9
|
-
"""
|
|
10
|
-
Goal: Ensure that session for each task is closed immediately after task completion
|
|
11
|
-
to prevent session accumulation and connection pool exhaustion.
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
# Create separate middleware for testing
|
|
15
|
-
TestSQLAlchemyMiddleware, test_db = create_middleware_and_session_proxy()
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
async def execute_query(query_id: int):
|
|
19
|
-
"""Execute query using session"""
|
|
20
|
-
result = await test_db.session.execute(text(f"SELECT {query_id} as id"))
|
|
21
|
-
# Simulate a long operation
|
|
22
|
-
await asyncio.sleep(0.5)
|
|
23
|
-
return result.fetchone()
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
@pytest.mark.asyncio
|
|
27
|
-
async def test_multisession_with_limited_pool():
|
|
28
|
-
"""Test: 20 coroutines with multisession=True with a pool of 5 connections"""
|
|
29
|
-
|
|
30
|
-
TestSQLAlchemyMiddleware(
|
|
31
|
-
app=None,
|
|
32
|
-
db_url="sqlite+aiosqlite:///test.db",
|
|
33
|
-
engine_args={
|
|
34
|
-
"poolclass": AsyncAdaptedQueuePool,
|
|
35
|
-
"pool_size": 5,
|
|
36
|
-
"max_overflow": 0,
|
|
37
|
-
"echo": False,
|
|
38
|
-
},
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
async with test_db(multi_sessions=True):
|
|
42
|
-
# Create 20 coroutines
|
|
43
|
-
tasks = [asyncio.create_task(execute_query(i)) for i in range(20)]
|
|
44
|
-
|
|
45
|
-
# Execute all tasks in parallel
|
|
46
|
-
results = await asyncio.gather(*tasks)
|
|
47
|
-
|
|
48
|
-
# Checks
|
|
49
|
-
assert len(results) == 20
|
|
50
|
-
assert all(result is not None for result in results)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
@pytest.mark.asyncio
|
|
54
|
-
async def test_different_tasks_get_different_sessions():
|
|
55
|
-
"""Test: different tasks get different sessions, same task gets same session"""
|
|
56
|
-
|
|
57
|
-
TestSQLAlchemyMiddleware(
|
|
58
|
-
app=None,
|
|
59
|
-
db_url="sqlite+aiosqlite:///:memory:",
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
session_ids = []
|
|
63
|
-
|
|
64
|
-
async with test_db(multi_sessions=True):
|
|
65
|
-
|
|
66
|
-
async def worker():
|
|
67
|
-
s1 = test_db.session
|
|
68
|
-
s2 = test_db.session
|
|
69
|
-
# Same task should get same session
|
|
70
|
-
assert id(s1) == id(s2), "Same task should get same session"
|
|
71
|
-
session_ids.append(id(s1))
|
|
72
|
-
await s1.execute(text("SELECT 1"))
|
|
73
|
-
|
|
74
|
-
tasks = [asyncio.create_task(worker()) for _ in range(5)]
|
|
75
|
-
await asyncio.gather(*tasks)
|
|
76
|
-
|
|
77
|
-
# Different tasks should get different sessions
|
|
78
|
-
assert len(set(session_ids)) == 5, "Different tasks should get different sessions"
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if __name__ == "__main__":
|
|
82
|
-
asyncio.run(test_multisession_with_limited_pool())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/pyproject.toml
RENAMED
|
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
|
{fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/tests/test_session.py
RENAMED
|
File without changes
|
{fastapi_async_sqlalchemy-0.7.1.post2 → fastapi_async_sqlalchemy-0.7.1.post3}/tests/test_sqlmodel.py
RENAMED
|
File without changes
|
|
File without changes
|