mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-15 11:04:12 +08:00
293 lines
12 KiB
Python
293 lines
12 KiB
Python
"""
|
||
数据库会话与异步连接池。
|
||
|
||
NullPool 之所以曾被硬编码,是因为它「永不复用」从而「永不跨事件循环」——asyncpg 的
|
||
Connection 与 aiosqlite 的线程都绑定在创建它的循环上。但它同时移除了唯一的背压:
|
||
每个异步会话独占一条物理连接且无上限。
|
||
|
||
这里按事件循环缓存带池引擎:常驻主循环走连接池(有上限、可复用),其余循环回退
|
||
全局 NullPool 引擎以保持跨循环安全,并由全局配额为其补上背压。
|
||
"""
|
||
import asyncio
|
||
import inspect
|
||
import threading
|
||
import time
|
||
from contextlib import asynccontextmanager
|
||
from typing import Any, AsyncGenerator, Dict, Generator, Optional, Tuple, cast
|
||
|
||
from sqlalchemy.ext.asyncio import AsyncEngine as SaAsyncEngine
|
||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||
from sqlalchemy.orm import Session, scoped_session, sessionmaker
|
||
|
||
from app.runtime.config import global_vars, settings
|
||
from app.db import engine as engine_module
|
||
from app.db.engine import (_async_pool_enabled, _get_database_engine,
|
||
get_engine, get_global_async_engine)
|
||
from app.runtime.log import logger
|
||
|
||
# 会话工厂同样惰性:sessionmaker 在构造时就要绑定引擎,模块级构造等于把引擎的
|
||
# 创建时机重新拉回 import 期,惰性化就白做了。
|
||
_factory_lock = threading.RLock()
|
||
_session_factory: Optional[sessionmaker] = None
|
||
_async_session_factory: Optional[async_sessionmaker] = None
|
||
_scoped_session: Optional[scoped_session] = None
|
||
|
||
|
||
def get_session_factory() -> sessionmaker:
|
||
"""
|
||
获取同步会话工厂,首次调用时按需绑定引擎。
|
||
:return: 同步会话工厂
|
||
"""
|
||
global _session_factory
|
||
if _session_factory is None:
|
||
with _factory_lock:
|
||
if _session_factory is None:
|
||
_session_factory = sessionmaker(bind=get_engine())
|
||
return _session_factory
|
||
|
||
|
||
def get_async_session_factory() -> async_sessionmaker:
|
||
"""
|
||
获取异步会话工厂,首次调用时按需绑定全局异步引擎。
|
||
:return: 异步会话工厂
|
||
"""
|
||
global _async_session_factory
|
||
if _async_session_factory is None:
|
||
with _factory_lock:
|
||
if _async_session_factory is None:
|
||
_async_session_factory = async_sessionmaker(
|
||
bind=get_global_async_engine(), class_=AsyncSession)
|
||
return _async_session_factory
|
||
|
||
|
||
def get_scoped_session() -> scoped_session:
|
||
"""
|
||
获取线程局部的会话注册表,首次调用时创建。
|
||
:return: scoped_session
|
||
"""
|
||
global _scoped_session
|
||
if _scoped_session is None:
|
||
with _factory_lock:
|
||
if _scoped_session is None:
|
||
_scoped_session = scoped_session(get_session_factory())
|
||
return _scoped_session
|
||
|
||
|
||
# SessionFactory / AsyncSessionFactory / ScopedSession 这三个旧名字保留为**转发函数**,
|
||
# 而不是模块级 __getattr__(PEP 562)。
|
||
#
|
||
# __getattr__ 只在「对模块对象取属性」时触发。`from app.db.session import ScopedSession`
|
||
# 确实会走到它,但那样每个导入方都会在 import 期把引擎创建出来,惰性化等于白做;而模块
|
||
# 自己的函数体里写裸名字 ScopedSession 则**根本不会**触发它——那是全局名字查找,只查模块
|
||
# __dict__ 和 builtins,直接 NameError。
|
||
#
|
||
# 做成 __dict__ 里真实存在的函数就两头都成立:导入它不碰引擎,调用它才创建。全仓库对这三个
|
||
# 名字的用法都是 `X()` 取一个会话,这一形式的语义与原先的 sessionmaker / scoped_session 实例
|
||
# 完全一致;patch("app.scheduler.SessionFactory", ...) 这类既有测试替身也照旧生效。
|
||
#
|
||
# 但仅限 `X()` 这一形式:它们不再是 sessionmaker / scoped_session 实例,因此实例上的其余接口
|
||
# (ScopedSession.remove()、SessionFactory.configure()、AsyncSessionFactory.begin()、
|
||
# scoped_session(SessionFactory) 等)不再可用。需要真正的工厂对象时用
|
||
# get_scoped_session() / get_session_factory() / get_async_session_factory()。
|
||
#
|
||
# 这三个名字已从 app/db/__init__.py 的 __all__ 中移除,属包内实现细节而非对外契约:它们建出
|
||
# 的是绕过事务装饰器的裸会话,提交/回滚/释放全得调用方自己兜底。包内的既有调用方(scheduler、
|
||
# postgresql 模块、Alembic 迁移脚本)走的是直接导入,不受 __all__ 影响。
|
||
def SessionFactory() -> Session: # noqa: N802
|
||
"""
|
||
创建一个同步会话,引擎在首次调用时才建立。
|
||
:return: Session
|
||
"""
|
||
return get_session_factory()()
|
||
|
||
|
||
def AsyncSessionFactory() -> AsyncSession: # noqa: N802
|
||
"""
|
||
创建一个异步会话,全局异步引擎在首次调用时才建立。
|
||
:return: AsyncSession
|
||
"""
|
||
return get_async_session_factory()()
|
||
|
||
|
||
def ScopedSession() -> Session: # noqa: N802
|
||
"""
|
||
取当前线程的会话,引擎在首次调用时才建立。
|
||
:return: Session
|
||
"""
|
||
return get_scoped_session()()
|
||
|
||
|
||
# ==================== 异步引擎的按事件循环池化 ====================
|
||
# NullPool 之所以被选用,是因为它「永不复用」从而「永不跨事件循环」——asyncpg 的
|
||
# Connection 与 aiosqlite 的线程都绑定在创建它的循环上,跨循环复用会抛出
|
||
# "Task got Future attached to a different loop"。
|
||
#
|
||
# 但它同时也移除了唯一的背压:每个异步会话独占一条物理连接且无上限。调度器以上百个
|
||
# 线程向主循环投递协程,突发并发会直接顶穿 PostgreSQL 的 max_connections;SQLite 侧
|
||
# 则表现为 WAL 写争用与反复 checkpoint 造成的长时间卡顿。
|
||
#
|
||
# 这里按事件循环缓存带池引擎:常驻主循环走连接池(有上限、可复用),其余循环回退
|
||
# 全局 NullPool 引擎以保持跨循环安全,并由 _fallback_slots 为其补上背压。
|
||
_pooled_async_engines: Dict[int, Any] = {}
|
||
_pooled_async_lock = threading.Lock()
|
||
# 回退路径(未池化的临时循环)共享的全局连接配额。用 threading 信号量而非
|
||
# asyncio.Semaphore:后者绑定单个事件循环,无法跨循环生效
|
||
_fallback_slots = threading.BoundedSemaphore(max(1, settings.DB_ASYNC_FALLBACK_LIMIT))
|
||
|
||
|
||
def _pooled_loop() -> Optional[Any]:
|
||
"""
|
||
取当前可池化的事件循环,不可池化时返回 None。
|
||
|
||
只认常驻主循环:它承载了绝大多数异步 DB 流量,且生命周期与进程一致,
|
||
池中连接不会因循环销毁而失效。
|
||
直接读 CURRENT_EVENT_LOOP 而不用 global_vars.loop——后者在未设置时会
|
||
新建一个事件循环,仅为判断就产生副作用是不可接受的。
|
||
"""
|
||
if not _async_pool_enabled():
|
||
return None
|
||
try:
|
||
loop = asyncio.get_running_loop()
|
||
except RuntimeError:
|
||
# 没有运行中的循环,行为与池化前一致
|
||
return None
|
||
if loop is not getattr(global_vars, "CURRENT_EVENT_LOOP", None):
|
||
return None
|
||
return loop
|
||
|
||
|
||
def _resolve_async_engine() -> Tuple[SaAsyncEngine, bool]:
|
||
"""
|
||
按事件循环解析异步引擎,并一并给出它是否为池化引擎。
|
||
|
||
「是否池化」必须由这里给出,不能让调用方拿 `engine is not get_global_async_engine()`
|
||
反推:那个比较本身就会把全局引擎创建出来——池化路径压根用不到它,却因为一次身份比较
|
||
多出一个从未使用的活引擎,而且第一个异步请求会在事件循环内部去抢引擎创建锁。
|
||
:return: (异步引擎, 是否池化)
|
||
"""
|
||
loop = _pooled_loop()
|
||
if loop is None:
|
||
return get_global_async_engine(), False
|
||
key = id(loop)
|
||
engine = _pooled_async_engines.get(key)
|
||
if engine is not None:
|
||
return engine, True
|
||
with _pooled_async_lock:
|
||
engine = _pooled_async_engines.get(key)
|
||
if engine is None:
|
||
engine = cast(SaAsyncEngine, _get_database_engine(is_async=True, pooled=True))
|
||
_pooled_async_engines[key] = engine
|
||
logger.info(f"异步数据库连接池已启用: pool_size={settings.DB_ASYNC_POOL_SIZE}, "
|
||
f"max_overflow={settings.DB_ASYNC_MAX_OVERFLOW}")
|
||
return engine, True
|
||
|
||
|
||
def get_async_engine() -> SaAsyncEngine:
|
||
"""
|
||
按事件循环获取异步引擎:常驻主循环用池化引擎,其余回退全局 NullPool 引擎。
|
||
:return: 异步引擎
|
||
"""
|
||
return _resolve_async_engine()[0]
|
||
|
||
|
||
async def _acquire_fallback_slot():
|
||
"""
|
||
为回退路径申请一个全局连接配额。
|
||
|
||
池化路径由连接池自身限流;回退路径若不加约束,临时循环上的突发并发会重新
|
||
变得无界。信号量是线程安全且与事件循环无关的,但不能在协程里阻塞获取,
|
||
因此用非阻塞获取 + 异步让出。
|
||
"""
|
||
deadline = time.monotonic() + settings.DB_POOL_TIMEOUT
|
||
while not _fallback_slots.acquire(blocking=False):
|
||
if time.monotonic() >= deadline:
|
||
raise TimeoutError(
|
||
f"异步数据库连接配额已耗尽(上限 {settings.DB_ASYNC_FALLBACK_LIMIT}),"
|
||
f"等待超过 {settings.DB_POOL_TIMEOUT} 秒"
|
||
)
|
||
await asyncio.sleep(0.01)
|
||
|
||
|
||
@asynccontextmanager
|
||
async def async_session_scope() -> AsyncGenerator[AsyncSession, None]:
|
||
"""
|
||
获取异步数据库会话。
|
||
|
||
这是异步会话的唯一入口:池化与配额都在这里收口,调用方无需感知
|
||
当前运行在哪个事件循环上。
|
||
:return: AsyncSession
|
||
"""
|
||
engine, pooled = _resolve_async_engine()
|
||
if not pooled:
|
||
await _acquire_fallback_slot()
|
||
try:
|
||
async with AsyncSession(bind=engine, expire_on_commit=False) as session:
|
||
yield session
|
||
finally:
|
||
if not pooled:
|
||
_fallback_slots.release()
|
||
|
||
|
||
def get_db() -> Generator:
|
||
"""
|
||
获取数据库会话,用于WEB请求
|
||
:return: Session
|
||
"""
|
||
db = None
|
||
try:
|
||
db = SessionFactory()
|
||
yield db
|
||
finally:
|
||
if db:
|
||
db.close()
|
||
|
||
|
||
async def get_async_db() -> AsyncGenerator[AsyncSession, None]:
|
||
"""
|
||
获取异步数据库会话,用于WEB请求
|
||
:return: AsyncSession
|
||
"""
|
||
async with async_session_scope() as session:
|
||
yield session
|
||
|
||
|
||
async def _dispose_engine(engine: Any, label: str) -> None:
|
||
"""
|
||
释放单个引擎,异常只打印不上抛,避免拖累其余引擎的释放。
|
||
|
||
同步引擎的 dispose 是普通函数、异步引擎的返回协程,这里一并处理,
|
||
好让三类引擎共用同一套异常隔离。
|
||
:param engine: 待释放的引擎
|
||
:param label: 出错时用于定位是哪一个引擎
|
||
"""
|
||
try:
|
||
result = engine.dispose()
|
||
if inspect.isawaitable(result):
|
||
await result
|
||
except Exception as err: # noqa: BLE001
|
||
print(f"Error while disposing {label}: {err}")
|
||
|
||
|
||
async def close_database():
|
||
"""
|
||
关闭所有数据库连接并清理资源。
|
||
|
||
逐个引擎隔离异常,而不是外面套一个大 try:套大 try 时同步引擎 dispose 一抛错,
|
||
异步引擎与全部池化引擎就都跳过了释放——一条坏连接拖着其余连接一起泄漏,
|
||
正是关停路径最不该出现的失败方式。
|
||
"""
|
||
# 只释放确实创建过的引擎:惰性之后,为了 dispose 而先把引擎创建出来毫无意义,
|
||
# 还会在从未用过数据库的进程里凭空连一次库
|
||
sync_engine = engine_module.peek_sync_engine()
|
||
if sync_engine is not None:
|
||
await _dispose_engine(sync_engine, "sync engine")
|
||
async_engine = engine_module.peek_async_engine()
|
||
if async_engine is not None:
|
||
await _dispose_engine(async_engine, "global async engine")
|
||
# 释放按事件循环缓存的池化引擎
|
||
with _pooled_async_lock:
|
||
engines = list(_pooled_async_engines.values())
|
||
_pooled_async_engines.clear()
|
||
for engine in engines:
|
||
await _dispose_engine(engine, "pooled async engine")
|