Files
MoviePilot/tests/test_db_session_lifecycle.py

218 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
数据库会话生命周期与资源释放测试。
会话生成器FastAPI 依赖注入入口必须在请求结束时归还连接close_database 必须
释放全部引擎——池化之后引擎不再只有一个:除全局同步/异步引擎外,还有按事件循环
缓存的池化引擎,漏掉任何一类都是连接泄漏。
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
from app.runtime.config import global_vars, settings
from app.db import engine as engine_module
from app.db import session as session_module
@pytest.fixture(autouse=True)
def _restore_pooled_engines():
"""
复原按循环缓存的引擎,避免用例间相互污染。
"""
saved = dict(session_module._pooled_async_engines)
yield
session_module._pooled_async_engines.clear()
session_module._pooled_async_engines.update(saved)
def test_get_db_closes_session_on_exit(monkeypatch):
"""
同步会话生成器必须在迭代结束后关闭会话,否则连接不会归还连接池。
"""
closed = []
fake = MagicMock()
fake.close = lambda: closed.append(1)
monkeypatch.setattr(session_module, "SessionFactory", lambda: fake)
gen = session_module.get_db()
assert next(gen) is fake
with pytest.raises(StopIteration):
next(gen)
assert closed, "生成器正常结束时未关闭会话"
def test_get_db_closes_session_even_on_error(monkeypatch):
"""
调用方提前中止时同样要归还会话——否则一次请求失败就泄漏一条连接。
"""
closed = []
fake = MagicMock()
fake.close = lambda: closed.append(1)
monkeypatch.setattr(session_module, "SessionFactory", lambda: fake)
gen = session_module.get_db()
next(gen)
gen.close()
assert closed, "生成器被中止时未关闭会话"
def test_get_async_db_yields_session_from_scope(monkeypatch):
"""
异步会话入口必须经 async_session_scope 获取——池化与配额都在那里收口,
绕过它会同时失去连接复用和背压。
"""
used = []
class _Scope:
"""会话作用域替身,记录进入与退出。"""
async def __aenter__(self):
used.append("enter")
return "SESSION"
async def __aexit__(self, *_exc):
used.append("exit")
return False
monkeypatch.setattr(session_module, "async_session_scope", lambda: _Scope())
async def run():
gen = session_module.get_async_db()
got = await gen.__anext__()
with pytest.raises(StopAsyncIteration):
await gen.__anext__()
return got
assert asyncio.run(run()) == "SESSION"
assert used == ["enter", "exit"], "会话作用域未正确进入/退出"
def test_close_database_disposes_pooled_engines(monkeypatch):
"""
close_database 必须释放按事件循环缓存的池化引擎。
池化之后引擎不再只有全局那一个,漏掉缓存中的引擎意味着进程退出时
仍持有未归还的物理连接。
"""
sync_engine = MagicMock()
async_engine = MagicMock(dispose=AsyncMock())
pooled_a = MagicMock(dispose=AsyncMock())
pooled_b = MagicMock(dispose=AsyncMock())
monkeypatch.setattr(engine_module, "_sync_engine", sync_engine)
monkeypatch.setattr(engine_module, "_async_engine", async_engine)
session_module._pooled_async_engines.clear()
session_module._pooled_async_engines.update({1: pooled_a, 2: pooled_b})
asyncio.run(session_module.close_database())
sync_engine.dispose.assert_called_once()
async_engine.dispose.assert_awaited_once()
pooled_a.dispose.assert_awaited_once()
pooled_b.dispose.assert_awaited_once()
assert not session_module._pooled_async_engines, "释放后未清空缓存"
def test_close_database_does_not_create_engines_to_dispose_them(monkeypatch):
"""
两个引擎槽都是空的时候close_database 不得为了 dispose 而把引擎创建出来。
这是惰性化的直接后果,也是最容易在重构中丢掉的一条:写成 `Engine.dispose()`
同样能跑通上面那几个用例——它们都把 MagicMock 塞进了引擎槽,`is not None` 恒真,
于是「先创建再释放」和「有才释放」在测试里完全等价。因此必须单独用空槽压一次:
否则一个从未用过数据库的进程会在关停时凭空连一次库,只为了随后释放它。
"""
created = []
monkeypatch.setattr(engine_module, "_sync_engine", None)
monkeypatch.setattr(engine_module, "_async_engine", None)
monkeypatch.setattr(engine_module, "_get_database_engine",
lambda **kw: created.append(kw) or MagicMock(dispose=AsyncMock()))
session_module._pooled_async_engines.clear()
asyncio.run(session_module.close_database())
assert created == [], f"close_database 为了 dispose 创建了引擎:{created}"
assert engine_module._sync_engine is None, "同步引擎槽被 close_database 填上了"
assert engine_module._async_engine is None, "异步引擎槽被 close_database 填上了"
def test_close_database_continues_after_single_engine_failure(monkeypatch):
"""
单个引擎释放失败不能中断其余引擎的释放,否则一个坏连接会让其他连接全部泄漏。
"""
failing = MagicMock(dispose=AsyncMock(side_effect=RuntimeError("connection reset")))
healthy = MagicMock(dispose=AsyncMock())
monkeypatch.setattr(engine_module, "_sync_engine", MagicMock())
monkeypatch.setattr(engine_module, "_async_engine", MagicMock(dispose=AsyncMock()))
session_module._pooled_async_engines.clear()
session_module._pooled_async_engines.update({1: failing, 2: healthy})
asyncio.run(session_module.close_database())
healthy.dispose.assert_awaited_once()
@pytest.mark.parametrize("failing", ["sync", "async"])
def test_close_database_releases_remaining_engines_after_global_failure(monkeypatch, failing):
"""
全局引擎释放失败:既不能抛出,也不能连累后面的引擎。
「不抛」是因为 close_database 在关闭流程末尾调用,抛异常会掩盖其他关闭步骤的问题。
但只断言「不抛」是不够的——在外面套一个大 try 同样不抛,代价是同步引擎一出错,
异步引擎和全部池化引擎就都跳过了释放:一条坏连接拖着其余连接一起泄漏,
而这恰恰是兄弟用例 test_close_database_continues_after_single_engine_failure
的 docstring 已经声称过的不变量。所以这里把它真正钉住:坏的那个失败,其余照常释放。
"""
sync_engine = MagicMock()
async_engine = MagicMock(dispose=AsyncMock())
pooled = MagicMock(dispose=AsyncMock())
if failing == "sync":
sync_engine.dispose.side_effect = RuntimeError("boom")
else:
async_engine.dispose.side_effect = RuntimeError("boom")
monkeypatch.setattr(engine_module, "_sync_engine", sync_engine)
monkeypatch.setattr(engine_module, "_async_engine", async_engine)
session_module._pooled_async_engines.clear()
session_module._pooled_async_engines.update({1: pooled})
asyncio.run(session_module.close_database()) # 不抛异常
# 出错的那个也得真被尝试过,排在它后面的一个都不能少
sync_engine.dispose.assert_called_once()
async_engine.dispose.assert_awaited_once()
pooled.dispose.assert_awaited_once()
assert not session_module._pooled_async_engines, "释放后未清空缓存"
def test_pooled_engine_is_reused_within_same_loop(monkeypatch):
"""
同一事件循环内必须复用同一个池化引擎实例。
每次新建引擎等于每次新建一个连接池,连接无法复用,池化就退化回了 NullPool
的行为——只是多了一层包装。
"""
monkeypatch.setattr(settings, "DB_ASYNC_POOL_TYPE", "QueuePool", raising=False)
created = []
monkeypatch.setattr(session_module, "_get_database_engine",
lambda **kw: created.append(kw) or MagicMock())
async def run():
global_vars.CURRENT_EVENT_LOOP = asyncio.get_running_loop()
session_module._pooled_async_engines.clear()
first = session_module.get_async_engine()
second = session_module.get_async_engine()
return first is second
saved = global_vars.CURRENT_EVENT_LOOP
try:
assert asyncio.run(run()) is True
assert len(created) == 1, f"引擎被重复创建 {len(created)}"
assert created[0]["pooled"] is True
finally:
global_vars.CURRENT_EVENT_LOOP = saved