refactor(transfer): make queue admission durable

This commit is contained in:
jxxghp
2026-08-27 10:57:22 +08:00
parent 1133557849
commit aac184a1b9
23 changed files with 1549 additions and 196 deletions
+115
View File
@@ -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
+147 -7
View File
@@ -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:
"""
+90 -2
View File
@@ -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:
"""
注销一个待整理文件登记。