mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-04 06:15:14 +08:00
fix(transfer): resolve durable failure recovery issues
This commit is contained in:
@@ -91,6 +91,7 @@ def get_transfer_history_mutation_command(
|
||||
return TransferHistoryMutationCommand(
|
||||
repository=runtime.history.transfer_mutation_repository(db),
|
||||
download_repository=runtime.history.download_repository(db),
|
||||
transfer_execution_repository=runtime.history.transfer_execution_repository,
|
||||
unit_of_work=runtime.persistence.sync_transaction(db),
|
||||
file_item_factory=lambda payload: _SchemaFileItem(**payload),
|
||||
file_exists=storage_chain.exists,
|
||||
|
||||
@@ -6,6 +6,10 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional, Protocol
|
||||
|
||||
from app.application.transfer.execution import (
|
||||
TransferExecutionCommand,
|
||||
TransferExecutionRepository,
|
||||
)
|
||||
from app.schemas.common import JsonData
|
||||
from app.schemas.history import TransferHistoryDeleteResult, TransferHistoryDeleteStep
|
||||
|
||||
@@ -34,6 +38,11 @@ class TransferHistoryMutationRecord(Protocol):
|
||||
"""返回持久整理任务标识。"""
|
||||
...
|
||||
|
||||
@property
|
||||
def transfer_settlement_revision(self) -> Optional[int]:
|
||||
"""返回持久整理失败回执的结算版本。"""
|
||||
...
|
||||
|
||||
@property
|
||||
def src(self) -> Optional[str]:
|
||||
"""返回源路径。"""
|
||||
@@ -132,6 +141,7 @@ class TransferHistoryMutationCommand:
|
||||
*,
|
||||
repository: TransferHistoryMutationRepository,
|
||||
download_repository: DownloadFileMutationRepository,
|
||||
transfer_execution_repository: TransferExecutionRepository,
|
||||
unit_of_work: HistoryUnitOfWork,
|
||||
file_item_factory: Callable[[dict[str, JsonData]], Any],
|
||||
delete_media_file: Callable[[Any], bool],
|
||||
@@ -142,6 +152,7 @@ class TransferHistoryMutationCommand:
|
||||
"""保存历史事务、存储删除、事件和失败状态清理端口。"""
|
||||
self._repository = repository
|
||||
self._download_repository = download_repository
|
||||
self._transfer_execution_repository = transfer_execution_repository
|
||||
self._unit_of_work = unit_of_work
|
||||
self._file_item_factory = file_item_factory
|
||||
self._file_exists = file_exists or (lambda _fileitem: True)
|
||||
@@ -166,12 +177,28 @@ class TransferHistoryMutationCommand:
|
||||
message="记录不存在",
|
||||
)
|
||||
if history.transfer_task_id:
|
||||
return TransferHistoryDeleteResult(
|
||||
source=TransferHistoryDeleteStep(status="not_requested"),
|
||||
destination=TransferHistoryDeleteStep(status="not_requested"),
|
||||
history="retained",
|
||||
message="持久整理失败记录不可删除,请使用重试或人工复核入口",
|
||||
settlement_revision = history.transfer_settlement_revision
|
||||
if not settlement_revision:
|
||||
return TransferHistoryDeleteResult(
|
||||
source=TransferHistoryDeleteStep(status="not_requested"),
|
||||
destination=TransferHistoryDeleteStep(status="not_requested"),
|
||||
history="retained",
|
||||
message="持久整理失败记录缺少结算版本,请刷新后重试",
|
||||
)
|
||||
discard = TransferExecutionCommand(
|
||||
self._transfer_execution_repository
|
||||
).discard_failed(
|
||||
task_id=history.transfer_task_id,
|
||||
history_id=history_id,
|
||||
settlement_revision=settlement_revision,
|
||||
)
|
||||
if not discard.discarded:
|
||||
return TransferHistoryDeleteResult(
|
||||
source=TransferHistoryDeleteStep(status="not_requested"),
|
||||
destination=TransferHistoryDeleteStep(status="not_requested"),
|
||||
history="retained",
|
||||
message=discard.message,
|
||||
)
|
||||
|
||||
destination_result = self._delete_file(
|
||||
history.dest_fileitem if delete_destination else None,
|
||||
|
||||
@@ -435,6 +435,15 @@ class TransferRetryRequestResult:
|
||||
message: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TransferFailureDiscardResult:
|
||||
"""描述终态失败任务是否已解除历史绑定并删除执行证据。"""
|
||||
|
||||
discarded: bool
|
||||
state: Optional[TransferExecutionState]
|
||||
message: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TransferManualReviewResult:
|
||||
"""描述一次已持久审计的人工判定及后续调度状态。"""
|
||||
@@ -595,6 +604,15 @@ class TransferExecutionRepository(Protocol):
|
||||
) -> TransferRetryRequestResult:
|
||||
"""仅把 FAILED 终态转入到期可 claim 的 retry_wait。"""
|
||||
|
||||
def discard_failed(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
history_id: int,
|
||||
settlement_revision: int,
|
||||
) -> TransferFailureDiscardResult:
|
||||
"""仅放弃与指定失败回执完全匹配的无租约 FAILED 任务。"""
|
||||
|
||||
def resolve_manual_review(
|
||||
self,
|
||||
*,
|
||||
@@ -810,6 +828,22 @@ class TransferExecutionCommand:
|
||||
requested_by=requested_by,
|
||||
)
|
||||
|
||||
def discard_failed(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
history_id: int,
|
||||
settlement_revision: int,
|
||||
) -> TransferFailureDiscardResult:
|
||||
"""放弃确定失败任务,使对应历史恢复为普通可维护记录。"""
|
||||
if not task_id or history_id <= 0 or settlement_revision <= 0:
|
||||
raise ValueError("放弃失败整理任务缺少任务、历史或结算版本")
|
||||
return self._repository.discard_failed(
|
||||
task_id=task_id,
|
||||
history_id=history_id,
|
||||
settlement_revision=settlement_revision,
|
||||
)
|
||||
|
||||
def resolve_manual_review(
|
||||
self,
|
||||
*,
|
||||
@@ -924,6 +958,7 @@ __all__ = [
|
||||
"TransferExecutionCommand",
|
||||
"TransferExecutionConflictError",
|
||||
"TransferExecutionError",
|
||||
"TransferFailureDiscardResult",
|
||||
"TransferExecutionLeaseLostError",
|
||||
"TransferOperationObservation",
|
||||
"TransferOperationObservationState",
|
||||
|
||||
@@ -705,6 +705,10 @@ class TransferAdmissionConflictError(ValueError):
|
||||
"""同一源文件以不同规划输入重复准入时抛出的冲突错误。"""
|
||||
|
||||
|
||||
class TransferPlanningRejectedError(ValueError):
|
||||
"""规划输入缺少业务必需信息且继续自动重试不会改变结果。"""
|
||||
|
||||
|
||||
class TransferPlanningStateError(RuntimeError):
|
||||
"""计划检查点无法从当前持久状态推进时抛出的状态错误。"""
|
||||
|
||||
|
||||
@@ -951,7 +951,7 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin,
|
||||
"""调用文件管理模块生成无文件写入的冻结整理计划。"""
|
||||
return cast(
|
||||
"Optional[TransferPlanCheckpoint]",
|
||||
self.run_module(
|
||||
self.run_module_strict(
|
||||
"plan_transfer",
|
||||
fileitem=fileitem,
|
||||
meta=meta,
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.application.transfer.workflow import (
|
||||
TransferLeaseLostError,
|
||||
TransferPlanCheckpoint,
|
||||
TransferPlanningInput,
|
||||
TransferPlanningRejectedError,
|
||||
TransferPlanningStateError,
|
||||
TransferProviderInvocationSnapshot,
|
||||
TransferProviderReference,
|
||||
@@ -274,13 +275,34 @@ class TransferPlanningOwner(_TransferOwnerBase):
|
||||
)
|
||||
planning_input = task.planning_input or self._TransferChain__build_planning_input(task)
|
||||
task.bind_planning_input(planning_input)
|
||||
checkpoint = self._TransferChain__build_planning_rejection_checkpoint(
|
||||
task,
|
||||
error=error,
|
||||
planning_input=planning_input,
|
||||
)
|
||||
persisted = self._TransferChain__persist_transfer_checkpoint(
|
||||
task,
|
||||
planning_input=planning_input,
|
||||
checkpoint=checkpoint,
|
||||
)
|
||||
task.bind_plan_checkpoint(persisted)
|
||||
return self._plan_checkpoint_and_execute(task)
|
||||
|
||||
def _TransferChain__build_planning_rejection_checkpoint(
|
||||
self,
|
||||
task: TransferTask,
|
||||
*,
|
||||
error: str,
|
||||
planning_input: TransferPlanningInput,
|
||||
) -> TransferPlanCheckpoint:
|
||||
"""把确定性的宿主规划错误冻结为可重放的零步骤失败计划。"""
|
||||
source_path = task.fileitem.path
|
||||
resolved_transfer_type = (
|
||||
task.transfer_type
|
||||
or planning_input.requested_transfer_type
|
||||
or "copy"
|
||||
)
|
||||
checkpoint = TransferPlanCheckpoint(
|
||||
return TransferPlanCheckpoint(
|
||||
planning_input=planning_input,
|
||||
target_storage=task.fileitem.storage,
|
||||
root_target_path=source_path,
|
||||
@@ -300,13 +322,6 @@ class TransferPlanningOwner(_TransferOwnerBase):
|
||||
overwrite_mode=planning_input.overwrite_mode,
|
||||
rejection_error=error,
|
||||
)
|
||||
persisted = self._TransferChain__persist_transfer_checkpoint(
|
||||
task,
|
||||
planning_input=planning_input,
|
||||
checkpoint=checkpoint,
|
||||
)
|
||||
task.bind_plan_checkpoint(persisted)
|
||||
return self._plan_checkpoint_and_execute(task)
|
||||
|
||||
def _TransferChain__execute_planning_rejection(
|
||||
self,
|
||||
@@ -675,25 +690,32 @@ class TransferPlanningOwner(_TransferOwnerBase):
|
||||
source_oper: Any,
|
||||
) -> TransferPlanCheckpoint:
|
||||
"""在 provider 未接管时生成纯宿主计划,不执行任何文件写入。"""
|
||||
checkpoint = cast(
|
||||
Optional[TransferPlanCheckpoint],
|
||||
self.plan_transfer(
|
||||
fileitem=task.fileitem,
|
||||
meta=cast(MetaBase, task.meta),
|
||||
mediainfo=cast(Union[MediaInfo, MusicInfo], task.mediainfo),
|
||||
target_directory=task.target_directory,
|
||||
target_storage=task.target_storage,
|
||||
target_path=task.target_path,
|
||||
transfer_type=task.transfer_type,
|
||||
episodes_info=task.episodes_info,
|
||||
scrape=task.scrape,
|
||||
library_type_folder=task.library_type_folder,
|
||||
library_category_folder=task.library_category_folder,
|
||||
source_oper=source_oper,
|
||||
preview=bool(task.preview),
|
||||
try:
|
||||
checkpoint = cast(
|
||||
Optional[TransferPlanCheckpoint],
|
||||
self.plan_transfer(
|
||||
fileitem=task.fileitem,
|
||||
meta=cast(MetaBase, task.meta),
|
||||
mediainfo=cast(Union[MediaInfo, MusicInfo], task.mediainfo),
|
||||
target_directory=task.target_directory,
|
||||
target_storage=task.target_storage,
|
||||
target_path=task.target_path,
|
||||
transfer_type=task.transfer_type,
|
||||
episodes_info=task.episodes_info,
|
||||
scrape=task.scrape,
|
||||
library_type_folder=task.library_type_folder,
|
||||
library_category_folder=task.library_category_folder,
|
||||
source_oper=source_oper,
|
||||
preview=bool(task.preview),
|
||||
planning_input=planning_input,
|
||||
),
|
||||
)
|
||||
except TransferPlanningRejectedError as error:
|
||||
return self._TransferChain__build_planning_rejection_checkpoint(
|
||||
task,
|
||||
error=str(error),
|
||||
planning_input=planning_input,
|
||||
),
|
||||
)
|
||||
)
|
||||
if checkpoint is None:
|
||||
raise RuntimeError("文件整理模块未返回规划检查点")
|
||||
return checkpoint
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.application.history import (
|
||||
resolve_history,
|
||||
)
|
||||
from app.application.transfer.execution import (
|
||||
TransferExecutionCommand,
|
||||
TransferExecutionRepository,
|
||||
)
|
||||
from app.chain._contracts import TransferMixinHost
|
||||
@@ -477,8 +478,8 @@ class ManualHistoryMixin(_TransferOwnerBase):
|
||||
) -> Optional[Tuple[bool, str]]:
|
||||
"""将 durable 历史重试交还持久调度器,旧历史返回 ``None``。
|
||||
|
||||
durable 任务的历史、目标文件和步骤证据共同描述一次可恢复执行。任何历史
|
||||
入口都只能登记重试意图,不能删除这些证据后重新准入一条并行任务。
|
||||
普通重试和 AI 接管只登记重试意图;显式重新整理由
|
||||
``_delete_manual_transfer_history`` 先放弃确定失败任务,再重新准入。
|
||||
|
||||
:param history: 整理历史
|
||||
:param requested_by: 发起重试的稳定入口身份
|
||||
@@ -495,15 +496,25 @@ class ManualHistoryMixin(_TransferOwnerBase):
|
||||
history: TransferHistorySnapshot,
|
||||
transfer_history_oper: TransferHistoryRepository,
|
||||
) -> Tuple[bool, str]:
|
||||
"""删除手动重整历史;非成功移动记录同时清理可能存在的旧目标。"""
|
||||
durable_retry = self._request_durable_transfer_retry(
|
||||
history,
|
||||
requested_by="manual_reorganize",
|
||||
)
|
||||
if durable_retry is not None:
|
||||
_, message = durable_retry
|
||||
# 返回 False 阻止旧 caller 把“已登记”误当成“历史已删除”并继续执行。
|
||||
return False, message
|
||||
"""删除手动重整历史;失败 durable 回执先原子放弃再清理旧目标。"""
|
||||
task_id = getattr(history, "transfer_task_id", None)
|
||||
if task_id:
|
||||
settlement_revision = getattr(
|
||||
history,
|
||||
"transfer_settlement_revision",
|
||||
None,
|
||||
)
|
||||
if not settlement_revision:
|
||||
return False, "持久整理失败记录缺少结算版本,请刷新后重试"
|
||||
discard = TransferExecutionCommand(
|
||||
self.transfer_execution_repository
|
||||
).discard_failed(
|
||||
task_id=task_id,
|
||||
history_id=history.id,
|
||||
settlement_revision=settlement_revision,
|
||||
)
|
||||
if not discard.discarded:
|
||||
return False, discard.message
|
||||
if (
|
||||
history.dest_fileitem
|
||||
and not ManualHistoryMixin._is_successful_move_history(history)
|
||||
|
||||
@@ -710,20 +710,21 @@ class TransferWorkflowOwner(_TransferOwnerBase):
|
||||
reorganize or not transferd.status
|
||||
)
|
||||
if should_reorganize:
|
||||
durable_retry = self._request_durable_transfer_retry(
|
||||
transferd,
|
||||
requested_by="manual_reorganize",
|
||||
)
|
||||
if durable_retry is not None:
|
||||
accepted, message = durable_retry
|
||||
if accepted:
|
||||
logger.info(message)
|
||||
else:
|
||||
all_success = False
|
||||
logger.error(message)
|
||||
err_msgs.append(message)
|
||||
# durable 历史只登记唯一调度重试,不在当前调用重新准入。
|
||||
continue
|
||||
if not reorganize:
|
||||
durable_retry = self._request_durable_transfer_retry(
|
||||
transferd,
|
||||
requested_by="manual_reorganize",
|
||||
)
|
||||
if durable_retry is not None:
|
||||
accepted, message = durable_retry
|
||||
if accepted:
|
||||
logger.info(message)
|
||||
else:
|
||||
all_success = False
|
||||
logger.error(message)
|
||||
err_msgs.append(message)
|
||||
# 普通失败重试仍由唯一 durable 调度器继续原计划。
|
||||
continue
|
||||
state, message = self._delete_manual_transfer_history(
|
||||
history=transferd,
|
||||
transfer_history_oper=transfer_history_oper,
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.application.transfer.execution import (
|
||||
TransferExecutionSnapshot,
|
||||
TransferExecutionState,
|
||||
TransferExecutionStep,
|
||||
TransferFailureDiscardResult,
|
||||
TransferManualReviewDecision,
|
||||
TransferManualReviewPage,
|
||||
TransferManualReviewResult,
|
||||
@@ -42,6 +43,7 @@ from app.db.models.transferexecutionstep import (
|
||||
)
|
||||
from app.db.models.transferpending import TransferPending
|
||||
from app.db.oper.transferexecutionstep import TransferExecutionStepOper
|
||||
from app.db.oper.transferhistory import TransferHistoryOper
|
||||
from app.db.oper.transferpending import TransferPendingOper
|
||||
from app.db.uow import SqlAlchemyUnitOfWork
|
||||
|
||||
@@ -1197,6 +1199,110 @@ class TransactionalTransferExecutionRepository:
|
||||
self._rollback(transaction)
|
||||
raise
|
||||
|
||||
def discard_failed(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
history_id: int,
|
||||
settlement_revision: int,
|
||||
) -> TransferFailureDiscardResult:
|
||||
"""原子删除指定 FAILED pending、步骤证据并解除历史回执映射。"""
|
||||
with self._session_factory() as session:
|
||||
transaction = SqlAlchemyUnitOfWork(session)
|
||||
try:
|
||||
pending_oper = TransferPendingOper(session)
|
||||
history_oper = TransferHistoryOper(session)
|
||||
pending = pending_oper.get_by_task_id(task_id=task_id)
|
||||
if pending is None:
|
||||
history = history_oper.get(history_id)
|
||||
if history is None or history.transfer_task_id is None:
|
||||
return TransferFailureDiscardResult(
|
||||
discarded=True,
|
||||
state=None,
|
||||
message="失败整理任务已由并发请求放弃",
|
||||
)
|
||||
return TransferFailureDiscardResult(
|
||||
discarded=False,
|
||||
state=None,
|
||||
message="未找到与失败历史匹配的整理任务,请刷新后重试",
|
||||
)
|
||||
|
||||
state = TransferExecutionState(pending.execution_state)
|
||||
if state is not TransferExecutionState.FAILED:
|
||||
message = (
|
||||
"人工复核任务必须先完成专门判定"
|
||||
if state is TransferExecutionState.MANUAL_REVIEW
|
||||
else "整理任务当前状态不能放弃"
|
||||
)
|
||||
return TransferFailureDiscardResult(
|
||||
discarded=False,
|
||||
state=state,
|
||||
message=message,
|
||||
)
|
||||
if pending.lease_owner is not None or pending.lease_token is not None:
|
||||
return TransferFailureDiscardResult(
|
||||
discarded=False,
|
||||
state=state,
|
||||
message="整理任务仍被执行器占用,不能放弃",
|
||||
)
|
||||
if (
|
||||
pending.terminal_history_id != history_id
|
||||
or pending.settlement_revision != settlement_revision
|
||||
):
|
||||
return TransferFailureDiscardResult(
|
||||
discarded=False,
|
||||
state=state,
|
||||
message="整理任务失败回执已变化,请刷新后重试",
|
||||
)
|
||||
|
||||
deleted = pending_oper.stage_delete_terminal_failure(
|
||||
task_id=task_id,
|
||||
history_id=history_id,
|
||||
settlement_revision=settlement_revision,
|
||||
)
|
||||
if deleted != 1:
|
||||
session.expire_all()
|
||||
pending = pending_oper.get_by_task_id(task_id=task_id)
|
||||
history = history_oper.get(history_id)
|
||||
if pending is None and (
|
||||
history is None or history.transfer_task_id is None
|
||||
):
|
||||
return TransferFailureDiscardResult(
|
||||
discarded=True,
|
||||
state=None,
|
||||
message="失败整理任务已由并发请求放弃",
|
||||
)
|
||||
return TransferFailureDiscardResult(
|
||||
discarded=False,
|
||||
state=(
|
||||
TransferExecutionState(pending.execution_state)
|
||||
if pending is not None
|
||||
else None
|
||||
),
|
||||
message="整理任务状态已变化,未放弃失败任务",
|
||||
)
|
||||
|
||||
# PostgreSQL/启用外键的 SQLite 会级联删除;显式清理兼容独立测试库。
|
||||
TransferExecutionStepOper(session).stage_delete_task(task_id=task_id)
|
||||
detached = history_oper.stage_detach_failed_transfer_task(
|
||||
history_id=history_id,
|
||||
task_id=task_id,
|
||||
settlement_revision=settlement_revision,
|
||||
)
|
||||
if detached != 1:
|
||||
raise TransferExecutionConflictError(
|
||||
"失败整理任务删除后无法解除历史回执映射"
|
||||
)
|
||||
transaction.commit()
|
||||
return TransferFailureDiscardResult(
|
||||
discarded=True,
|
||||
state=TransferExecutionState.FAILED,
|
||||
message="已放弃失败整理任务",
|
||||
)
|
||||
except Exception:
|
||||
self._rollback(transaction)
|
||||
raise
|
||||
|
||||
def resolve_manual_review(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -627,6 +627,41 @@ class TransferHistory(Base):
|
||||
update(cls).where(cls.id == historyid).values(download_hash=download_hash)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def detach_failed_transfer_task(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
history_id: int,
|
||||
task_id: str,
|
||||
settlement_revision: int,
|
||||
) -> int:
|
||||
"""
|
||||
解除指定失败回执与 durable 任务的精确映射。
|
||||
|
||||
:param db: 数据库会话
|
||||
:param history_id: 失败终态历史标识
|
||||
:param task_id: 稳定任务标识
|
||||
:param settlement_revision: 失败回执结算版本
|
||||
:return: 更新的历史数
|
||||
"""
|
||||
if history_id <= 0 or not task_id or settlement_revision <= 0:
|
||||
return 0
|
||||
return execute_dml(
|
||||
db,
|
||||
update(cls)
|
||||
.where(
|
||||
cls.id == history_id,
|
||||
cls.transfer_task_id == task_id,
|
||||
cls.transfer_settlement_revision == settlement_revision,
|
||||
)
|
||||
.values(
|
||||
transfer_task_id=None,
|
||||
transfer_settlement_revision=None,
|
||||
),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def replace_by_src(cls, db: Session, **kwargs) -> "TransferHistory":
|
||||
"""
|
||||
|
||||
@@ -27,7 +27,12 @@ from sqlalchemy.sql.elements import ColumnElement
|
||||
from app.db.base import Base, execute_dml, get_id_column
|
||||
|
||||
_TRANSFER_EXECUTION_STEP = table("transferexecutionstep", column("task_id"))
|
||||
_TRANSFER_HISTORY = table("transferhistory", column("transfer_task_id"))
|
||||
_TRANSFER_HISTORY = table(
|
||||
"transferhistory",
|
||||
column("id"),
|
||||
column("transfer_task_id"),
|
||||
column("transfer_settlement_revision"),
|
||||
)
|
||||
|
||||
|
||||
class TransferPending(Base):
|
||||
@@ -737,6 +742,49 @@ class TransferPending(Base):
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def delete_terminal_failure(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
history_id: int,
|
||||
settlement_revision: int,
|
||||
) -> int:
|
||||
"""
|
||||
仅删除仍与指定失败历史精确绑定的无租约终态任务。
|
||||
|
||||
历史存在性子查询把用户看到的失败回执纳入同一个 CAS,避免陈旧页面误删
|
||||
已重试、已人工判定或已产生新结算版本的任务。
|
||||
:param db: 数据库会话
|
||||
:param task_id: 稳定任务标识
|
||||
:param history_id: 失败终态历史标识
|
||||
:param settlement_revision: 失败回执结算版本
|
||||
:return: 删除的任务数,1 表示成功取得放弃权
|
||||
"""
|
||||
if not task_id or history_id <= 0 or settlement_revision <= 0:
|
||||
return 0
|
||||
return execute_dml(
|
||||
db,
|
||||
delete(cls).where(
|
||||
cls.task_id == task_id,
|
||||
cls.execution_state == "failed",
|
||||
cls.terminal_history_id == history_id,
|
||||
cls.settlement_revision == settlement_revision,
|
||||
cls.lease_owner.is_(None),
|
||||
cls.lease_token.is_(None),
|
||||
exists(
|
||||
select(_TRANSFER_HISTORY.c.id).where(
|
||||
_TRANSFER_HISTORY.c.id == history_id,
|
||||
_TRANSFER_HISTORY.c.transfer_task_id == cls.task_id,
|
||||
_TRANSFER_HISTORY.c.transfer_settlement_revision
|
||||
== cls.settlement_revision,
|
||||
)
|
||||
),
|
||||
),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def delete_terminal_success(
|
||||
cls,
|
||||
|
||||
@@ -509,6 +509,23 @@ class TransferHistoryOper(DbOper):
|
||||
self._db.flush()
|
||||
return history
|
||||
|
||||
def stage_detach_failed_transfer_task(
|
||||
self,
|
||||
*,
|
||||
history_id: int,
|
||||
task_id: str,
|
||||
settlement_revision: int,
|
||||
) -> int:
|
||||
"""在调用方事务内解除指定失败历史与 durable 任务的精确映射。"""
|
||||
if not isinstance(self._db, Session):
|
||||
raise RuntimeError("整理失败回执解绑需要调用方提供同步 Session")
|
||||
return TransferHistory.detach_failed_transfer_task(
|
||||
self._db,
|
||||
history_id=history_id,
|
||||
task_id=task_id,
|
||||
settlement_revision=settlement_revision,
|
||||
)
|
||||
|
||||
def update_download_hash(self, historyid, download_hash):
|
||||
"""
|
||||
补充转移记录download_hash
|
||||
|
||||
@@ -521,6 +521,23 @@ class TransferPendingOper(DbOper):
|
||||
)
|
||||
)
|
||||
|
||||
def stage_delete_terminal_failure(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
history_id: int,
|
||||
settlement_revision: int,
|
||||
) -> int:
|
||||
"""暂存与指定失败回执精确匹配的无租约 pending 删除。"""
|
||||
return self._execute_sync_write(
|
||||
lambda session: TransferPending.delete_terminal_failure(
|
||||
session,
|
||||
task_id=task_id,
|
||||
history_id=history_id,
|
||||
settlement_revision=settlement_revision,
|
||||
)
|
||||
)
|
||||
|
||||
def stage_delete_terminal_success(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.application.transfer.workflow import (
|
||||
TransferPlanCheckpoint,
|
||||
TransferPlanItem,
|
||||
TransferPlanningInput,
|
||||
TransferPlanningRejectedError,
|
||||
)
|
||||
from app.domain.context import MediaInfo, MusicInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
@@ -380,7 +381,7 @@ class TransHandler:
|
||||
preview=preview,
|
||||
skip_reason="未识别到文件集数,识别为特典/附加视频文件",
|
||||
)
|
||||
raise ValueError("未识别到文件集数")
|
||||
raise TransferPlanningRejectedError("未识别到文件集数")
|
||||
planning_meta.end_season = None
|
||||
if planning_meta.total_season:
|
||||
planning_meta.total_season = 1
|
||||
|
||||
@@ -1059,6 +1059,7 @@ def test_receipt_replay_survives_real_history_command_cleanup(cleanup):
|
||||
command = TransferHistoryMutationCommand(
|
||||
repository=TransferHistoryOper(session),
|
||||
download_repository=Mock(),
|
||||
transfer_execution_repository=Mock(),
|
||||
unit_of_work=SqlAlchemyUnitOfWork(session),
|
||||
file_item_factory=Mock(),
|
||||
delete_media_file=Mock(return_value=True),
|
||||
|
||||
@@ -7,6 +7,10 @@ from app.application.history import (
|
||||
DownloadHistoryMutationCommand,
|
||||
TransferHistoryMutationCommand,
|
||||
)
|
||||
from app.application.transfer.execution import (
|
||||
TransferExecutionState,
|
||||
TransferFailureDiscardResult,
|
||||
)
|
||||
|
||||
|
||||
def _transfer_command(*, history=None, delete_result=True, commit_error=None):
|
||||
@@ -14,11 +18,13 @@ def _transfer_command(*, history=None, delete_result=True, commit_error=None):
|
||||
repository = Mock()
|
||||
repository.get.return_value = history
|
||||
download_repository = Mock()
|
||||
transfer_execution_repository = Mock()
|
||||
unit_of_work = Mock()
|
||||
unit_of_work.commit.side_effect = commit_error
|
||||
dependencies = {
|
||||
"repository": repository,
|
||||
"download_repository": download_repository,
|
||||
"transfer_execution_repository": transfer_execution_repository,
|
||||
"unit_of_work": unit_of_work,
|
||||
"file_item_factory": lambda payload: SimpleNamespace(**payload),
|
||||
"delete_media_file": Mock(return_value=delete_result),
|
||||
@@ -38,6 +44,7 @@ def _history():
|
||||
src_fileitem={"path": "/downloads/demo.mkv"},
|
||||
dest_fileitem={"path": "/media/demo.mkv"},
|
||||
transfer_task_id=None,
|
||||
transfer_settlement_revision=None,
|
||||
)
|
||||
|
||||
|
||||
@@ -129,22 +136,57 @@ def test_transfer_truncate_uses_single_transaction():
|
||||
dependencies["unit_of_work"].commit.assert_called_once_with()
|
||||
|
||||
|
||||
def test_transfer_delete_rejects_durable_receipt_before_file_side_effects():
|
||||
"""durable 回执不能被历史 API 连同源或目标文件一起删除。"""
|
||||
def test_transfer_delete_rejects_nonfailed_durable_receipt_before_file_side_effects():
|
||||
"""非 FAILED durable 回执不能被历史 API 连同文件一起删除。"""
|
||||
history = _history()
|
||||
history.transfer_task_id = "task-durable"
|
||||
history.transfer_settlement_revision = 2
|
||||
command, dependencies = _transfer_command(history=history)
|
||||
dependencies["transfer_execution_repository"].discard_failed.return_value = (
|
||||
TransferFailureDiscardResult(
|
||||
discarded=False,
|
||||
state=TransferExecutionState.MANUAL_REVIEW,
|
||||
message="人工复核任务必须先完成专门判定",
|
||||
)
|
||||
)
|
||||
|
||||
result = command.delete(7, delete_source=True, delete_destination=True)
|
||||
|
||||
assert result.success is False
|
||||
assert result.message == "持久整理失败记录不可删除,请使用重试或人工复核入口"
|
||||
assert result.message == "人工复核任务必须先完成专门判定"
|
||||
assert result.history == "retained"
|
||||
dependencies["delete_media_file"].assert_not_called()
|
||||
dependencies["repository"].stage_delete.assert_not_called()
|
||||
dependencies["unit_of_work"].commit.assert_not_called()
|
||||
|
||||
|
||||
def test_transfer_delete_discards_failed_durable_receipt_before_cleanup():
|
||||
"""FAILED durable 回执应先放弃任务,再按普通历史执行文件和记录删除。"""
|
||||
history = _history()
|
||||
history.transfer_task_id = "task-durable"
|
||||
history.transfer_settlement_revision = 3
|
||||
command, dependencies = _transfer_command(history=history)
|
||||
dependencies["transfer_execution_repository"].discard_failed.return_value = (
|
||||
TransferFailureDiscardResult(
|
||||
discarded=True,
|
||||
state=TransferExecutionState.FAILED,
|
||||
message="已放弃失败整理任务",
|
||||
)
|
||||
)
|
||||
|
||||
result = command.delete(7, delete_destination=True)
|
||||
|
||||
assert result.success is True
|
||||
dependencies["transfer_execution_repository"].discard_failed.assert_called_once_with(
|
||||
task_id="task-durable",
|
||||
history_id=7,
|
||||
settlement_revision=3,
|
||||
)
|
||||
dependencies["delete_media_file"].assert_called_once()
|
||||
dependencies["repository"].stage_delete.assert_called_once_with(7)
|
||||
dependencies["unit_of_work"].commit.assert_called_once_with()
|
||||
|
||||
|
||||
def test_transfer_destination_delete_failure_keeps_history_and_reports_step():
|
||||
"""目标文件删除失败时必须保留历史,并向调用方报告失败步骤。"""
|
||||
command, dependencies = _transfer_command(
|
||||
@@ -169,6 +211,7 @@ def test_transfer_delete_commits_completed_source_when_destination_fails():
|
||||
dependencies = {
|
||||
"repository": repository,
|
||||
"download_repository": Mock(),
|
||||
"transfer_execution_repository": Mock(),
|
||||
"unit_of_work": Mock(),
|
||||
"file_item_factory": lambda payload: SimpleNamespace(**payload),
|
||||
"file_exists": Mock(return_value=True),
|
||||
@@ -201,6 +244,9 @@ def test_transfer_delete_treats_missing_requested_file_as_completed():
|
||||
command = TransferHistoryMutationCommand(
|
||||
repository=dependencies["repository"],
|
||||
download_repository=dependencies["download_repository"],
|
||||
transfer_execution_repository=dependencies[
|
||||
"transfer_execution_repository"
|
||||
],
|
||||
unit_of_work=dependencies["unit_of_work"],
|
||||
file_item_factory=dependencies["file_item_factory"],
|
||||
file_exists=dependencies["file_exists"],
|
||||
|
||||
@@ -622,6 +622,61 @@ def test_manual_reorganize_removes_success_history_and_old_target(monkeypatch):
|
||||
assert planned == [fileitem.path]
|
||||
|
||||
|
||||
def test_explicit_durable_reorganize_discards_old_task_and_replans(monkeypatch):
|
||||
"""显式重新整理 durable 失败历史时不得登记原计划重试,应清理后重新规划。"""
|
||||
chain = make_transfer_chain()
|
||||
fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv")
|
||||
history = SimpleNamespace(
|
||||
id=12,
|
||||
transfer_task_id="transfer-task-12",
|
||||
transfer_settlement_revision=1,
|
||||
status=False,
|
||||
mode="copy",
|
||||
dest_fileitem=None,
|
||||
download_hash=None,
|
||||
downloader=None,
|
||||
src=fileitem.path,
|
||||
src_storage=fileitem.storage,
|
||||
)
|
||||
planned = []
|
||||
deleted = []
|
||||
_patch_transfer_planning(
|
||||
monkeypatch,
|
||||
chain,
|
||||
fileitem,
|
||||
history,
|
||||
planned,
|
||||
deleted,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
chain,
|
||||
"_request_durable_transfer_retry",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("显式重新整理不得登记旧 durable 计划重试")
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
chain,
|
||||
"_delete_manual_transfer_history",
|
||||
lambda history, transfer_history_oper: deleted.append(
|
||||
("history", history.id)
|
||||
) or (True, ""),
|
||||
)
|
||||
|
||||
state, message = TransferChain.do_transfer(
|
||||
chain,
|
||||
fileitem=fileitem,
|
||||
background=False,
|
||||
manual=True,
|
||||
reorganize=True,
|
||||
)
|
||||
|
||||
assert state is True
|
||||
assert message == ""
|
||||
assert deleted == [("history", 12)]
|
||||
assert planned == [fileitem.path]
|
||||
|
||||
|
||||
def test_manual_reorganize_keeps_successful_move_target_as_source(monkeypatch):
|
||||
"""成功移动后的目标是当前重整源,只能删历史记录,不能先删除文件。"""
|
||||
chain = make_transfer_chain()
|
||||
|
||||
@@ -4,6 +4,7 @@ from types import SimpleNamespace
|
||||
|
||||
from app.application.transfer.execution import (
|
||||
TransferExecutionState,
|
||||
TransferFailureDiscardResult,
|
||||
TransferRetryRequestResult,
|
||||
)
|
||||
from app.chain.transfer import TransferChain
|
||||
@@ -31,6 +32,26 @@ class _RetryCommand:
|
||||
return self.result
|
||||
|
||||
|
||||
class _DiscardCommand:
|
||||
"""记录显式重新整理提交的失败任务放弃请求。"""
|
||||
|
||||
calls: list[tuple[object, dict]] = []
|
||||
result = TransferFailureDiscardResult(
|
||||
discarded=True,
|
||||
state=TransferExecutionState.FAILED,
|
||||
message="已放弃失败整理任务",
|
||||
)
|
||||
|
||||
def __init__(self, repository: object) -> None:
|
||||
"""保存测试仓储实例。"""
|
||||
self._repository = repository
|
||||
|
||||
def discard_failed(self, **kwargs) -> TransferFailureDiscardResult:
|
||||
"""记录放弃请求并返回用例指定结果。"""
|
||||
self.calls.append((self._repository, kwargs))
|
||||
return self.result
|
||||
|
||||
|
||||
def _install_retry_port(monkeypatch) -> object:
|
||||
"""构造不会接触数据库的 execution 端口并安装命令替身。"""
|
||||
repository = object()
|
||||
@@ -48,6 +69,22 @@ def _install_retry_port(monkeypatch) -> object:
|
||||
return repository
|
||||
|
||||
|
||||
def _install_discard_port(monkeypatch) -> object:
|
||||
"""构造不会接触数据库的 execution 端口并安装放弃命令替身。"""
|
||||
repository = object()
|
||||
_DiscardCommand.calls = []
|
||||
_DiscardCommand.result = TransferFailureDiscardResult(
|
||||
discarded=True,
|
||||
state=TransferExecutionState.FAILED,
|
||||
message="已放弃失败整理任务",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.chain.transfer.records.TransferExecutionCommand",
|
||||
_DiscardCommand,
|
||||
)
|
||||
return repository
|
||||
|
||||
|
||||
def test_durable_history_redo_only_requests_persistent_retry(monkeypatch):
|
||||
"""durable 重做不得检查源文件、重新识别或重新准入执行。"""
|
||||
repository = _install_retry_port(monkeypatch)
|
||||
@@ -91,12 +128,13 @@ def test_durable_history_redo_only_requests_persistent_retry(monkeypatch):
|
||||
]
|
||||
|
||||
|
||||
def test_durable_manual_cleanup_keeps_target_history_and_failure_budget(monkeypatch):
|
||||
"""手动重整命中 durable 历史时不得先删目标、历史或失败计数。"""
|
||||
repository = _install_retry_port(monkeypatch)
|
||||
def test_durable_manual_cleanup_discards_task_and_removes_old_state(monkeypatch):
|
||||
"""显式重整命中 FAILED durable 历史时应放弃任务并清理旧状态。"""
|
||||
repository = _install_discard_port(monkeypatch)
|
||||
history = SimpleNamespace(
|
||||
id=82,
|
||||
transfer_task_id="transfer-task-82",
|
||||
transfer_settlement_revision=4,
|
||||
status=False,
|
||||
mode="copy",
|
||||
src="/downloads/source.mkv",
|
||||
@@ -107,22 +145,23 @@ def test_durable_manual_cleanup_keeps_target_history_and_failure_budget(monkeypa
|
||||
"type": "file",
|
||||
},
|
||||
)
|
||||
history_port = SimpleNamespace(
|
||||
delete=lambda _history_id: (_ for _ in ()).throw(
|
||||
AssertionError("durable 历史不得删除")
|
||||
)
|
||||
)
|
||||
deleted = []
|
||||
cleared = []
|
||||
history_port = SimpleNamespace(delete=lambda history_id: deleted.append(
|
||||
("history", history_id)
|
||||
))
|
||||
monkeypatch.setattr(
|
||||
"app.chain.transfer.records.StorageChain",
|
||||
lambda: (_ for _ in ()).throw(
|
||||
AssertionError("durable 目标不得删除")
|
||||
lambda: SimpleNamespace(
|
||||
exists=lambda _fileitem: True,
|
||||
delete_media_file=lambda fileitem: deleted.append(
|
||||
("target", fileitem.path)
|
||||
) or True,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.chain.transfer.records.clear_transfer_failures",
|
||||
lambda *_args: (_ for _ in ()).throw(
|
||||
AssertionError("durable 失败计数不得清零")
|
||||
),
|
||||
lambda *args: cleared.append(args),
|
||||
)
|
||||
chain = object.__new__(TransferChain)
|
||||
chain.transfer_execution_repository = repository
|
||||
@@ -132,9 +171,72 @@ def test_durable_manual_cleanup_keeps_target_history_and_failure_budget(monkeypa
|
||||
transfer_history_oper=history_port,
|
||||
)
|
||||
|
||||
assert state is True
|
||||
assert message == ""
|
||||
assert _DiscardCommand.calls == [
|
||||
(
|
||||
repository,
|
||||
{
|
||||
"task_id": "transfer-task-82",
|
||||
"history_id": 82,
|
||||
"settlement_revision": 4,
|
||||
},
|
||||
)
|
||||
]
|
||||
assert deleted == [
|
||||
("target", "/library/source.mkv"),
|
||||
("history", 82),
|
||||
]
|
||||
assert cleared == [("/downloads/source.mkv", "local")]
|
||||
|
||||
|
||||
def test_durable_manual_cleanup_rejects_nonfailed_state(monkeypatch):
|
||||
"""非 FAILED durable 任务被拒绝后不得删除目标、历史或失败计数。"""
|
||||
repository = _install_discard_port(monkeypatch)
|
||||
_DiscardCommand.result = TransferFailureDiscardResult(
|
||||
discarded=False,
|
||||
state=TransferExecutionState.MANUAL_REVIEW,
|
||||
message="人工复核任务必须先完成专门判定",
|
||||
)
|
||||
history = SimpleNamespace(
|
||||
id=85,
|
||||
transfer_task_id="transfer-task-85",
|
||||
transfer_settlement_revision=2,
|
||||
status=False,
|
||||
mode="copy",
|
||||
src="/downloads/source.mkv",
|
||||
src_storage="local",
|
||||
dest_fileitem={
|
||||
"storage": "local",
|
||||
"path": "/library/source.mkv",
|
||||
"type": "file",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.chain.transfer.records.StorageChain",
|
||||
lambda: (_ for _ in ()).throw(AssertionError("拒绝后不得删除目标")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.chain.transfer.records.clear_transfer_failures",
|
||||
lambda *_args: (_ for _ in ()).throw(
|
||||
AssertionError("拒绝后不得清理失败计数")
|
||||
),
|
||||
)
|
||||
chain = object.__new__(TransferChain)
|
||||
chain.transfer_execution_repository = repository
|
||||
history_port = SimpleNamespace(
|
||||
delete=lambda _history_id: (_ for _ in ()).throw(
|
||||
AssertionError("拒绝后不得删除历史")
|
||||
)
|
||||
)
|
||||
|
||||
state, message = chain._delete_manual_transfer_history(
|
||||
history=history,
|
||||
transfer_history_oper=history_port,
|
||||
)
|
||||
|
||||
assert state is False
|
||||
assert message == "整理任务已登记重试"
|
||||
assert _RetryCommand.calls[0][1]["requested_by"] == "manual_reorganize"
|
||||
assert message == "人工复核任务必须先完成专门判定"
|
||||
|
||||
|
||||
def test_durable_ai_button_bypasses_agent_and_requests_scheduler(monkeypatch):
|
||||
|
||||
@@ -134,6 +134,56 @@ def _seed_pending(
|
||||
session.commit()
|
||||
|
||||
|
||||
def _seed_failed_receipt(
|
||||
factory,
|
||||
*,
|
||||
task_id: str = "task-1",
|
||||
execution_state: str = "failed",
|
||||
settlement_revision: int = 1,
|
||||
) -> int:
|
||||
"""写入带失败历史和步骤证据的无租约 durable 终态任务。"""
|
||||
_seed_pending(factory, task_id=task_id)
|
||||
with factory() as session:
|
||||
pending = session.scalar(
|
||||
select(TransferPending).where(TransferPending.task_id == task_id)
|
||||
)
|
||||
assert pending is not None
|
||||
history = TransferHistory(
|
||||
transfer_task_id=task_id,
|
||||
transfer_settlement_revision=settlement_revision,
|
||||
src=f"/{task_id}.mkv",
|
||||
src_storage="local",
|
||||
status=False,
|
||||
errmsg="terminal failure",
|
||||
)
|
||||
session.add(history)
|
||||
session.flush()
|
||||
pending.execution_state = execution_state
|
||||
pending.settlement_revision = settlement_revision
|
||||
pending.terminal_history_id = history.id
|
||||
pending.lease_owner = None
|
||||
pending.lease_token = None
|
||||
pending.lease_expires_at = None
|
||||
session.add(TransferExecutionStep(
|
||||
task_id=task_id,
|
||||
operation_id=f"operation-{task_id}",
|
||||
checkpoint_fingerprint=_plan_fingerprint(task_id),
|
||||
ordinal=0,
|
||||
phase="transfer",
|
||||
kind="materialize_target",
|
||||
state="failed",
|
||||
attempt_token=None,
|
||||
attempt_count=1,
|
||||
intent_version=1,
|
||||
intent_payload={"path": f"/{task_id}.mkv"},
|
||||
last_error="terminal failure",
|
||||
prepared_at="2026-08-27 09:00:00",
|
||||
updated_at="2026-08-27 09:30:00",
|
||||
))
|
||||
session.commit()
|
||||
return history.id
|
||||
|
||||
|
||||
def _repository(factory, token_values: list[str] | None = None):
|
||||
"""构造固定时钟与可预测 attempt token 的执行命令。"""
|
||||
repository = TransactionalTransferExecutionRepository(
|
||||
@@ -833,6 +883,81 @@ def test_user_retry_is_single_generation_and_rejects_manual_review(execution_sto
|
||||
assert "人工" in rejected.message
|
||||
|
||||
|
||||
def test_discard_failed_removes_execution_evidence_and_detaches_history(
|
||||
execution_store,
|
||||
) -> None:
|
||||
"""放弃匹配的 FAILED 任务应删除执行证据,并把历史恢复为普通记录。"""
|
||||
history_id = _seed_failed_receipt(execution_store)
|
||||
_, command = _repository(execution_store)
|
||||
|
||||
result = command.discard_failed(
|
||||
task_id="task-1",
|
||||
history_id=history_id,
|
||||
settlement_revision=1,
|
||||
)
|
||||
|
||||
assert result.discarded is True
|
||||
assert result.state is TransferExecutionState.FAILED
|
||||
with execution_store() as session:
|
||||
assert session.scalar(select(TransferPending)) is None
|
||||
assert session.scalar(select(TransferExecutionStep)) is None
|
||||
history = session.scalar(select(TransferHistory))
|
||||
assert history is not None
|
||||
assert history.id == history_id
|
||||
assert history.transfer_task_id is None
|
||||
assert history.transfer_settlement_revision is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("execution_state", ("retry_wait", "manual_review"))
|
||||
def test_discard_failed_rejects_nonfailed_execution_state(
|
||||
execution_store,
|
||||
execution_state,
|
||||
) -> None:
|
||||
"""等待重试和人工复核任务不得通过历史维护入口删除执行证据。"""
|
||||
history_id = _seed_failed_receipt(
|
||||
execution_store,
|
||||
execution_state=execution_state,
|
||||
)
|
||||
_, command = _repository(execution_store)
|
||||
|
||||
result = command.discard_failed(
|
||||
task_id="task-1",
|
||||
history_id=history_id,
|
||||
settlement_revision=1,
|
||||
)
|
||||
|
||||
assert result.discarded is False
|
||||
assert result.state is TransferExecutionState(execution_state)
|
||||
with execution_store() as session:
|
||||
assert session.scalar(select(TransferPending)) is not None
|
||||
assert session.scalar(select(TransferExecutionStep)) is not None
|
||||
history = session.scalar(select(TransferHistory))
|
||||
assert history is not None
|
||||
assert history.transfer_task_id == "task-1"
|
||||
|
||||
|
||||
def test_discard_failed_rejects_stale_settlement_revision(execution_store) -> None:
|
||||
"""陈旧页面携带的结算版本不能放弃已经变化的失败任务。"""
|
||||
history_id = _seed_failed_receipt(execution_store, settlement_revision=3)
|
||||
_, command = _repository(execution_store)
|
||||
|
||||
result = command.discard_failed(
|
||||
task_id="task-1",
|
||||
history_id=history_id,
|
||||
settlement_revision=2,
|
||||
)
|
||||
|
||||
assert result.discarded is False
|
||||
assert "已变化" in result.message
|
||||
with execution_store() as session:
|
||||
pending = session.scalar(select(TransferPending))
|
||||
history = session.scalar(select(TransferHistory))
|
||||
assert pending is not None
|
||||
assert pending.settlement_revision == 3
|
||||
assert history is not None
|
||||
assert history.transfer_settlement_revision == 3
|
||||
|
||||
|
||||
def test_manual_not_applied_decision_is_audited_and_schedules_same_step(
|
||||
execution_store,
|
||||
) -> None:
|
||||
|
||||
@@ -10,7 +10,6 @@ from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from app import schemas
|
||||
from app.runtime.config import settings
|
||||
from app.application.history import (
|
||||
HistoryGateAction,
|
||||
TransferHistoryMutationCommand,
|
||||
@@ -19,6 +18,7 @@ from app.application.history import (
|
||||
failed_retry_count,
|
||||
record_transfer_failure,
|
||||
)
|
||||
from app.runtime.config import settings
|
||||
|
||||
|
||||
def _reset_failed_retries(src_path, storage=None):
|
||||
@@ -115,6 +115,7 @@ def test_delete_transfer_history_endpoint_clears_retry_count(monkeypatch):
|
||||
command = TransferHistoryMutationCommand(
|
||||
repository=repository,
|
||||
download_repository=Mock(),
|
||||
transfer_execution_repository=Mock(),
|
||||
unit_of_work=Mock(),
|
||||
file_item_factory=lambda payload: SimpleNamespace(**payload),
|
||||
delete_media_file=Mock(return_value=True),
|
||||
|
||||
@@ -1344,6 +1344,33 @@ def test_checkpoint_commit_failure_blocks_executor_and_keeps_accepted():
|
||||
assert "commit failed" in repository.record_planning_failure.call_args.kwargs["error"]
|
||||
|
||||
|
||||
def test_host_planning_value_error_commits_rejection_instead_of_retrying():
|
||||
"""确定性的宿主规划错误必须形成失败终态,不能留在 accepted 周期回放。"""
|
||||
repository = Mock()
|
||||
task = _task()
|
||||
planning_input = _planning_input()
|
||||
task.bind_admission_task_id("task-planning-rejection")
|
||||
_bind_planning_input(task, planning_input)
|
||||
repository.checkpoint_plan.side_effect = lambda **kwargs: _planned_admission(
|
||||
task,
|
||||
kwargs["checkpoint"],
|
||||
)
|
||||
chain = _chain(repository=repository)
|
||||
chain.plan_transfer.side_effect = transfer_application.TransferPlanningRejectedError(
|
||||
"未识别到文件集数"
|
||||
)
|
||||
|
||||
result = chain._plan_checkpoint_and_execute(task)
|
||||
|
||||
assert result.success is False
|
||||
assert result.message == "未识别到文件集数"
|
||||
assert task.plan_checkpoint is not None
|
||||
assert task.plan_checkpoint.rejection_error == "未识别到文件集数"
|
||||
assert task.plan_checkpoint.items == ()
|
||||
assert task.execution_checkpoint is not None
|
||||
repository.record_planning_failure.assert_not_called()
|
||||
|
||||
|
||||
def test_post_commit_crash_replays_frozen_plan_without_replanning():
|
||||
"""commit 后执行崩溃仍保留 planned checkpoint,重启后跳过 rename 规划。"""
|
||||
repository = Mock()
|
||||
|
||||
@@ -205,7 +205,7 @@ def test_chain_base_plan_transfer_keeps_internal_dto_type_only():
|
||||
"""规划入口运行时只做内部调度,不要求导入或重复导出 DTO。"""
|
||||
chain = object.__new__(ChainBase)
|
||||
checkpoint = Mock(name="checkpoint")
|
||||
chain.run_module = Mock(return_value=checkpoint)
|
||||
chain.run_module_strict = Mock(return_value=checkpoint)
|
||||
meta = Mock(name="meta")
|
||||
mediainfo = Mock(name="mediainfo")
|
||||
|
||||
@@ -216,7 +216,7 @@ def test_chain_base_plan_transfer_keeps_internal_dto_type_only():
|
||||
)
|
||||
|
||||
assert returned is checkpoint
|
||||
chain.run_module.assert_called_once_with(
|
||||
chain.run_module_strict.assert_called_once_with(
|
||||
"plan_transfer",
|
||||
fileitem=_fileitem(),
|
||||
meta=meta,
|
||||
|
||||
Reference in New Issue
Block a user