Files
MoviePilot/app/db/decorators.py

265 lines
11 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.
"""
数据库事务装饰器。
同步/异步各一对:查询装饰器负责会话的获取与释放,更新装饰器额外负责提交与回滚。
未显式传入会话时自动创建,并在结束时归还——异步路径经 async_session_scope 收口,
连接池与配额都在那里生效。
收尾故障rollback / close / __aexit__ 自身抛异常)一律只记日志、不上抛,四个装饰器
的处理一致。理由与代价都要写明,别当成漏写的 raise
- 连接断开、事务已失效这类故障恰恰最容易发生在「出错之后」的收尾阶段。裸写收尾语句时
它一抛错就顶替掉原始异常调用方看到的只剩「connection reset」业务异常连类型都被
换掉,按类型分流的 except唯一约束冲突要重试、参数错误要报错一并失配。
- 代价是成功路径的行为随之改变func() 成功、close() 失败时,调用方**静默拿到返回值**
故障只进日志。这是有意为之——close() 失败时事务已经提交、业务确实成功了,且
SQLAlchemy 归还连接时已在池层吞掉异常并 invalidate 坏连接,再把释放故障升级成调用方
的异常,只会让一次已经落库的写入看起来像失败,诱发重复提交。
"""
from typing import Any, Awaitable, Callable, Optional, Tuple, TypeVar
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.db.session import ScopedSession, async_session_scope
from app.runtime.log import logger
_R = TypeVar("_R")
# 四个装饰器都会重写实参列表:未传会话时自行创建一个并塞回 db 位置。因此包装后的可调用
# 对象接受的实参与被包装函数的签名并不一致——用 Callable[..., _R] 如实表达「参数由装饰器
# 接管、返回值原样透传」。否则调用方传 None 或传异步会话都会被判成类型不符,而这恰恰是
# 装饰器存在的理由(各 Oper 的 self._db 常态就是 None
def _get_args_db(args: tuple, kwargs: dict) -> Optional[Session]:
"""
从参数中获取数据库Session对象
"""
db = None
if args:
for arg in args:
if isinstance(arg, Session):
db = arg
break
if kwargs:
for key, value in kwargs.items():
if isinstance(value, Session):
db = value
break
return db
def _get_args_async_db(args: tuple, kwargs: dict) -> Optional[AsyncSession]:
"""
从参数中获取异步数据库AsyncSession对象
"""
db = None
if args:
for arg in args:
if isinstance(arg, AsyncSession):
db = arg
break
if kwargs:
for key, value in kwargs.items():
if isinstance(value, AsyncSession):
db = value
break
return db
def _update_args_db(args: tuple, kwargs: dict, db: Session) -> Tuple[tuple, dict]:
"""
更新参数中的数据库Session对象关键字传参时更新db的值否则更新第1或第2个参数
"""
if kwargs and 'db' in kwargs:
kwargs['db'] = db
elif args:
if args[0] is None:
args = (db, *args[1:])
else:
args = (args[0], db, *args[2:])
return args, kwargs
def _update_args_async_db(args: tuple, kwargs: dict, db: AsyncSession) -> Tuple[tuple, dict]:
"""
更新参数中的异步数据库AsyncSession对象关键字传参时更新db的值否则更新第1或第2个参数
"""
if kwargs and 'db' in kwargs:
kwargs['db'] = db
elif args:
if args[0] is None:
args = (db, *args[1:])
else:
args = (args[0], db, *args[2:])
return args, kwargs
def db_update(func: Callable[..., _R]) -> Callable[..., _R]:
"""
数据库更新类操作装饰器第一个参数必须是数据库会话或存在db参数
"""
def wrapper(*args: Any, **kwargs: Any) -> _R:
# 是否关闭数据库会话
_close_db = False
# 从参数中获取数据库会话
db = _get_args_db(args, kwargs)
if not db:
# 如果没有获取到数据库会话,创建一个
db = ScopedSession()
# 标记需要关闭数据库会话
_close_db = True
# 更新参数中的数据库会话
args, kwargs = _update_args_db(args, kwargs, db)
try:
# 执行函数
result = func(*args, **kwargs)
# 提交事务
db.commit()
except Exception as err:
# 回滚事务。回滚自身失败不得顶替原始异常:连接断开、事务已失效这类收尾故障
# 恰恰最容易发生在「出错之后」,裸写 db.rollback() 时它一抛错,调用方看到的
# 就只剩「connection reset」真正的业务异常连类型都被换掉、按类型分流的
# except 一并失配。故障本身另行记录,不静默吞掉
try:
db.rollback()
except Exception as rollback_err: # noqa: BLE001 回滚失败不能掩盖原始异常
logger.error(f"事务回滚失败,原始异常将原样上抛:{rollback_err}")
raise err
finally:
# 关闭数据库会话。释放失败只记录:既不顶替上面正在传播的业务异常,
# 成功路径下也不把一次已提交的写入变成调用方眼里的失败(见模块说明)
if _close_db:
try:
db.close()
except Exception as close_err: # noqa: BLE001 释放故障不得改变调用结果
logger.error(f"释放数据库会话失败:{close_err}")
return result
return wrapper
def async_db_update(func: Callable[..., Awaitable[_R]]) -> Callable[..., Awaitable[_R]]:
"""
异步数据库更新类操作装饰器第一个参数必须是异步数据库会话或存在db参数
"""
async def wrapper(*args: Any, **kwargs: Any) -> _R:
# 是否关闭数据库会话;作用域与 _scope 同生共死,先置空以便静态检查看清
_close_db = False
_scope = None
# 从参数中获取异步数据库会话
db = _get_args_async_db(args, kwargs)
if not db:
# 如果没有获取到异步数据库会话,创建一个。经 async_session_scope
# 统一收口:常驻主循环走连接池,其余循环走 NullPool 并占用全局配额
_scope = async_session_scope()
db = await _scope.__aenter__()
# 标记需要关闭数据库会话
_close_db = True
# 更新参数中的异步数据库会话
args, kwargs = _update_args_async_db(args, kwargs, db)
try:
# 执行函数
result = await func(*args, **kwargs)
# 提交事务
await db.commit()
except Exception as err:
# 回滚事务;与同步路径同理,回滚失败只记录,不顶替原始异常
try:
await db.rollback()
except Exception as rollback_err: # noqa: BLE001 回滚失败不能掩盖原始异常
logger.error(f"事务回滚失败,原始异常将原样上抛:{rollback_err}")
raise err
finally:
# 关闭数据库会话
if _close_db and _scope is not None:
# 退出会话上下文而不是只 close配额的释放绑定在 __aexit__ 上,
# 只关会话会让回退路径的全局配额永不归还,最终把自己饿死。
# 退出失败同样只记录,不改变调用结果(见模块说明)
try:
await _scope.__aexit__(None, None, None)
except Exception as close_err: # noqa: BLE001 释放故障不得改变调用结果
logger.error(f"释放数据库会话失败:{close_err}")
return result
return wrapper
def db_query(func: Callable[..., _R]) -> Callable[..., _R]:
"""
数据库查询操作装饰器第一个参数必须是数据库会话或存在db参数
注意db.query列表数据时需要转换为list返回
"""
def wrapper(*args: Any, **kwargs: Any) -> _R:
# 是否关闭数据库会话
_close_db = False
# 从参数中获取数据库会话
db = _get_args_db(args, kwargs)
if not db:
# 如果没有获取到数据库会话,创建一个
db = ScopedSession()
# 标记需要关闭数据库会话
_close_db = True
# 更新参数中的数据库会话
args, kwargs = _update_args_db(args, kwargs, db)
try:
# 执行函数
result = func(*args, **kwargs)
except Exception as err:
raise err
finally:
# 关闭数据库会话。释放失败只记录,不顶替业务异常、也不影响成功路径的返回值
# (见模块说明)
if _close_db:
try:
db.close()
except Exception as close_err: # noqa: BLE001 释放故障不得改变调用结果
logger.error(f"释放数据库会话失败:{close_err}")
return result
return wrapper
def async_db_query(func: Callable[..., Awaitable[_R]]) -> Callable[..., Awaitable[_R]]:
"""
异步数据库查询操作装饰器第一个参数必须是异步数据库会话或存在db参数
注意db.query列表数据时需要转换为list返回
"""
async def wrapper(*args: Any, **kwargs: Any) -> _R:
# 是否关闭数据库会话
_close_db = False
_scope = None
# 从参数中获取异步数据库会话
db = _get_args_async_db(args, kwargs)
if not db:
# 如果没有获取到异步数据库会话,创建一个。经 async_session_scope
# 统一收口:常驻主循环走连接池,其余循环走 NullPool 并占用全局配额
_scope = async_session_scope()
db = await _scope.__aenter__()
# 标记需要关闭数据库会话
_close_db = True
# 更新参数中的异步数据库会话
args, kwargs = _update_args_async_db(args, kwargs, db)
try:
# 执行函数
result = await func(*args, **kwargs)
except Exception as err:
raise err
finally:
# 关闭数据库会话
if _close_db and _scope is not None:
# 退出会话上下文而不是只 close配额的释放绑定在 __aexit__ 上,
# 只关会话会让回退路径的全局配额永不归还,最终把自己饿死。
# 退出失败同样只记录,不改变调用结果(见模块说明)
try:
await _scope.__aexit__(None, None, None)
except Exception as close_err: # noqa: BLE001 释放故障不得改变调用结果
logger.error(f"释放数据库会话失败:{close_err}")
return result
return wrapper