diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 14ac7350..1f96cc10 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -149,6 +149,20 @@ async def clean_tables(): yield +# ── Function-scoped cleanup: 每个测试后清空模块级 engine 连接池 ── +# pytest-asyncio 为每个测试创建新事件循环,而 app.core.database.engine 是 +# 模块级单例:池化的 asyncpg 连接会跨循环存活,下一个测试(或其后台任务) +# 复用旧循环连接即报 "Future attached to a different loop",且症状是此后 +# 所有 PG 测试连环 mass-E(#97)。dispose 清空连接池,让下次 checkout 在 +# 当前循环新建连接;测试 PG 在本机,重连成本可忽略。 +@pytest_asyncio.fixture(autouse=True) +async def dispose_module_engine_connections(): + yield + from app.core.database import engine + + await engine.dispose() + + @pytest_asyncio.fixture(autouse=True) async def reset_llm_provider_state(): """每个测试前重置 LLM provider 模块级 state + logging 配置。 diff --git a/backend/tests/test_module_engine_loop_isolation.py b/backend/tests/test_module_engine_loop_isolation.py new file mode 100644 index 00000000..62c511c2 --- /dev/null +++ b/backend/tests/test_module_engine_loop_isolation.py @@ -0,0 +1,35 @@ +"""模块级 engine 跨事件循环隔离的回归测试(#97)。 + +pytest-asyncio 为每个测试创建新事件循环;``app.core.database.engine`` 是 +模块级单例,其连接池若把上一个循环创建的 asyncpg 连接留给下一个测试复用, +后者一执行真实 SQL 就报 "Future ... attached to a different loop",并引发 +后续 PG 测试连环 mass-E。conftest 的 ``dispose_module_engine_connections`` +fixture 在每个测试后清空连接池。 + +下面两个**顺序敏感**的测试各自从模块 engine checkout 并执行真实 SQL: +若跨循环隔离回归(比如 fixture 被移除),第二个测试必然失败——这就是本 +缺陷的机器化回归锚点。请保持它们为两个独立测试函数、文件内字母序执行。 +""" + +from __future__ import annotations + +import pytest +from sqlalchemy import text + +from app.core.database import async_session + + +@pytest.mark.asyncio +async def test_loop_a_checkout_and_release(): + """第一个循环:从模块 engine 拿连接、执行、归还池中。""" + async with async_session() as db: + val = (await db.execute(text("SELECT 41 + 1"))).scalar() + assert val == 42 + + +@pytest.mark.asyncio +async def test_loop_b_checkout_after_previous_loop(): + """第二个循环:若复用上一循环留在池里的连接即报 different loop。""" + async with async_session() as db: + val = (await db.execute(text("SELECT 43 - 1"))).scalar() + assert val == 42