Files
MoviePilot/tests/test_db_engine_postgresql.py

350 lines
15 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.
"""
PostgreSQL 引擎构建与连接额度校验测试。
生产故障发生在 PostgreSQL 环境(一次 60 站点搜索触发 74 次
TooManyConnectionsError但本地开发与 CI 都跑 SQLite——PG 分支此前零执行,
额度校验这类「只在 PG 下生效」的逻辑完全没有测试兜底。
这里用 mock 覆盖 PG 路径,不依赖真实 PostgreSQL 实例:额度核算是纯计算,
校验逻辑只需要伪造 SHOW 查询的返回值。
"""
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from app.runtime.config import settings
from app.db import engine as engine_module
from app.db.engine import connection_budget
def _fake_pg_connection(max_connections: int, reserved: int) -> MagicMock:
"""
伪造一个 PostgreSQL 连接,按顺序返回两条 SHOW 查询的结果。
:param max_connections: max_connections 的返回值
:param reserved: superuser_reserved_connections 的返回值
"""
conn = MagicMock()
conn.execute.side_effect = [
MagicMock(scalar=MagicMock(return_value=str(max_connections))),
MagicMock(scalar=MagicMock(return_value=str(reserved))),
]
ctx = MagicMock()
ctx.__enter__ = MagicMock(return_value=conn)
ctx.__exit__ = MagicMock(return_value=False)
return ctx
def _patch_engine(monkeypatch, connect) -> None:
"""
把额度校验取到的同步引擎换成只带 connect 的替身。
额度校验只用引擎做一件事connect() 出来跑两条 SHOW。打桩 get_engine() 而不是
在真引擎上改 connect——后者会为了一次本可全 mock 的校验真的把引擎建出来、连库、
设 WAL正是引擎惰性化要消掉的那种 import/取值副作用。
:param monkeypatch: pytest 的 monkeypatch 夹具
:param connect: 替身引擎的 connect 实现
"""
monkeypatch.setattr(engine_module, "get_engine",
lambda: SimpleNamespace(connect=connect))
# --------------------------------------------------------------------------- #
# 额度核算
# --------------------------------------------------------------------------- #
def test_budget_sums_all_connection_sources(monkeypatch):
"""
理论峰值必须涵盖全部连接来源。
各连接池此前彼此独立配置、没有任何地方核算总和——异步侧从无界收敛到有界后,
决定安全与否的就变成了这个总数。漏算任何一项都会让校验失去意义。
"""
monkeypatch.setattr(settings, "DB_TYPE", "postgresql", raising=False)
monkeypatch.setattr(settings, "DB_POSTGRESQL_POOL_SIZE", 10, raising=False)
monkeypatch.setattr(settings, "DB_POSTGRESQL_MAX_OVERFLOW", 50, raising=False)
monkeypatch.setattr(settings, "DB_POOL_TYPE", "QueuePool", raising=False)
monkeypatch.setattr(settings, "DB_ASYNC_POOL_TYPE", "QueuePool", raising=False)
monkeypatch.setattr(settings, "DB_ASYNC_POOL_SIZE", 5, raising=False)
monkeypatch.setattr(settings, "DB_ASYNC_MAX_OVERFLOW", 10, raising=False)
monkeypatch.setattr(settings, "DB_ASYNC_FALLBACK_LIMIT", 10, raising=False)
budget = engine_module.connection_budget()
assert budget["sync"] == 60
assert budget["async_pooled"] == 15
assert budget["async_fallback"] == 10
assert budget["total"] == 85
def test_budget_counts_nullpool_async_as_scheduler_sized(monkeypatch):
"""
异步侧配成 NullPool 时不存在池上限,此时用调度器线程数作为峰值估计
——这正是缺陷未修复前的真实状况,额度核算必须如实反映而不是记为 0。
"""
monkeypatch.setattr(settings, "DB_TYPE", "postgresql", raising=False)
monkeypatch.setattr(settings, "DB_ASYNC_POOL_TYPE", "NullPool", raising=False)
budget = engine_module.connection_budget()
assert budget["async_pooled"] == 0, "NullPool 没有池,不应计入池上限"
assert budget["async_fallback"] == settings.CONF.scheduler
def test_budget_uses_sqlite_pool_for_sqlite(monkeypatch):
"""
SQLite 后端应取 SQLite 的池配置,而不是 PostgreSQL 的。
"""
monkeypatch.setattr(settings, "DB_TYPE", "sqlite", raising=False)
monkeypatch.setattr(settings, "DB_SQLITE_POOL_SIZE", 3, raising=False)
monkeypatch.setattr(settings, "DB_SQLITE_MAX_OVERFLOW", 4, raising=False)
monkeypatch.setattr(settings, "DB_POOL_TYPE", "QueuePool", raising=False)
assert engine_module.connection_budget()["sync"] == 7
# --------------------------------------------------------------------------- #
# 额度校验PostgreSQL 路径)
# --------------------------------------------------------------------------- #
def test_check_passes_when_within_available(monkeypatch):
"""
峰值在数据库可用额度之内时应通过。
"""
monkeypatch.setattr(settings, "DB_TYPE", "postgresql", raising=False)
monkeypatch.setattr(engine_module, "connection_budget",
lambda: {"sync": 60, "async_pooled": 15, "async_fallback": 10,
"per_worker": 85, "workers": 1, "total": 85})
_patch_engine(monkeypatch, lambda *_a, **_kw: _fake_pg_connection(100, 3))
assert engine_module.check_connection_budget() is True
def test_check_fails_when_exceeding_available(monkeypatch):
"""
峰值超出可用额度时必须返回 False 并报错。
这是本校验存在的全部意义:把「突发并发时才以 TooManyConnectionsError 暴露」
的配置问题,前移到启动期就能看见。
"""
monkeypatch.setattr(settings, "DB_TYPE", "postgresql", raising=False)
monkeypatch.setattr(engine_module, "connection_budget",
lambda: {"sync": 60, "async_pooled": 40, "async_fallback": 30,
"per_worker": 130, "workers": 1, "total": 130})
_patch_engine(monkeypatch, lambda *_a, **_kw: _fake_pg_connection(100, 3))
errors = []
monkeypatch.setattr(engine_module.logger, "error", errors.append)
assert engine_module.check_connection_budget() is False
assert errors, "超额时必须留下错误日志"
assert "额度不足" in errors[0]
# 报错必须指出可调的参数,否则用户不知道该改什么
assert "MAX_OVERFLOW" in errors[0]
def test_check_uses_real_max_connections_not_assumption(monkeypatch):
"""
必须读取数据库的真实 max_connections而不是假定 100
——部署方很可能已经调过它,用猜测值会得出相反的结论。
"""
monkeypatch.setattr(settings, "DB_TYPE", "postgresql", raising=False)
monkeypatch.setattr(engine_module, "connection_budget",
lambda: {"sync": 200, "async_pooled": 15, "async_fallback": 10,
"per_worker": 225, "workers": 1, "total": 225})
# 数据库已调大到 500225 应当通过
_patch_engine(monkeypatch, lambda *_a, **_kw: _fake_pg_connection(500, 3))
assert engine_module.check_connection_budget() is True
def test_check_tolerates_unreadable_limits(monkeypatch):
"""
读取上限失败(权限不足、连接异常)不能阻断启动,只记告警。
"""
monkeypatch.setattr(settings, "DB_TYPE", "postgresql", raising=False)
def boom(*_args, **_kwargs):
"""
模拟无权执行 SHOW。
"""
raise RuntimeError("permission denied for SHOW")
_patch_engine(monkeypatch, boom)
warnings = []
monkeypatch.setattr(engine_module.logger, "warn", warnings.append)
assert engine_module.check_connection_budget() is True
assert warnings
def test_check_skips_query_for_sqlite(monkeypatch):
"""
SQLite 没有服务端连接上限,不应执行任何 SHOW 查询。
"""
monkeypatch.setattr(settings, "DB_TYPE", "sqlite", raising=False)
called = []
_patch_engine(monkeypatch, lambda *_a, **_kw: called.append(1))
assert engine_module.check_connection_budget() is True
assert not called, "SQLite 不应连接数据库查询上限"
# --------------------------------------------------------------------------- #
# PostgreSQL 引擎构建
# --------------------------------------------------------------------------- #
def test_pg_sync_engine_applies_pool_settings(monkeypatch):
"""
同步 PG 引擎应带上 QueuePool 的尺寸参数。
"""
monkeypatch.setattr(settings, "DB_POOL_TYPE", "QueuePool", raising=False)
monkeypatch.setattr(settings, "DB_POSTGRESQL_POOL_SIZE", 7, raising=False)
monkeypatch.setattr(settings, "DB_POSTGRESQL_MAX_OVERFLOW", 9, raising=False)
captured = {}
monkeypatch.setattr(engine_module, "create_engine",
lambda **kw: captured.update(kw) or MagicMock())
monkeypatch.setattr(engine_module, "_register_database_error_logging", lambda *_a: None)
engine_module._get_postgresql_engine(is_async=False)
assert captured["pool_size"] == 7
assert captured["max_overflow"] == 9
assert captured["url"].startswith("postgresql")
def test_pg_async_engine_pooled_omits_poolclass(monkeypatch):
"""
池化的异步引擎不得指定 poolclassSQLAlchemy 需自行选用异步适配的
AsyncAdaptedQueuePool传入同步 QueuePool 会出错。
"""
monkeypatch.setattr(settings, "DB_ASYNC_POOL_SIZE", 5, raising=False)
monkeypatch.setattr(settings, "DB_ASYNC_MAX_OVERFLOW", 10, raising=False)
captured = {}
monkeypatch.setattr(engine_module, "create_async_engine",
lambda **kw: captured.update(kw) or MagicMock(sync_engine=MagicMock()))
monkeypatch.setattr(engine_module, "_register_database_error_logging", lambda *_a: None)
engine_module._get_postgresql_engine(is_async=True, pooled=True)
assert "poolclass" not in captured
assert captured["pool_size"] == 5
assert "asyncpg" in captured["url"]
def test_pg_async_engine_unpooled_uses_nullpool(monkeypatch):
"""
未池化的异步引擎必须用 NullPool保持跨事件循环的安全性。
"""
captured = {}
monkeypatch.setattr(engine_module, "create_async_engine",
lambda **kw: captured.update(kw) or MagicMock(sync_engine=MagicMock()))
monkeypatch.setattr(engine_module, "_register_database_error_logging", lambda *_a: None)
engine_module._get_postgresql_engine(is_async=True, pooled=False)
assert captured["poolclass"].__name__ == "NullPool"
def test_pg_engine_injects_connect_args(monkeypatch):
"""
驱动级参数必须能注入——经 PgBouncer 事务模式接入时 asyncpg 需要
statement_cache_size=0此前无法配置导致连纯运维手段都用不了。
"""
monkeypatch.setattr(settings, "DB_CONNECT_ARGS", {"statement_cache_size": 0}, raising=False)
captured = {}
monkeypatch.setattr(engine_module, "create_async_engine",
lambda **kw: captured.update(kw) or MagicMock(sync_engine=MagicMock()))
monkeypatch.setattr(engine_module, "_register_database_error_logging", lambda *_a: None)
engine_module._get_postgresql_engine(is_async=True, pooled=False)
assert captured["connect_args"]["statement_cache_size"] == 0
# --------------------------------------------------------------------------- #
# 多 worker 下的额度核算
# --------------------------------------------------------------------------- #
def test_budget_reports_per_worker_and_total(monkeypatch):
"""
连接池是进程级的,多 worker 下每个进程各持一份。
核算必须同时给出「单进程」与「全部 worker 合计」——只报单进程会让多 worker
部署在启动校验里一路绿灯,实际第一个 worker 还没起完就顶穿 max_connections。
"""
monkeypatch.setattr(settings, "DB_TYPE", "postgresql", raising=False)
monkeypatch.setattr(settings, "DB_POSTGRESQL_POOL_SIZE", 10, raising=False)
monkeypatch.setattr(settings, "DB_POSTGRESQL_MAX_OVERFLOW", 50, raising=False)
monkeypatch.setattr(settings, "DB_POOL_TYPE", "QueuePool", raising=False)
monkeypatch.setattr(settings, "DB_ASYNC_POOL_TYPE", "QueuePool", raising=False)
monkeypatch.setattr(settings, "DB_ASYNC_POOL_SIZE", 5, raising=False)
monkeypatch.setattr(settings, "DB_ASYNC_MAX_OVERFLOW", 10, raising=False)
monkeypatch.setattr(settings, "DB_ASYNC_FALLBACK_LIMIT", 10, raising=False)
monkeypatch.setattr(settings, "API_WORKERS", 4, raising=False)
budget = connection_budget()
assert budget["per_worker"] == 85
assert budget["workers"] == 4
assert budget["total"] == 340
def test_budget_single_worker_keeps_total_equal_to_per_worker(monkeypatch):
"""
单 worker 时合计等于单进程用量,与引入 worker 概念之前的口径一致。
"""
monkeypatch.setattr(settings, "DB_TYPE", "postgresql", raising=False)
monkeypatch.setattr(settings, "API_WORKERS", 1, raising=False)
budget = connection_budget()
assert budget["total"] == budget["per_worker"]
@pytest.mark.parametrize("workers", [0, -3, None])
def test_budget_treats_invalid_worker_count_as_one(monkeypatch, workers):
"""
worker 数非法时按 1 计,不能让核算退化成 0 而误判「额度充足」。
"""
monkeypatch.setattr(settings, "DB_TYPE", "postgresql", raising=False)
monkeypatch.setattr(settings, "API_WORKERS", workers, raising=False)
budget = connection_budget()
assert budget["workers"] == 1
assert budget["total"] == budget["per_worker"]
def test_check_fails_when_workers_multiply_past_the_limit(monkeypatch):
"""
单进程用量在额度内、但乘上 worker 数后超限时必须报错。
这正是盲区所在85 条对 max_connections=100 是安全的17 个 worker 的 1445 条
则毫无胜算,而此前的校验对后者一路放行。
"""
monkeypatch.setattr(settings, "DB_TYPE", "postgresql", raising=False)
monkeypatch.setattr(engine_module, "connection_budget",
lambda: {"sync": 60, "async_pooled": 15, "async_fallback": 10,
"per_worker": 85, "workers": 4, "total": 340})
_patch_engine(monkeypatch, lambda *_a, **_kw: _fake_pg_connection(100, 3))
errors = []
monkeypatch.setattr(engine_module.logger, "error", errors.append)
assert engine_module.check_connection_budget() is False
assert errors and "额度不足" in errors[0]
# 报错必须点出 worker 数,否则用户看到 340 会以为是池配置写错了
assert "worker" in errors[0].lower()
def test_check_passes_when_workers_stay_within_the_limit(monkeypatch):
"""
乘上 worker 数后仍在额度内时通过。
"""
monkeypatch.setattr(settings, "DB_TYPE", "postgresql", raising=False)
monkeypatch.setattr(engine_module, "connection_budget",
lambda: {"sync": 20, "async_pooled": 5, "async_fallback": 5,
"per_worker": 30, "workers": 3, "total": 90})
_patch_engine(monkeypatch, lambda *_a, **_kw: _fake_pg_connection(100, 3))
assert engine_module.check_connection_budget() is True