mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
refactor: add durable transfer planning checkpoints
This commit is contained in:
+163
-2
@@ -2,6 +2,7 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -9,7 +10,13 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.transfer import (
|
||||
TRANSFER_ADMISSION_ACCEPTED,
|
||||
TRANSFER_ADMISSION_PLANNED,
|
||||
TRANSFER_ADMISSION_PROVIDER_PENDING,
|
||||
TransferAdmission,
|
||||
TransferAdmissionConflictError,
|
||||
TransferPlanCheckpoint,
|
||||
TransferPlanningInput,
|
||||
TransferPlanningStateError,
|
||||
)
|
||||
from app.db.models.transferpending import TransferPending
|
||||
from app.db.oper.transferpending import TransferPendingOper
|
||||
@@ -32,6 +39,40 @@ class TransactionalTransferAdmissionRepository:
|
||||
def _project(pending: TransferPending) -> TransferAdmission:
|
||||
"""在 Session 有效期内把 ORM 行冻结为应用层 DTO。"""
|
||||
created_at = pending.created_at or pending.updated_at
|
||||
planning_input = TransferPlanningInput.from_payload(pending.planning_input)
|
||||
if pending.input_version != planning_input.schema_version:
|
||||
raise TransferPlanningStateError("整理规划输入列版本与 JSON 版本不一致")
|
||||
if pending.input_fingerprint != planning_input.fingerprint:
|
||||
raise TransferAdmissionConflictError("整理规划输入 JSON 与持久指纹不一致")
|
||||
checkpoint = (
|
||||
TransferPlanCheckpoint.from_payload(pending.checkpoint_payload)
|
||||
if pending.checkpoint_payload is not None
|
||||
else None
|
||||
)
|
||||
if checkpoint is not None:
|
||||
if pending.checkpoint_version != checkpoint.schema_version:
|
||||
raise TransferPlanningStateError("整理检查点列版本与 JSON 版本不一致")
|
||||
if checkpoint.planning_input.fingerprint != pending.input_fingerprint:
|
||||
raise TransferAdmissionConflictError("整理检查点内嵌输入与准入指纹不一致")
|
||||
if pending.state in {
|
||||
TRANSFER_ADMISSION_PROVIDER_PENDING,
|
||||
TRANSFER_ADMISSION_PLANNED,
|
||||
} and checkpoint is None:
|
||||
raise TransferPlanningStateError("待执行任务缺少完整检查点")
|
||||
if pending.state == TRANSFER_ADMISSION_ACCEPTED and checkpoint is not None:
|
||||
raise TransferPlanningStateError("接纳态任务不能携带计划检查点")
|
||||
if (
|
||||
pending.state == TRANSFER_ADMISSION_PROVIDER_PENDING
|
||||
and checkpoint is not None
|
||||
and not checkpoint.is_provider_pending
|
||||
):
|
||||
raise TransferPlanningStateError("provider_pending 状态缺少 provider 调用快照")
|
||||
if (
|
||||
pending.state == TRANSFER_ADMISSION_PLANNED
|
||||
and checkpoint is not None
|
||||
and checkpoint.is_provider_pending
|
||||
):
|
||||
raise TransferPlanningStateError("planned 状态不能携带 provider-only 检查点")
|
||||
return TransferAdmission(
|
||||
task_id=pending.task_id,
|
||||
storage=pending.storage,
|
||||
@@ -40,10 +81,39 @@ class TransactionalTransferAdmissionRepository:
|
||||
created_at=created_at,
|
||||
updated_at=pending.updated_at,
|
||||
last_error=pending.last_error,
|
||||
input_fingerprint=pending.input_fingerprint,
|
||||
planning_input=planning_input,
|
||||
checkpoint=checkpoint,
|
||||
)
|
||||
|
||||
def admit(self, *, storage: str, src_path: str) -> TransferAdmission:
|
||||
"""幂等持久化准入事实,并返回跨重启稳定的任务标识。"""
|
||||
@staticmethod
|
||||
def _assert_input_match(
|
||||
pending: TransferPending,
|
||||
planning_input: TransferPlanningInput,
|
||||
) -> None:
|
||||
"""拒绝同一源文件以不同规划输入复用既有任务身份。"""
|
||||
if pending.input_fingerprint != planning_input.fingerprint:
|
||||
raise TransferAdmissionConflictError(
|
||||
f"整理源文件已按不同输入准入: {pending.storage}:{pending.src_path}"
|
||||
)
|
||||
|
||||
def admit(
|
||||
self,
|
||||
*,
|
||||
storage: str,
|
||||
src_path: str,
|
||||
planning_input: Optional[TransferPlanningInput] = None,
|
||||
) -> TransferAdmission:
|
||||
"""按输入指纹幂等持久化准入事实,并返回跨重启稳定身份。"""
|
||||
effective_input = planning_input or TransferPlanningInput.legacy(
|
||||
storage=storage,
|
||||
src_path=src_path,
|
||||
)
|
||||
if (
|
||||
effective_input.source_fileitem.get("storage") != storage
|
||||
or effective_input.source_fileitem.get("path") != src_path
|
||||
):
|
||||
raise ValueError("整理规划输入的源文件身份与准入参数不一致")
|
||||
now_time = self._now()
|
||||
try:
|
||||
with self._session_factory() as session:
|
||||
@@ -55,10 +125,14 @@ class TransactionalTransferAdmissionRepository:
|
||||
src_path=src_path,
|
||||
state=TRANSFER_ADMISSION_ACCEPTED,
|
||||
now_time=now_time,
|
||||
input_version=effective_input.schema_version,
|
||||
planning_input=effective_input.to_payload(),
|
||||
input_fingerprint=effective_input.fingerprint,
|
||||
)
|
||||
if pending is None:
|
||||
raise ValueError("整理任务的存储与源路径不能为空")
|
||||
session.flush()
|
||||
self._assert_input_match(pending, effective_input)
|
||||
admission = self._project(pending)
|
||||
transaction.commit()
|
||||
return admission
|
||||
@@ -74,6 +148,7 @@ class TransactionalTransferAdmissionRepository:
|
||||
)
|
||||
if pending is None:
|
||||
raise RuntimeError("并发准入冲突后未找到已提交记录") from error
|
||||
self._assert_input_match(pending, effective_input)
|
||||
return self._project(pending)
|
||||
|
||||
def list_accepted(self, limit: int = 5000) -> list[TransferAdmission]:
|
||||
@@ -85,6 +160,19 @@ class TransactionalTransferAdmissionRepository:
|
||||
)
|
||||
return [self._project(pending) for pending in pending_items]
|
||||
|
||||
def list_recoverable(self, limit: int = 5000) -> list[TransferAdmission]:
|
||||
"""投影接纳、provider 待执行或已规划的全部可恢复任务。"""
|
||||
with self._session_factory() as session:
|
||||
pending_items = TransferPendingOper(db=session).list_by_states(
|
||||
states=(
|
||||
TRANSFER_ADMISSION_ACCEPTED,
|
||||
TRANSFER_ADMISSION_PROVIDER_PENDING,
|
||||
TRANSFER_ADMISSION_PLANNED,
|
||||
),
|
||||
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:
|
||||
@@ -100,6 +188,79 @@ class TransactionalTransferAdmissionRepository:
|
||||
transaction.rollback()
|
||||
raise
|
||||
|
||||
def checkpoint_plan(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
input_fingerprint: str,
|
||||
checkpoint: TransferPlanCheckpoint,
|
||||
) -> TransferAdmission:
|
||||
"""以输入指纹 CAS 保存 provider 调用快照或升级宿主计划。"""
|
||||
if checkpoint.planning_input.fingerprint != input_fingerprint:
|
||||
raise TransferAdmissionConflictError("检查点输入与准入输入指纹不一致")
|
||||
checkpoint_payload = checkpoint.to_payload()
|
||||
target_state = (
|
||||
TRANSFER_ADMISSION_PROVIDER_PENDING
|
||||
if checkpoint.is_provider_pending
|
||||
else TRANSFER_ADMISSION_PLANNED
|
||||
)
|
||||
source_states = (
|
||||
(TRANSFER_ADMISSION_ACCEPTED,)
|
||||
if checkpoint.is_provider_pending
|
||||
else (
|
||||
TRANSFER_ADMISSION_ACCEPTED,
|
||||
TRANSFER_ADMISSION_PROVIDER_PENDING,
|
||||
)
|
||||
)
|
||||
with self._session_factory() as session:
|
||||
transaction = SqlAlchemyUnitOfWork(session)
|
||||
try:
|
||||
oper = TransferPendingOper(db=session)
|
||||
updated = oper.stage_checkpoint_plan(
|
||||
task_id=task_id,
|
||||
input_fingerprint=input_fingerprint,
|
||||
checkpoint_version=checkpoint.schema_version,
|
||||
checkpoint_payload=checkpoint_payload,
|
||||
source_states=source_states,
|
||||
target_state=target_state,
|
||||
now_time=self._now(),
|
||||
)
|
||||
session.flush()
|
||||
session.expire_all()
|
||||
pending = oper.get_by_task_id(task_id=task_id)
|
||||
if pending is None:
|
||||
raise TransferPlanningStateError(f"未找到整理任务: {task_id}")
|
||||
if pending.input_fingerprint != input_fingerprint:
|
||||
raise TransferAdmissionConflictError("整理任务输入指纹已经改变")
|
||||
if not updated and not (
|
||||
pending.state == target_state
|
||||
and pending.checkpoint_payload == checkpoint_payload
|
||||
):
|
||||
raise TransferPlanningStateError(
|
||||
f"整理任务不能从状态 {pending.state} 保存 {target_state} 检查点"
|
||||
)
|
||||
admission = self._project(pending)
|
||||
transaction.commit()
|
||||
return admission
|
||||
except Exception:
|
||||
transaction.rollback()
|
||||
raise
|
||||
|
||||
def record_planning_failure(self, *, task_id: str, error: str) -> None:
|
||||
"""独立提交规划错误并保持任务处于接纳态供恢复重试。"""
|
||||
with self._session_factory() as session:
|
||||
transaction = SqlAlchemyUnitOfWork(session)
|
||||
try:
|
||||
TransferPendingOper(db=session).stage_record_planning_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:
|
||||
|
||||
Reference in New Issue
Block a user