mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
refactor(transfer): make queue admission durable
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
"""整理任务持久准入端口的 SQLAlchemy 适配器。"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.transfer import (
|
||||
TRANSFER_ADMISSION_ACCEPTED,
|
||||
TransferAdmission,
|
||||
)
|
||||
from app.db.models.transferpending import TransferPending
|
||||
from app.db.oper.transferpending import TransferPendingOper
|
||||
from app.db.uow import SqlAlchemyUnitOfWork
|
||||
|
||||
|
||||
class TransactionalTransferAdmissionRepository:
|
||||
"""以短生命周期 Session 实现整理任务持久准入端口。"""
|
||||
|
||||
def __init__(self, session_factory: Callable[[], Session]) -> None:
|
||||
"""保存由组合根提供的同步会话工厂。"""
|
||||
self._session_factory = session_factory
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
"""生成与历史登记时间可按字典序比较的当前时间。"""
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
@staticmethod
|
||||
def _project(pending: TransferPending) -> TransferAdmission:
|
||||
"""在 Session 有效期内把 ORM 行冻结为应用层 DTO。"""
|
||||
created_at = pending.created_at or pending.updated_at
|
||||
return TransferAdmission(
|
||||
task_id=pending.task_id,
|
||||
storage=pending.storage,
|
||||
src_path=pending.src_path,
|
||||
state=pending.state,
|
||||
created_at=created_at,
|
||||
updated_at=pending.updated_at,
|
||||
last_error=pending.last_error,
|
||||
)
|
||||
|
||||
def admit(self, *, storage: str, src_path: str) -> TransferAdmission:
|
||||
"""幂等持久化准入事实,并返回跨重启稳定的任务标识。"""
|
||||
now_time = self._now()
|
||||
try:
|
||||
with self._session_factory() as session:
|
||||
transaction = SqlAlchemyUnitOfWork(session)
|
||||
try:
|
||||
pending = TransferPendingOper(db=session).stage_admit(
|
||||
task_id=uuid4().hex,
|
||||
storage=storage,
|
||||
src_path=src_path,
|
||||
state=TRANSFER_ADMISSION_ACCEPTED,
|
||||
now_time=now_time,
|
||||
)
|
||||
if pending is None:
|
||||
raise ValueError("整理任务的存储与源路径不能为空")
|
||||
session.flush()
|
||||
admission = self._project(pending)
|
||||
transaction.commit()
|
||||
return admission
|
||||
except Exception:
|
||||
transaction.rollback()
|
||||
raise
|
||||
except IntegrityError as error:
|
||||
# 并发准入可能同时通过查询;唯一约束决定赢家,输家回读稳定身份。
|
||||
with self._session_factory() as session:
|
||||
pending = TransferPendingOper(db=session).get_by_identity(
|
||||
storage=storage,
|
||||
src_path=src_path,
|
||||
)
|
||||
if pending is None:
|
||||
raise RuntimeError("并发准入冲突后未找到已提交记录") from error
|
||||
return self._project(pending)
|
||||
|
||||
def list_accepted(self, limit: int = 5000) -> list[TransferAdmission]:
|
||||
"""在独立只读会话中投影等待恢复或执行的准入记录。"""
|
||||
with self._session_factory() as session:
|
||||
pending_items = TransferPendingOper(db=session).list_by_state(
|
||||
state=TRANSFER_ADMISSION_ACCEPTED,
|
||||
limit=limit,
|
||||
)
|
||||
return [self._project(pending) for pending in pending_items]
|
||||
|
||||
def record_enqueue_failure(self, *, task_id: str, error: str) -> None:
|
||||
"""独立提交最近一次入队失败,保留准入记录供后续恢复。"""
|
||||
with self._session_factory() as session:
|
||||
transaction = SqlAlchemyUnitOfWork(session)
|
||||
try:
|
||||
TransferPendingOper(db=session).stage_record_enqueue_failure(
|
||||
task_id=task_id,
|
||||
error=error,
|
||||
now_time=self._now(),
|
||||
)
|
||||
transaction.commit()
|
||||
except Exception:
|
||||
transaction.rollback()
|
||||
raise
|
||||
|
||||
def discard_task(self, *, task_id: str) -> int:
|
||||
"""在独立事务中按稳定任务标识删除已到终态的准入记录。"""
|
||||
with self._session_factory() as session:
|
||||
transaction = SqlAlchemyUnitOfWork(session)
|
||||
try:
|
||||
deleted = TransferPendingOper(db=session).stage_discard_task(
|
||||
task_id=task_id,
|
||||
)
|
||||
transaction.commit()
|
||||
return deleted
|
||||
except Exception:
|
||||
transaction.rollback()
|
||||
raise
|
||||
@@ -1,6 +1,8 @@
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import Index, String, delete, select
|
||||
from sqlalchemy import Index, String, Text, UniqueConstraint, delete, select, update
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from app.db.base import Base, execute_dml, get_id_column
|
||||
@@ -15,22 +17,43 @@ class TransferPending(Base):
|
||||
蒸发。而已经稳定落地的文件不会再产生任何监控事件,也不会有新的补偿扫描起点
|
||||
——结果就是永久漏件,只能靠人工比对补整理。
|
||||
|
||||
这里只落盘最小事实:存储与源文件路径。重启后重新走一遍整理入口,由整理历史
|
||||
查重挡掉已经完成的,因此不需要序列化 meta/mediainfo 这些重对象,也不存在
|
||||
识别结果陈旧的问题。
|
||||
这里只落盘恢复所需的最小事实:稳定任务身份、存储、源文件路径、准入状态和
|
||||
最近入队错误。重启后重新走一遍整理入口,由整理历史查重挡掉已经完成的,
|
||||
因此不需要序列化 meta/mediainfo 这些重对象,也不存在识别结果陈旧的问题。
|
||||
"""
|
||||
|
||||
id = get_id_column()
|
||||
# 稳定任务标识
|
||||
task_id: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, default=lambda: uuid4().hex
|
||||
)
|
||||
# 存储
|
||||
storage: Mapped[str] = mapped_column(String, nullable=False)
|
||||
# 源文件路径
|
||||
src_path: Mapped[str] = mapped_column(String, nullable=False)
|
||||
# 登记时间
|
||||
created_at: Mapped[Optional[str]] = mapped_column(String)
|
||||
# 持久状态
|
||||
state: Mapped[str] = mapped_column(String(32), nullable=False, default="accepted")
|
||||
# 最后更新时间
|
||||
updated_at: Mapped[str] = mapped_column(
|
||||
String(40), nullable=False,
|
||||
default=lambda: datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
# 最近一次入队失败原因
|
||||
last_error: Mapped[Optional[str]] = mapped_column(Text)
|
||||
|
||||
__table_args__ = (
|
||||
# 同一个文件重复入队只保留一条,回放时不会重复送入整理链
|
||||
Index("ux_transferpending_storage_path", "storage", "src_path", unique=True),
|
||||
# 恢复主查询按状态过滤、登记时间与主键稳定排序
|
||||
Index(
|
||||
"ix_transferpending_state_created",
|
||||
"state",
|
||||
"created_at",
|
||||
"id",
|
||||
),
|
||||
UniqueConstraint("task_id", name="uq_transferpending_task_id"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -50,11 +73,128 @@ class TransferPending(Base):
|
||||
select(cls).where(cls.storage == storage, cls.src_path == src_path)
|
||||
).scalars().first()
|
||||
if pending:
|
||||
return pending
|
||||
pending = cls(storage=storage, src_path=src_path, created_at=now_time)
|
||||
return cast("TransferPending", pending)
|
||||
pending = cls(
|
||||
storage=storage,
|
||||
src_path=src_path,
|
||||
state="accepted",
|
||||
created_at=now_time,
|
||||
updated_at=now_time,
|
||||
)
|
||||
db.add(pending)
|
||||
return pending
|
||||
|
||||
@classmethod
|
||||
def stage_admit(cls, db: Session, *, task_id: str, storage: str,
|
||||
src_path: str, state: str,
|
||||
now_time: str) -> Optional["TransferPending"]:
|
||||
"""
|
||||
在调用方会话中暂存一条持久接纳记录。
|
||||
|
||||
相同存储与路径已存在时返回原记录,确保重复监控事件复用同一个任务标识。
|
||||
:param db: 数据库会话
|
||||
:param task_id: 任务标识
|
||||
:param storage: 存储
|
||||
:param src_path: 源文件路径
|
||||
:param state: 持久状态
|
||||
:param now_time: 当前时间
|
||||
:return: 接纳记录
|
||||
"""
|
||||
if not task_id or not storage or not src_path or not state:
|
||||
return None
|
||||
pending = db.execute(
|
||||
select(cls).where(cls.storage == storage, cls.src_path == src_path)
|
||||
).scalars().first()
|
||||
if pending:
|
||||
return cast("TransferPending", pending)
|
||||
pending = cls(
|
||||
task_id=task_id,
|
||||
storage=storage,
|
||||
src_path=src_path,
|
||||
state=state,
|
||||
created_at=now_time,
|
||||
updated_at=now_time,
|
||||
)
|
||||
db.add(pending)
|
||||
return pending
|
||||
|
||||
@classmethod
|
||||
def list_by_state(cls, db: Session, *, state: str,
|
||||
limit: Optional[int] = 5000) -> List["TransferPending"]:
|
||||
"""
|
||||
按登记顺序列出指定持久状态的接纳记录。
|
||||
:param db: 数据库会话
|
||||
:param state: 持久状态
|
||||
:param limit: 单次读取上限
|
||||
:return: 接纳记录列表
|
||||
"""
|
||||
if not state:
|
||||
return []
|
||||
return list(db.execute(
|
||||
select(cls)
|
||||
.where(cls.state == state)
|
||||
.order_by(cls.created_at.asc(), cls.id.asc())
|
||||
.limit(limit)
|
||||
).scalars().all())
|
||||
|
||||
@classmethod
|
||||
def get_by_identity(cls, db: Session, *, storage: str,
|
||||
src_path: str) -> Optional["TransferPending"]:
|
||||
"""
|
||||
按存储与源路径查询一条持久接纳记录。
|
||||
:param db: 数据库会话
|
||||
:param storage: 存储
|
||||
:param src_path: 源文件路径
|
||||
:return: 接纳记录
|
||||
"""
|
||||
if not storage or not src_path:
|
||||
return None
|
||||
return cast(
|
||||
Optional["TransferPending"],
|
||||
db.execute(
|
||||
select(cls).where(
|
||||
cls.storage == storage,
|
||||
cls.src_path == src_path,
|
||||
)
|
||||
).scalars().first(),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def record_enqueue_failure(cls, db: Session, *, task_id: str,
|
||||
error: str, now_time: str) -> int:
|
||||
"""
|
||||
在调用方会话中记录任务最近一次入队失败。
|
||||
:param db: 数据库会话
|
||||
:param task_id: 任务标识
|
||||
:param error: 失败原因
|
||||
:param now_time: 当前时间
|
||||
:return: 更新的记录数
|
||||
"""
|
||||
if not task_id:
|
||||
return 0
|
||||
return execute_dml(
|
||||
db,
|
||||
update(cls)
|
||||
.where(cls.task_id == task_id)
|
||||
.values(last_error=error, updated_at=now_time),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def discard_task(cls, db: Session, *, task_id: str) -> int:
|
||||
"""
|
||||
在调用方会话中按任务标识删除接纳记录。
|
||||
:param db: 数据库会话
|
||||
:param task_id: 任务标识
|
||||
:return: 删除的记录数
|
||||
"""
|
||||
if not task_id:
|
||||
return 0
|
||||
return execute_dml(
|
||||
db, delete(cls).where(cls.task_id == task_id),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def discard(cls, db: Session, storage: str, src_path: str) -> int:
|
||||
"""
|
||||
|
||||
@@ -9,8 +9,9 @@ class TransferPendingOper(DbOper):
|
||||
"""
|
||||
待整理文件登记管理。
|
||||
|
||||
只保存「存储 + 源文件路径」这一最小事实,用于在进程重启后把没走完整理链的
|
||||
文件重新送回去,避免挂载故障重启后永久漏件。
|
||||
保存稳定任务身份、存储、源文件路径和准入状态,用于在进程重启后把没走完
|
||||
整理链的文件重新送回去,避免挂载故障重启后永久漏件。旧版路径登记接口继续
|
||||
保留,供插件和兼容调用方使用。
|
||||
"""
|
||||
|
||||
def register(self, storage: str, src_path: str) -> Optional[TransferPending]:
|
||||
@@ -30,6 +31,93 @@ class TransferPendingOper(DbOper):
|
||||
)
|
||||
)
|
||||
|
||||
def stage_admit(self, *, task_id: str, storage: str, src_path: str,
|
||||
state: str, now_time: str) -> Optional[TransferPending]:
|
||||
"""
|
||||
在当前会话中暂存一条持久接纳记录。
|
||||
|
||||
适配器应传入显式 Session,使提交与回滚仍由应用用例对应的 UoW 管理。
|
||||
:param task_id: 任务标识
|
||||
:param storage: 存储
|
||||
:param src_path: 源文件路径
|
||||
:param state: 持久状态
|
||||
:param now_time: 当前时间
|
||||
:return: 接纳记录
|
||||
"""
|
||||
return self._execute_sync_write(
|
||||
lambda session: TransferPending.stage_admit(
|
||||
session,
|
||||
task_id=task_id,
|
||||
storage=storage,
|
||||
src_path=src_path,
|
||||
state=state,
|
||||
now_time=now_time,
|
||||
)
|
||||
)
|
||||
|
||||
def list_by_state(self, *, state: str,
|
||||
limit: Optional[int] = 5000) -> List[TransferPending]:
|
||||
"""
|
||||
使用当前会话列出指定状态记录。
|
||||
:param state: 持久状态
|
||||
:param limit: 单次读取上限
|
||||
:return: ORM 接纳记录列表
|
||||
"""
|
||||
return self._execute_sync_query(
|
||||
lambda session: TransferPending.list_by_state(
|
||||
session,
|
||||
state=state,
|
||||
limit=limit,
|
||||
)
|
||||
) or []
|
||||
|
||||
def get_by_identity(self, *, storage: str,
|
||||
src_path: str) -> Optional[TransferPending]:
|
||||
"""
|
||||
使用当前会话按存储与源路径查询接纳记录。
|
||||
:param storage: 存储
|
||||
:param src_path: 源文件路径
|
||||
:return: 接纳记录
|
||||
"""
|
||||
return self._execute_sync_query(
|
||||
lambda session: TransferPending.get_by_identity(
|
||||
session,
|
||||
storage=storage,
|
||||
src_path=src_path,
|
||||
)
|
||||
)
|
||||
|
||||
def stage_record_enqueue_failure(self, *, task_id: str, error: str,
|
||||
now_time: str) -> int:
|
||||
"""
|
||||
在当前会话中暂存最近一次入队失败。
|
||||
:param task_id: 任务标识
|
||||
:param error: 失败原因
|
||||
:param now_time: 当前时间
|
||||
:return: 更新的记录数
|
||||
"""
|
||||
return self._execute_sync_write(
|
||||
lambda session: TransferPending.record_enqueue_failure(
|
||||
session,
|
||||
task_id=task_id,
|
||||
error=error,
|
||||
now_time=now_time,
|
||||
)
|
||||
)
|
||||
|
||||
def stage_discard_task(self, *, task_id: str) -> int:
|
||||
"""
|
||||
在当前会话中暂存按任务标识删除接纳记录。
|
||||
:param task_id: 任务标识
|
||||
:return: 删除的记录数
|
||||
"""
|
||||
return self._execute_sync_write(
|
||||
lambda session: TransferPending.discard_task(
|
||||
session,
|
||||
task_id=task_id,
|
||||
)
|
||||
)
|
||||
|
||||
def discard(self, storage: str, src_path: str) -> int:
|
||||
"""
|
||||
注销一个待整理文件登记。
|
||||
|
||||
Reference in New Issue
Block a user