refactor(db): 修复异步连接池无界增长,并完成 SQLAlchemy 2.0 迁移与分层归位 (#6320)

This commit is contained in:
Aqr-K
2026-08-15 06:58:38 +08:00
committed by GitHub
parent e28de9cfe1
commit 8a11214a43
252 changed files with 11405 additions and 2889 deletions
+188
View File
@@ -0,0 +1,188 @@
"""
异步数据库连接池的按事件循环池化测试。
NullPool 下每个异步会话独占一条物理连接、零复用且无上限:调度器以上百个线程向
主事件循环投递协程,突发并发会直接顶穿 PostgreSQL 的 max_connectionsSQLite 侧
则表现为 WAL 写争用与反复 checkpoint 导致的长时间卡顿。
NullPool 被选用的唯一理由是「永不复用」从而「永不跨事件循环」——asyncpg 的
Connection 与 aiosqlite 的线程都绑定在创建它的循环上。因此池化必须严格按循环
隔离:常驻主循环用池,其余循环回退 NullPool。
这些测试固定三项不变量:只有常驻主循环被池化、池化引擎按循环隔离且可复用、
回退路径受全局配额约束。
"""
import asyncio
import threading
import pytest
# 池化实现位于 app.db.sessionapp.db 只做 re-export,私有符号不在其上
import app.db.session as db_module
from app.runtime.config import global_vars, settings
from app.db.engine import _async_pool_kwargs, get_global_async_engine
# 用 getter 而不是旧名字 AsyncEngine:后者只为仓库外插件保留,模块级导入它会在 pytest
# 的**收集期**就把全局异步引擎建出来——用例还一个没跑,引擎已经在了。getter 是同一个
# 单例,下面那几处 `is` 断言的语义分毫不差。
@pytest.fixture(autouse=True)
def _restore_state():
"""
每个用例后复原全局状态,避免污染其他测试。
"""
saved_loop = global_vars.CURRENT_EVENT_LOOP
saved_engines = dict(db_module._pooled_async_engines)
yield
global_vars.CURRENT_EVENT_LOOP = saved_loop
db_module._pooled_async_engines.clear()
db_module._pooled_async_engines.update(saved_engines)
def test_pool_disabled_falls_back_to_nullpool(monkeypatch):
"""
配置为 NullPool 时必须完全回到池化前的行为,作为兼容性逃生舱。
"""
monkeypatch.setattr(settings, "DB_ASYNC_POOL_TYPE", "NullPool", raising=False)
async def run():
global_vars.CURRENT_EVENT_LOOP = asyncio.get_running_loop()
return db_module.get_async_engine()
assert asyncio.run(run()) is get_global_async_engine()
def test_non_resident_loop_is_not_pooled():
"""
非常驻循环必须回退 NullPool。
池中连接绑定在创建它的循环上,临时循环销毁后连接即失效;若对其池化,
下次复用会抛 "attached to a different loop"
"""
async def run():
# 当前运行的循环不是注册的常驻循环
global_vars.CURRENT_EVENT_LOOP = None
return db_module.get_async_engine()
assert asyncio.run(run()) is get_global_async_engine()
def test_no_running_loop_falls_back():
"""
没有运行中的事件循环时不得池化,行为与池化前一致。
"""
assert db_module.get_async_engine() is get_global_async_engine()
def test_pooled_loop_gets_dedicated_engine_and_reuses_it(monkeypatch):
"""
常驻主循环应获得独立的池化引擎,且同一循环内必须复用同一个引擎实例
——每次新建引擎等于每次新建一个池,池化就失去了意义。
"""
monkeypatch.setattr(settings, "DB_ASYNC_POOL_TYPE", "QueuePool", raising=False)
async def run():
global_vars.CURRENT_EVENT_LOOP = asyncio.get_running_loop()
db_module._pooled_async_engines.clear()
first = db_module.get_async_engine()
second = db_module.get_async_engine()
return first, second
first, second = asyncio.run(run())
assert first is not get_global_async_engine(), "常驻循环没有拿到池化引擎"
assert first is second, "同一循环重复创建了引擎,池被反复丢弃"
def test_engine_is_isolated_per_loop(monkeypatch):
"""
不同事件循环必须拿到不同的引擎实例,绝不能共用一个池。
"""
monkeypatch.setattr(settings, "DB_ASYNC_POOL_TYPE", "QueuePool", raising=False)
db_module._pooled_async_engines.clear()
engines = []
def run_in_own_loop():
"""
在独立线程的独立事件循环中取一次引擎。
"""
async def inner():
global_vars.CURRENT_EVENT_LOOP = asyncio.get_running_loop()
engines.append(db_module.get_async_engine())
asyncio.run(inner())
for _ in range(2):
thread = threading.Thread(target=run_in_own_loop)
thread.start()
thread.join()
assert len(engines) == 2
assert engines[0] is not engines[1], "两个事件循环共用了同一个连接池"
def test_pool_kwargs_shape():
"""
池化时不得指定 poolclassSQLAlchemy 需要自行选用异步适配的
AsyncAdaptedQueuePool,显式传入同步 QueuePool 会出错。
"""
pooled = _async_pool_kwargs(True)
assert "poolclass" not in pooled
assert pooled["pool_size"] == settings.DB_ASYNC_POOL_SIZE
assert pooled["max_overflow"] == settings.DB_ASYNC_MAX_OVERFLOW
fallback = _async_pool_kwargs(False)
assert fallback["poolclass"].__name__ == "NullPool"
def test_fallback_slot_is_released(monkeypatch):
"""
回退路径的配额必须在会话结束后归还,否则连续调用会把自己饿死。
"""
monkeypatch.setattr(settings, "DB_ASYNC_POOL_TYPE", "NullPool", raising=False)
async def run():
before = db_module._fallback_slots._value
for _ in range(3):
async with db_module.async_session_scope():
pass
return before, db_module._fallback_slots._value
before, after = asyncio.run(run())
assert before == after, "配额未归还,回退路径会逐步耗尽"
def test_fallback_slot_times_out_when_exhausted(monkeypatch):
"""
配额耗尽时必须抛出明确错误,而不是无限等待或静默失败
——这正是 NullPool 缺失的背压。
"""
monkeypatch.setattr(settings, "DB_POOL_TIMEOUT", 0.05, raising=False)
monkeypatch.setattr(settings, "DB_ASYNC_FALLBACK_LIMIT", 1, raising=False)
monkeypatch.setattr(db_module, "_fallback_slots", threading.BoundedSemaphore(1))
async def run():
db_module._fallback_slots.acquire() # 占满唯一名额
with pytest.raises(TimeoutError):
await db_module._acquire_fallback_slot()
asyncio.run(run())
def test_pooled_path_does_not_consume_fallback_quota(monkeypatch):
"""
池化路径由连接池自身限流,不应再占用回退配额
——否则主循环流量会把兜底名额吃光,临时循环反而被饿死。
"""
monkeypatch.setattr(settings, "DB_ASYNC_POOL_TYPE", "QueuePool", raising=False)
async def run():
global_vars.CURRENT_EVENT_LOOP = asyncio.get_running_loop()
db_module._pooled_async_engines.clear()
before = db_module._fallback_slots._value
async with db_module.async_session_scope():
during = db_module._fallback_slots._value
return before, during
before, during = asyncio.run(run())
assert before == during, "池化路径不应消耗回退配额"