From e9de149dbf5d2db00fa7644c4f319495fcd689d0 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 28 Aug 2026 00:03:36 +0800 Subject: [PATCH] refactor(transfer): complete durable recovery state machine --- app/application/transfer/execution.py | 81 +- app/application/transfer/workflow.py | 195 +---- app/chain/transfer.py | 736 ++++++++-------- app/db/adapters/chain.py | 8 + app/db/adapters/transfer/admission.py | 26 +- app/db/adapters/transfer/execution.py | 424 +++++++++- app/db/models/transferpending.py | 145 ++-- app/db/oper/transferpending.py | 18 +- app/schemas/plugin.py | 5 +- app/sdk/_legacy/transferpending.py | 42 + database/versions/b1e7d3f5a9c2_3_0_13.py | 84 +- database/versions/c2f8a4d6e1b3_3_0_14.py | 23 +- database/versions/d3a9e5f7b2c4_3_0_15.py | 42 +- database/versions/e3d9f4b7c806_3_0_4.py | 115 ++- database/versions/e5c7a9b1d3f6_3_0_16.py | 112 ++- database/versions/f6d8b0c2e4a7_3_0_17.py | 703 ++++++++++++++++ docs/architecture-optimization-checklist.md | 4 +- docs/architecture-refactor-roadmap.md | 2 +- docs/rules/05-architecture.md | 3 + .../architecture/dependency-baseline.json | 4 +- .../fixtures/architecture/mypy-baseline.json | 6 +- tests/test_architecture_dependencies.py | 95 +++ tests/test_chain_durable_events.py | 263 +++++- tests/test_db_media_identity_normalizer.py | 10 + tests/test_db_transferpending_queries.py | 181 +++- tests/test_legacy_import_compat.py | 52 ++ tests/test_music_transfer.py | 33 +- tests/test_transfer_admission_migration.py | 40 + tests/test_transfer_execution_migration.py | 118 ++- tests/test_transfer_execution_persistence.py | 461 +++++++++- tests/test_transfer_execution_runner.py | 218 ++++- tests/test_transfer_failed_retry_scheduler.py | 126 --- tests/test_transfer_job_manager.py | 134 ++- tests/test_transfer_lease_migration.py | 27 + tests/test_transfer_lease_persistence.py | 8 +- tests/test_transfer_legacy_terminal_compat.py | 34 +- .../test_transfer_manual_review_discovery.py | 51 +- tests/test_transfer_overwrite_declined.py | 47 +- ...test_transfer_pending_initial_migration.py | 97 +++ tests/test_transfer_pending_replay.py | 159 +++- tests/test_transfer_planning_checkpoint.py | 311 ++++++- tests/test_transfer_planning_migration.py | 61 +- tests/test_transfer_planning_persistence.py | 33 +- tests/test_transfer_queue_service.py | 24 +- .../test_transfer_reconciliation_migration.py | 796 ++++++++++++++++++ tests/test_transfer_settling_recovery.py | 230 ++++- tests/test_transfer_tmdb_category.py | 55 +- tests/test_transfer_worker_lifecycle.py | 119 ++- 48 files changed, 5421 insertions(+), 1140 deletions(-) create mode 100644 database/versions/f6d8b0c2e4a7_3_0_17.py delete mode 100644 tests/test_transfer_failed_retry_scheduler.py create mode 100644 tests/test_transfer_pending_initial_migration.py create mode 100644 tests/test_transfer_reconciliation_migration.py diff --git a/app/application/transfer/execution.py b/app/application/transfer/execution.py index 335dc5fcf..7e145cbe4 100644 --- a/app/application/transfer/execution.py +++ b/app/application/transfer/execution.py @@ -43,6 +43,14 @@ class TransferTerminalState(StrEnum): FAILED = "failed" +class TransferExecutionOutcome(StrEnum): + """描述执行检查点可裁决的业务结果。""" + + SUCCEEDED = "succeeded" + FAILED = "failed" + OVERWRITE_SKIPPED = "overwrite_skipped" + + class TransferOperationObservationState(StrEnum): """描述重启后对遗留 STARTED 外部操作的严格探测结论。""" @@ -249,6 +257,29 @@ class TransferExecutionCheckpoint: raise ValueError("整理执行检查点包含无效操作标识") if not self.operation_ids and not self.skip_reason: raise ValueError("零副作用整理执行检查点必须记录 skip_reason") + raw_outcome = self.payload.get("outcome") + try: + outcome = TransferExecutionOutcome( + raw_outcome if isinstance(raw_outcome, str) else "" + ) + except (TypeError, ValueError) as error: + raise ValueError("整理执行检查点 outcome 无效") from error + transferinfo = self.payload.get("transferinfo") + if transferinfo is not None: + if not isinstance(transferinfo, dict): + raise ValueError("整理执行检查点 TransferInfo 必须是 JSON 对象") + expected_success = outcome is TransferExecutionOutcome.SUCCEEDED + expected_overwrite_skip = ( + outcome is TransferExecutionOutcome.OVERWRITE_SKIPPED + ) + if ( + bool(transferinfo.get("success")) != expected_success + or bool(transferinfo.get("overwrite_skipped")) + != expected_overwrite_skip + ): + raise ValueError("整理执行 checkpoint outcome 与 TransferInfo 不一致") + elif outcome is TransferExecutionOutcome.OVERWRITE_SKIPPED: + raise ValueError("覆盖跳过执行检查点缺少冻结 TransferInfo") _canonical_json(self.payload) if build_transfer_checkpoint_fingerprint(self.to_payload()) != self.fingerprint: raise ValueError("整理执行检查点内容与指纹不一致") @@ -285,6 +316,35 @@ class TransferExecutionCheckpoint: "skip_reason": self.skip_reason, } + def validate_settlement_outcome( + self, + settlement_outcome: str, + ) -> TransferTerminalState: + """解析执行事实,并校验同事务历史裁决给出的结算方向。""" + raw_outcome = self.payload.get("outcome") + try: + outcome = TransferExecutionOutcome( + raw_outcome if isinstance(raw_outcome, str) else "" + ) + except (TypeError, ValueError) as error: + raise TransferExecutionConflictError( + "整理执行检查点 outcome 无效" + ) from error + try: + terminal_state = TransferTerminalState(settlement_outcome) + except (TypeError, ValueError) as error: + raise TransferExecutionConflictError( + "整理结算 outcome 无效" + ) from error + if ( + outcome != TransferExecutionOutcome.OVERWRITE_SKIPPED + and outcome.value != terminal_state.value + ): + raise TransferExecutionConflictError( + "整理结算 outcome 与执行检查点不一致" + ) + return terminal_state + @classmethod def from_payload( cls, @@ -293,6 +353,8 @@ class TransferExecutionCheckpoint: fingerprint: str, ) -> "TransferExecutionCheckpoint": """解析并校验数据库中的执行检查点版本与稳定指纹。""" + if not isinstance(payload, Mapping): + raise TransferExecutionConflictError("整理执行检查点 JSON 结构无效") serialized = dict(payload) if build_transfer_checkpoint_fingerprint(serialized) != fingerprint: raise TransferExecutionConflictError("整理执行检查点 JSON 与指纹不一致") @@ -308,13 +370,18 @@ class TransferExecutionCheckpoint: skip_reason = serialized.get("skip_reason") if skip_reason is not None and not isinstance(skip_reason, str): raise TransferExecutionConflictError("整理执行跳过原因类型无效") - return cls( - fingerprint=fingerprint, - payload=result_payload, - operation_ids=tuple(operation_ids), - skip_reason=skip_reason, - version=version, - ) + try: + return cls( + fingerprint=fingerprint, + payload=result_payload, + operation_ids=tuple(operation_ids), + skip_reason=skip_reason, + version=version, + ) + except ValueError as error: + raise TransferExecutionConflictError( + "整理执行检查点内容无效" + ) from error @dataclass(frozen=True, slots=True) diff --git a/app/application/transfer/workflow.py b/app/application/transfer/workflow.py index ffa962592..57eb10a1a 100644 --- a/app/application/transfer/workflow.py +++ b/app/application/transfer/workflow.py @@ -38,7 +38,6 @@ from typing import ( from pydantic import BaseModel, ConfigDict, PrivateAttr from app.adapters.system.host import SystemUtils -from app.application.agent import get_prompt_manager, get_running_agent_manager from app.application.transfer.execution import TransferExecutionCheckpoint from app.domain.context import MediaInfo, MusicInfo from app.domain.media import normalize_music_type @@ -61,7 +60,6 @@ from app.schemas.types import ( MUSIC_ENTITY_RECORDING, MediaSource, MediaType, - ReplyMode, ) if TYPE_CHECKING: @@ -209,14 +207,6 @@ class TransferPlanningInput: raise ValueError("整理规划输入缺少源文件存储或路径") _canonical_json(self.to_payload()) - @classmethod - def legacy(cls, *, storage: str, src_path: str) -> "TransferPlanningInput": - """为升级前只有存储与路径的登记构造保守重规划输入。""" - return cls( - source_fileitem={"storage": storage, "path": src_path}, - options={"legacy_replan": True}, - ) - @classmethod def from_payload(cls, payload: dict[str, Any]) -> "TransferPlanningInput": """从受版本约束的 JSON 对象恢复规划输入。""" @@ -495,6 +485,7 @@ class TransferPlanCheckpoint: overwrite_mode: Optional[str] = None preview: bool = False skip_reason: Optional[str] = None + rejection_error: Optional[str] = None schema_version: int = TRANSFER_PLAN_CHECKPOINT_VERSION def __post_init__(self) -> None: @@ -551,6 +542,7 @@ class TransferPlanCheckpoint: or self.resolved_transfer_type or self.items or self.skip_reason + or self.rejection_error ): raise ValueError("provider_pending 检查点不得包含宿主执行计划") else: @@ -558,6 +550,12 @@ class TransferPlanCheckpoint: raise ValueError("整理计划检查点缺少目标身份") if not self.resolved_transfer_type: raise ValueError("整理计划检查点缺少已解析的整理方式") + if self.rejection_error is not None and ( + not isinstance(self.rejection_error, str) + or not self.rejection_error.strip() + or self.items + ): + raise ValueError("整理拒绝检查点必须包含非空错误且不得包含文件步骤") if tuple(item.sequence for item in self.items) != tuple(range(len(self.items))): raise ValueError("整理计划项必须按从零开始的连续序号保存") if ( @@ -565,6 +563,7 @@ class TransferPlanCheckpoint: and not self.items and not self.preview and not self.skip_reason + and not self.rejection_error ): raise ValueError("非预览空计划必须记录合法跳过原因") _canonical_json(self.to_payload()) @@ -574,6 +573,13 @@ class TransferPlanCheckpoint: """返回该检查点是否只冻结 provider 调用、尚未生成宿主计划。""" return self.provider_invocation is not None + @property + def fingerprint(self) -> str: + """返回完整冻结计划规范 JSON 的稳定 SHA-256 指纹。""" + return hashlib.sha256( + _canonical_json(self.to_payload()).encode("utf-8") + ).hexdigest() + @classmethod def from_payload(cls, payload: dict[str, Any]) -> "TransferPlanCheckpoint": """从受版本约束的 JSON 对象恢复完整执行检查点。""" @@ -634,6 +640,7 @@ class TransferPlanCheckpoint: overwrite_mode=payload.get("overwrite_mode"), preview=payload.get("preview", False), skip_reason=payload.get("skip_reason"), + rejection_error=payload.get("rejection_error"), schema_version=payload.get("schema_version", 0), ) @@ -672,6 +679,7 @@ class TransferPlanCheckpoint: "overwrite_mode": self.overwrite_mode, "preview": self.preview, "skip_reason": self.skip_reason, + "rejection_error": self.rejection_error, } @@ -847,9 +855,9 @@ class TransferAdmission: state: str created_at: str updated_at: str + planning_input: TransferPlanningInput last_error: Optional[str] = None input_fingerprint: Optional[str] = None - planning_input: Optional[TransferPlanningInput] = None checkpoint: Optional[TransferPlanCheckpoint] = None lease_owner: Optional[str] = None lease_token: Optional[str] = None @@ -866,7 +874,7 @@ class TransferAdmissionRepository(Protocol): *, storage: str, src_path: str, - planning_input: Optional[TransferPlanningInput] = None, + planning_input: TransferPlanningInput, ) -> TransferAdmission: """按规划输入幂等登记源文件并返回稳定任务身份。""" ... @@ -932,8 +940,8 @@ class TransferAdmissionRepository(Protocol): """按 token 释放当前 claim,并可记录可恢复错误。""" ... - def discard_claimed(self, *, task_id: str, lease_token: str) -> int: - """仅允许当前 lease owner 删除已经到达终态的登记。""" + def abandon_unstarted(self, *, task_id: str, lease_token: str) -> int: + """仅允许当前 lease owner 删除确认未开始且源已消失的登记。""" ... @@ -1929,162 +1937,3 @@ class JobManager: with job_lock: __mediaid__ = self.__get_media_id(media=media, season=season) return self._season_episodes.get(__mediaid__) or [] - - -class FailedRetryScheduler: - """ - 负责失败整理记录的进程内 debounce 聚合与 AI 重试调度。 - - 缓冲不提供持久化保证;关闭时会取消尚未触发的记录,由上层 durable - 工作流在后续阶段承接需要跨进程保证的重试意图。 - """ - - RETRY_TRANSFER_DEBOUNCE_SECONDS = 300 - - def __init__(self) -> None: - """初始化重试缓冲、定时器、活跃任务集合与关闭状态。""" - super().__init__() - self._retry_transfer_buffer: dict[str, list[int]] = {} - self._retry_transfer_timers: dict[str, asyncio.TimerHandle] = {} - self._retry_transfer_generations: dict[str, int] = {} - self._retry_transfer_tasks: set[asyncio.Task[None]] = set() - self._retry_transfer_lock = asyncio.Lock() - self._closed = False - - async def close(self) -> None: - """停止接收重试,取消定时器,并等待活跃 flush 任务完成取消。""" - async with self._retry_transfer_lock: - self._closed = True - timers = list(self._retry_transfer_timers.values()) - buffered_count = sum( - len(history_ids) - for history_ids in self._retry_transfer_buffer.values() - ) - self._retry_transfer_timers.clear() - self._retry_transfer_generations.clear() - self._retry_transfer_buffer.clear() - tasks = tuple(self._retry_transfer_tasks) - - for timer in timers: - timer.cancel() - if buffered_count: - logger.warning( - f"智能体重试整理调度器关闭,取消 {buffered_count} 条未持久化缓冲记录" - ) - for task in tasks: - task.cancel() - if tasks: - await asyncio.gather(*tasks, return_exceptions=True) - - def _start_retry_transfer_task(self, group_key: str, generation: int) -> None: - """把定时器到期后的 flush 建为具名且受本调度器管理的任务。""" - if self._closed: - return - task = asyncio.create_task( - self._flush_retry_transfer(group_key, generation), - name="transfer.failed_retry.flush", - ) - self._retry_transfer_tasks.add(task) - task.add_done_callback(self._observe_retry_transfer_task) - - def _observe_retry_transfer_task(self, task: asyncio.Task[None]) -> None: - """移除已结束任务,并观察未被 flush 逻辑处理的异常。""" - self._retry_transfer_tasks.discard(task) - if task.cancelled(): - return - exception = task.exception() - if exception is not None: - logger.error(f"智能体重试整理后台任务异常: {exception}") - - @staticmethod - def _build_retry_transfer_template_context( - history_ids: list[int], - ) -> tuple[str, dict[str, int | str]]: - """仅负责把失败重试任务的动态数据映射成模板变量。""" - is_batch = len(history_ids) > 1 - task_type = "batch_transfer_failed_retry" if is_batch else "transfer_failed_retry" - template_context: dict[str, int | str] = { - "history_ids_csv": ", ".join(str(item) for item in history_ids), - "history_count": len(history_ids), - } - if not is_batch: - template_context["history_id"] = history_ids[0] - return task_type, template_context - - def _build_retry_transfer_prompt(self, history_ids: list[int]) -> str: - """根据失败记录数量构建统一的重试整理后台任务提示词。""" - task_type, template_context = self._build_retry_transfer_template_context(history_ids) - return cast(str, get_prompt_manager().render_system_task_message( - task_type, - template_context=template_context, - )) - - async def schedule_retry(self, history_id: int, group_key: str = "") -> None: - """ - 同一 group_key 的失败记录会在缓冲期内合并为一次 agent 调用。 - """ - if not group_key: - group_key = f"_default_{history_id}" - - async with self._retry_transfer_lock: - if self._closed: - raise RuntimeError("智能体重试整理调度器正在关闭,不能再接收任务") - if group_key not in self._retry_transfer_buffer: - self._retry_transfer_buffer[group_key] = [] - if history_id not in self._retry_transfer_buffer[group_key]: - self._retry_transfer_buffer[group_key].append(history_id) - logger.info( - f"智能体重试整理:记录 ID={history_id} 已加入缓冲区 " - f"(group={group_key}, 当前{len(self._retry_transfer_buffer[group_key])}条)" - ) - - if group_key in self._retry_transfer_timers: - self._retry_transfer_timers[group_key].cancel() - - loop = asyncio.get_running_loop() - generation = self._retry_transfer_generations.get(group_key, 0) + 1 - self._retry_transfer_generations[group_key] = generation - self._retry_transfer_timers[group_key] = loop.call_later( - self.RETRY_TRANSFER_DEBOUNCE_SECONDS, - self._start_retry_transfer_task, - group_key, - generation, - ) - - async def _flush_retry_transfer(self, group_key: str, generation: int) -> None: - """ - 延迟定时器到期后,取出该分组的所有 history_id 并合并为一次 agent 调用。 - """ - async with self._retry_transfer_lock: - # callback 到期与真正取得锁之间可能有新记录续期;旧代不能提前取走新批次。 - if self._retry_transfer_generations.get(group_key) != generation: - return - history_ids = self._retry_transfer_buffer.pop(group_key, []) - self._retry_transfer_timers.pop(group_key, None) - self._retry_transfer_generations.pop(group_key, None) - - if not history_ids: - return - - ids_str = ", ".join(str(item) for item in history_ids) - logger.info( - f"智能体重试整理:开始批量处理失败记录 IDs=[{ids_str}] (group={group_key})" - ) - - try: - manager = get_running_agent_manager() - if manager is None: - logger.warning("智能助手服务未运行,跳过整理失败自动重试") - return - await manager.run_background_prompt( - message=self._build_retry_transfer_prompt(history_ids), - session_prefix="__agent_retry_transfer_batch", - reply_mode=ReplyMode.DISPATCH, - ) - logger.info( - f"智能体重试整理:批量处理完成 IDs=[{ids_str}] (group={group_key})" - ) - except Exception as err: - logger.error( - f"智能体重试整理失败 (IDs=[{ids_str}], group={group_key}): {err}" - ) diff --git a/app/chain/transfer.py b/app/chain/transfer.py index 9621e3b40..fcb2233ca 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -1,6 +1,4 @@ import asyncio -import hashlib -import json import queue import re import threading @@ -8,8 +6,6 @@ import time import traceback import uuid from collections import Counter -from concurrent.futures import CancelledError as FutureCancelledError -from concurrent.futures import Future from copy import deepcopy from dataclasses import replace from datetime import datetime, timedelta, timezone @@ -50,6 +46,7 @@ from app.application.transfer.execution import ( TransferExecutionCheckpoint, TransferExecutionCommand, TransferExecutionConflictError, + TransferExecutionOutcome, TransferExecutionRepository, TransferExecutionSnapshot, TransferExecutionState, @@ -61,7 +58,6 @@ from app.application.transfer.execution import ( TransferStepState, ) from app.application.transfer.workflow import ( - FailedRetryScheduler, JobManager, TransferAdmission, TransferFailureNotification, @@ -200,11 +196,45 @@ class _DurableTransferStepRunner: *, step: Any, execute: Callable[[], TransferStepResult], + observe: Callable[[], TransferOperationObservation], ) -> TransferStepResult: - """在事务外执行副作用,并把失败交给持久重试或终态结算。""" + """在事务外执行副作用,异常后先探测结果再决定重试或人工复核。""" try: result = execute() except Exception as error: + try: + observation = observe() + except Exception as observe_error: + observation = TransferOperationObservation( + state=TransferOperationObservationState.UNKNOWN, + evidence=TransferStepResult(payload={ + "execute_error": str(error), + "observe_error": str(observe_error), + }), + ) + if observation.state is TransferOperationObservationState.APPLIED: + completed = self._command.complete( + task_id=self._task_id, + lease_token=self._lease_token, + step=step, + result=observation.evidence, + ) + if completed.result is None: + raise RuntimeError("探测完成的整理步骤缺少结果证据") from error + return completed.result + if observation.state is not TransferOperationObservationState.NOT_APPLIED: + reason = ( + f"整理步骤 {step.operation_id} 执行异常后外部结果为 " + f"{observation.state.value},禁止自动重放:{error}" + ) + self._command.manual_review( + task_id=self._task_id, + lease_token=self._lease_token, + step=step, + error=reason, + evidence=observation.evidence, + ) + raise _TransferManualReviewRequired(reason) from error snapshot = self._repository.get_snapshot(task_id=self._task_id) if snapshot is None: raise RuntimeError("整理执行快照在步骤失败后丢失") from error @@ -214,6 +244,7 @@ class _DurableTransferStepRunner: lease_token=self._lease_token, step=step, error=str(error), + evidence=observation.evidence, ) raise _TransferRetryExhausted(str(error), exhausted) from error self._command.defer( @@ -222,6 +253,7 @@ class _DurableTransferStepRunner: step=step, error=str(error), retry_due_at=self._retry_due_at(), + evidence=observation.evidence, ) raise _TransferRetryDeferred(str(error)) from error completed = self._command.complete( @@ -313,14 +345,18 @@ class _DurableTransferStepRunner: raise _TransferManualReviewRequired(reason) else: raise RuntimeError(f"不支持的整理步骤状态:{step.state}") - return self._execute_attempt(step=step, execute=execute) + return self._execute_attempt(step=step, execute=execute, observe=observe) def checkpoint(self, transferinfo: TransferInfo) -> TransferExecutionCheckpoint: """提交足以独立重放终态结算的聚合执行结果。""" - successful_outcome = bool(transferinfo.success) + outcome = ( + "overwrite_skipped" + if transferinfo.overwrite_skipped + else "succeeded" if transferinfo.success else "failed" + ) checkpoint = TransferExecutionCheckpoint.create( payload={ - "outcome": "succeeded" if successful_outcome else "failed", + "outcome": outcome, "transferinfo": transferinfo.model_dump(mode="json"), }, operation_ids=tuple(self._operation_ids), @@ -382,14 +418,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo @staticmethod def __transfer_plan_fingerprint(checkpoint: TransferPlanCheckpoint) -> str: """由完整冻结计划生成执行步骤使用的稳定 SHA-256 身份。""" - canonical = json.dumps( - checkpoint.to_payload(), - ensure_ascii=True, - sort_keys=True, - separators=(",", ":"), - allow_nan=False, - ) - return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + return checkpoint.fingerprint @staticmethod def _transfer_result_payload( @@ -444,15 +473,27 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo """由执行结果与已核实的覆盖裁决构造受 lease fencing 保护的终态命令。""" checkpoint = task.execution_checkpoint if checkpoint is None: - return None + if task.preview: + return None + raise RuntimeError("非预览整理终态缺少持久执行检查点") if not task.admission_task_id or not task.lease_token: raise TransferLeaseLostError("整理终态缺少持久任务身份或租约") successful_outcome = bool(transferinfo.success or overwrite_declined) + settlement_outcome = "succeeded" if successful_outcome else "failed" + checkpoint.validate_settlement_outcome(settlement_outcome) + frozen_transferinfo = checkpoint.payload.get("transferinfo") + if ( + isinstance(frozen_transferinfo, dict) + and frozen_transferinfo != transferinfo.model_dump(mode="json") + ): + raise TransferExecutionConflictError( + "整理终态与冻结 TransferInfo 不一致" + ) return TransferResultSettlement( task_id=task.admission_task_id, lease_token=task.lease_token, execution_fingerprint=checkpoint.fingerprint, - outcome="succeeded" if successful_outcome else "failed", + outcome=settlement_outcome, error=( None if successful_outcome @@ -586,7 +627,6 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo # 事件管理器 self.jobview = JobManager() # Agent重试管理器 - self.retry_scheduler = FailedRetryScheduler() # 整理失败通知聚合器 self.failure_notification_aggregator = TransferFailureNotificationAggregator() # durable admission 仓储先于内存入队保存任务,进程退出后仍可恢复。 @@ -805,11 +845,11 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo async def close(self, timeout_seconds: float = _WORKER_CLOSE_TIMEOUT_SECONDS) -> bool: """ - 收口整理线程、失败通知和 AI 重试,并返回依赖是否可以安全释放。 + 收口整理线程和失败通知,并返回依赖是否可以安全释放。 同步文件 I/O 在线程内无法被 asyncio 取消,因此先在线程池中执行有界 ``close_workers``。只有 worker 与 replay 全部退出后才关闭通知和重试;若 - 超时则保留这些依赖,供仍在运行的整理回调继续使用。 + 超时则保留通知依赖,供仍在运行的整理回调继续使用。 :param timeout_seconds: worker 与 replay 共享的最大等待秒数 :return: 所有整理后台 owner 均已收敛时返回 True """ @@ -820,41 +860,8 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo if not workers_closed: return False self.failure_notification_aggregator.close() - await self.retry_scheduler.close() return True - @staticmethod - def _observe_failed_retry_schedule(future: Future[None]) -> None: - """观察跨线程 AI 重试调度的完成结果,避免关闭竞态变成静默异常。""" - try: - future.result() - except FutureCancelledError: - return - except Exception as err: - logger.error(f"触发AI智能体重试整理失败: {err}") - - def _schedule_failed_transfer_retry( - self, - history_id: int, - group_key: str, - ) -> None: - """把失败历史提交到主事件循环,并持续持有和观察调度结果。""" - retry_coroutine = self.retry_scheduler.schedule_retry( - history_id, - group_key=group_key, - ) - try: - future = asyncio.run_coroutine_threadsafe( - retry_coroutine, - global_vars.loop, - ) - except Exception as err: - retry_coroutine.close() - logger.error(f"触发AI智能体重试整理失败: {err}") - return - future.add_done_callback(self._observe_failed_retry_schedule) - logger.info(f"已触发AI智能体重试整理历史记录 #{history_id}") - def on_config_changed(self) -> None: """配置变更时重启文件整理线程。""" with self._worker_lifecycle_lock: @@ -927,6 +934,11 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo transferinfo, overwrite_declined=overwrite_declined, ) + if settlement is None: + raise RuntimeError("非预览整理终态无法建立 durable 结算命令") + durable_writer = getattr(self, "durable_event_writer", None) + if durable_writer is None: + raise RuntimeError("非预览整理终态缺少 durable 原子写入端口") # 转移失败 if not transferinfo.success: @@ -939,142 +951,29 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo logger.info( f"{task.fileitem.name} 未入库并保留原整理记录:{transferinfo.message}" ) - if settlement is not None: - writer = getattr(self, "durable_event_writer", None) - if writer is None: - raise RuntimeError("覆盖跳过的 durable 终态缺少原子写入端口") - history = writer.transfer_result( - topic=None, - stage_history=lambda staging: staging.get_success_by_src( - task.fileitem.path, - task.fileitem.storage, - ), - event_payload=self._transfer_result_payload(task, transferinfo), - publish=None, - settlement=settlement, - ) - if not isinstance(history, TransferSettlementResult): - raise RuntimeError("覆盖跳过的 durable 终态没有返回结算结果") - task.mark_terminal_settled() + history = durable_writer.transfer_result( + topic=None, + stage_history=lambda staging: staging.get_success_by_src( + task.fileitem.path, + task.fileitem.storage, + ), + event_payload=self._transfer_result_payload(task, transferinfo), + publish=None, + settlement=settlement, + ) + if not isinstance(history, TransferSettlementResult): + raise RuntimeError("覆盖跳过的 durable 终态没有返回结算结果") + task.mark_terminal_settled() else: logger.warn(f"{task.fileitem.name} 入库失败:{transferinfo.message}") - # 累计失败次数,达到上限后查重闸不再自动放行重试 - record_transfer_failure( - task.fileitem.path if task.fileitem else None, - task.fileitem.storage if task.fileitem else None, - file_size=task.fileitem.size if task.fileitem else None, - file_modify_time=task.fileitem.modify_time if task.fileitem else None, - fileid=task.fileitem.fileid if task.fileitem else None, - ) - durable_event = self._durable_transfer_event(task, success=False) - durable_transfer_failed = bool( - getattr(self, "durable_event_writer", None) - and (durable_event or settlement) - ) - if durable_transfer_failed: - topic = durable_event[0] if durable_event else None - event_type = durable_event[1] if durable_event else None - event_payload = self._transfer_result_payload(task, transferinfo) - history = self.durable_event_writer.transfer_result( - topic=topic, - stage_history=lambda writer: add_transfer_fail( - fileitem=task.fileitem, - mode=transferinfo.transfer_type if transferinfo else "", - downloader=task.downloader, - download_hash=task.download_hash, - meta=task.meta, - mediainfo=task.mediainfo, - transferinfo=transferinfo, - transfer_history_oper=writer, - ), - event_payload=event_payload, - publish=( - lambda payload: self._publish_transfer_result( - event_type, payload - ) - if event_type is not None - else None - ), - settlement=settlement, - ) - if settlement is not None: - if not isinstance(history, TransferSettlementResult): - raise RuntimeError("整理失败 durable 终态没有返回结算结果") - task.mark_terminal_settled() - else: - history = add_transfer_fail( - fileitem=task.fileitem, - mode=transferinfo.transfer_type if transferinfo else "", - downloader=task.downloader, - download_hash=task.download_hash, - meta=task.meta, - mediainfo=task.mediainfo, - transferinfo=transferinfo, - transfer_history_oper=transferhis, - ) - - if durable_event and not durable_transfer_failed: - # 显式旧测试上下文仍走原始发送;正式上下文由 outbox writer 发布。 - self._publish_transfer_result( - durable_event[1], - self._transfer_result_payload( - task, - transferinfo, - self.__transfer_history_id(history), - ), - ) - - self.queue_failed_transfer_notification( - task=task, - transferinfo=transferinfo, - history_id=self.__transfer_history_id(history), - ) - - # 设置任务失败 - self.jobview.fail_task(task) - - # AI智能体自动重试整理 - if ( - history - and settlement is None - and self.runtime_config.ai_agent_enable - and self.runtime_config.ai_agent_retry_transfer - ): - # 使用 download_hash 或源文件父目录作为分组键, - # 同一批次(如同一个种子)的失败记录会被合并为一次agent调用 - self._schedule_failed_transfer_retry( - cast(int, self.__transfer_history_id(history)), - build_transfer_failure_group_key(task), - ) - - # 返回失败 - ret_status = False - ret_message = transferinfo.message - - else: - # 转移成功 - logger.info(f"{task.fileitem.name} 入库成功:{target_dir_path or ''}") - - # 整理成功即认为此前的连续失败已恢复,重置计数让后续故障重新获得完整重试额度 - clear_transfer_failures( - task.fileitem.path if task.fileitem else None, - task.fileitem.storage if task.fileitem else None, - ) - - durable_event = self._durable_transfer_event(task, success=True) - durable_transfer_complete = bool( - getattr(self, "durable_event_writer", None) - and (durable_event or settlement) - ) - if durable_transfer_complete: topic = durable_event[0] if durable_event else None event_type = durable_event[1] if durable_event else None event_payload = self._transfer_result_payload(task, transferinfo) - history = self.durable_event_writer.transfer_result( + history = durable_writer.transfer_result( topic=topic, - stage_history=lambda writer: add_transfer_success( + stage_history=lambda writer: add_transfer_fail( fileitem=task.fileitem, mode=transferinfo.transfer_type if transferinfo else "", downloader=task.downloader, @@ -1094,12 +993,43 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo ), settlement=settlement, ) - if settlement is not None: - if not isinstance(history, TransferSettlementResult): - raise RuntimeError("整理成功 durable 终态没有返回结算结果") - task.mark_terminal_settled() - else: - history = add_transfer_success( + if not isinstance(history, TransferSettlementResult): + raise RuntimeError("整理失败 durable 终态没有返回结算结果") + task.mark_terminal_settled() + + # 失败计数不是终态真相,只能在原子历史与 pending 结算成功后更新。 + record_transfer_failure( + task.fileitem.path if task.fileitem else None, + task.fileitem.storage if task.fileitem else None, + file_size=task.fileitem.size if task.fileitem else None, + file_modify_time=task.fileitem.modify_time if task.fileitem else None, + fileid=task.fileitem.fileid if task.fileitem else None, + ) + + self.queue_failed_transfer_notification( + task=task, + transferinfo=transferinfo, + history_id=self.__transfer_history_id(history), + ) + + # 设置任务失败 + self.jobview.fail_task(task) + + # 返回失败 + ret_status = False + ret_message = transferinfo.message + + else: + # 转移成功 + logger.info(f"{task.fileitem.name} 入库成功:{target_dir_path or ''}") + + durable_event = self._durable_transfer_event(task, success=True) + topic = durable_event[0] if durable_event else None + event_type = durable_event[1] if durable_event else None + event_payload = self._transfer_result_payload(task, transferinfo) + history = durable_writer.transfer_result( + topic=topic, + stage_history=lambda writer: add_transfer_success( fileitem=task.fileitem, mode=transferinfo.transfer_type if transferinfo else "", downloader=task.downloader, @@ -1107,19 +1037,27 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo meta=task.meta, mediainfo=task.mediainfo, transferinfo=transferinfo, - transfer_history_oper=transferhis, - ) + transfer_history_oper=writer, + ), + event_payload=event_payload, + publish=( + lambda payload: self._publish_transfer_result( + event_type, payload + ) + if event_type is not None + else None + ), + settlement=settlement, + ) + if not isinstance(history, TransferSettlementResult): + raise RuntimeError("整理成功 durable 终态没有返回结算结果") + task.mark_terminal_settled() - if durable_event and not durable_transfer_complete: - # 显式旧测试上下文仍走原始发送;正式上下文由 outbox writer 发布。 - self._publish_transfer_result( - durable_event[1], - self._transfer_result_payload( - task, - transferinfo, - self.__transfer_history_id(history), - ), - ) + # 失败预算同样只能在 durable 成功终态提交后重置。 + clear_transfer_failures( + task.fileitem.path if task.fileitem else None, + task.fileitem.storage if task.fileitem else None, + ) # task登记转移成功文件清单 target_files = transferinfo.file_list_new @@ -1758,12 +1696,17 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo storage = admission.storage src_path = admission.src_path try: - settling_checkpoint = self.__settling_replay_checkpoint(admission) - if settling_checkpoint is not None: + execution_snapshot = self.__execution_replay_snapshot(admission) + use_frozen_source = bool( + admission.checkpoint is not None + or execution_snapshot.state is not TransferExecutionState.NOT_STARTED + or execution_snapshot.steps + ) + if use_frozen_source: planning_input = admission.planning_input if planning_input is None: raise TransferPlanningStateError( - "settling 回放缺少整理规划输入" + "执行态回放缺少整理规划输入" ) fileitem = FileItem.model_validate( planning_input.source_fileitem @@ -1795,7 +1738,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo raise TransferLeaseLostError( f"恢复任务缺少 lease token:{admission.task_id}" ) - discarded = self._transfer_admissions.discard_claimed( + discarded = self._transfer_admissions.abandon_unstarted( task_id=admission.task_id, lease_token=lease_token, ) @@ -1803,10 +1746,15 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo logger.warning( f"恢复任务终态注销被 CAS 拒绝:{admission.task_id}" ) - self.__forget_owned_lease( - admission.task_id, - lease_token, - ) + self.__release_admission_claim( + admission, + error="源已消失但任务状态已变化,保留登记供恢复", + ) + else: + self.__forget_owned_lease( + admission.task_id, + lease_token, + ) else: self.__release_admission_claim( admission, @@ -1817,7 +1765,12 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo if self.__queue_planned_replay( fileitem, admission, - execution_checkpoint=settling_checkpoint, + execution_checkpoint=( + execution_snapshot.checkpoint + if execution_snapshot.state + is TransferExecutionState.SETTLING + else None + ), ): replayed += 1 else: @@ -1869,26 +1822,29 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo else: logger.info(f"✓ 待整理文件回放完成,{replayed} 个文件已重新送入整理链") - def __settling_replay_checkpoint( + def __execution_replay_snapshot( self, admission: TransferAdmission, - ) -> Optional[TransferExecutionCheckpoint]: - """读取已 claim 的 settling 检查点,避免终态恢复再次探测源文件。""" + ) -> TransferExecutionSnapshot: + """读取已 claim 的执行快照,确保任何步骤证据都先于源文件探测。""" repository: Optional[TransferExecutionRepository] = getattr( self, "_transfer_executions", None, ) if repository is None: - return None + raise RuntimeError("整理恢复缺少 execution repository") snapshot = repository.get_snapshot(task_id=admission.task_id) - if snapshot is None or snapshot.state is not TransferExecutionState.SETTLING: - return None - if snapshot.checkpoint is None: + if snapshot is None: + raise TransferExecutionConflictError("整理恢复任务缺少执行状态投影") + if ( + snapshot.state is TransferExecutionState.SETTLING + and snapshot.checkpoint is None + ): raise TransferExecutionConflictError( "settling 整理任务缺少可重放终态检查点" ) - return snapshot.checkpoint + return snapshot @staticmethod def __build_replay_kwargs( @@ -2293,14 +2249,14 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo checkpoint.resolved_meta, checkpoint.resolved_meta_kind, ) - elif task.meta is None: + elif task.meta is None and checkpoint.rejection_error is None: raise TransferPlanningStateError("整理计划检查点缺少已解析元数据") if checkpoint.resolved_mediainfo: task.mediainfo = self.__restore_mediainfo_snapshot( checkpoint.resolved_mediainfo, checkpoint.resolved_mediainfo_kind, ) - elif task.mediainfo is None: + elif task.mediainfo is None and checkpoint.rejection_error is None: raise TransferPlanningStateError("整理计划检查点缺少已识别媒体信息") task.episodes_info = [ TmdbEpisode.model_validate(item) @@ -2346,6 +2302,97 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo except Exception as record_error: logger.error(f"记录整理规划前失败原因出错:{record_error}") + def __checkpoint_planning_rejection( + self, + task: TransferTask, + error: str, + ) -> TransferInfo: + """把确定性规划拒绝冻结为零文件副作用计划并走统一终态结算。""" + if task.preview: + return TransferInfo( + success=False, + message=error, + fileitem=task.fileitem, + fail_list=[task.fileitem.path], + transfer_type=task.transfer_type, + need_notify=False, + ) + self.__claim_task_for_execution(task) + self.__assert_owned_lease(task) + if getattr(self, "durable_event_writer", None) is None: + raise RuntimeError( + "非预览整理缺少 durable 原子写入端口,拒绝提交规划终态" + ) + planning_input = task.planning_input or self.__build_planning_input(task) + task.bind_planning_input(planning_input) + source_path = task.fileitem.path + resolved_transfer_type = ( + task.transfer_type + or planning_input.requested_transfer_type + or "copy" + ) + checkpoint = TransferPlanCheckpoint( + planning_input=planning_input, + target_storage=task.fileitem.storage, + root_target_path=source_path, + final_target_path=source_path, + resolved_transfer_type=resolved_transfer_type, + items=(), + resolved_meta=self.__json_snapshot(task.meta), + resolved_meta_kind=(type(task.meta).__name__ if task.meta else None), + resolved_mediainfo=self.__json_snapshot(task.mediainfo), + resolved_mediainfo_kind=( + type(task.mediainfo).__name__ if task.mediainfo else None + ), + resolved_episodes_info=tuple( + self.__json_snapshot(item) for item in (task.episodes_info or []) + ), + need_notify=planning_input.need_notify, + overwrite_mode=planning_input.overwrite_mode, + rejection_error=error, + ) + persisted = self.__persist_transfer_checkpoint( + task, + planning_input=planning_input, + checkpoint=checkpoint, + ) + task.bind_plan_checkpoint(persisted) + return self._plan_checkpoint_and_execute(task) + + def __execute_planning_rejection( + self, + task: TransferTask, + checkpoint: TransferPlanCheckpoint, + ) -> TransferInfo: + """以可重放内部步骤确认冻结拒绝,并建立统一 execution checkpoint。""" + error = checkpoint.rejection_error + if not error: + raise TransferPlanningStateError("整理拒绝计划缺少失败原因") + step_runner = self.__build_durable_step_runner(task, checkpoint) + if step_runner is None: + raise RuntimeError("非预览整理拒绝缺少 durable 步骤 runner") + evidence = TransferStepResult(payload={"error": error}) + step_runner.run( + phase="planning", + kind="reject", + payload={"error": error}, + execute=lambda: evidence, + observe=lambda: TransferOperationObservation( + state=TransferOperationObservationState.APPLIED, + evidence=evidence, + ), + ) + transferinfo = TransferInfo( + success=False, + message=error, + fileitem=task.fileitem, + fail_list=[task.fileitem.path], + transfer_type=checkpoint.resolved_transfer_type, + need_notify=checkpoint.need_notify, + ) + task.bind_execution_checkpoint(step_runner.checkpoint(transferinfo)) + return transferinfo + def __handle_planned_transfer( self, task: TransferTask, @@ -2382,36 +2429,59 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo task: TransferTask, ) -> Optional[TransferInfo]: """从 execution checkpoint 恢复终态结果,禁止再次执行外部步骤。""" - checkpoint = task.execution_checkpoint - if checkpoint is None: - repository: Optional[TransferExecutionRepository] = getattr( - self, - "_transfer_executions", - None, + repository: Optional[TransferExecutionRepository] = getattr( + self, + "_transfer_executions", + None, + ) + task_id = task.admission_task_id + if repository is None or not task_id: + if task.execution_checkpoint is not None: + raise TransferExecutionConflictError( + "绑定执行检查点的整理任务缺少持久状态仓储" + ) + return None + snapshot = repository.get_snapshot(task_id=task_id) + if snapshot is None: + if task.execution_checkpoint is not None: + raise TransferExecutionConflictError( + "绑定执行检查点的整理任务缺少持久执行状态" + ) + return None + if snapshot.state is not TransferExecutionState.SETTLING: + return None + checkpoint = snapshot.checkpoint + bound_checkpoint = task.execution_checkpoint + if ( + bound_checkpoint is not None + and checkpoint is not None + and bound_checkpoint.fingerprint != checkpoint.fingerprint + ): + raise TransferExecutionConflictError( + "内存整理任务与持久结算检查点不一致" ) - task_id = task.admission_task_id - if repository is None or not task_id: - return None - snapshot = repository.get_snapshot(task_id=task_id) - if snapshot is None or snapshot.state is not TransferExecutionState.SETTLING: - return None - checkpoint = snapshot.checkpoint if checkpoint is None: raise TransferExecutionConflictError( "settling 整理任务缺少可重放终态检查点" ) self.__assert_owned_lease(task) payload = checkpoint.payload - outcome = payload.get("outcome") - if outcome not in {"succeeded", "failed"}: - raise TransferExecutionConflictError("整理执行检查点缺少确定终态") + raw_outcome = payload.get("outcome") + try: + outcome = TransferExecutionOutcome( + raw_outcome if isinstance(raw_outcome, str) else "" + ) + except (TypeError, ValueError) as error: + raise TransferExecutionConflictError( + "整理执行检查点缺少确定终态" + ) from error transfer_payload = payload.get("transferinfo") if isinstance(transfer_payload, dict): transferinfo = cast( TransferInfo, TransferInfo.model_validate(transfer_payload), ) - elif outcome == "failed": + elif outcome is TransferExecutionOutcome.FAILED: plan_checkpoint = task.plan_checkpoint transferinfo = TransferInfo( success=False, @@ -2433,8 +2503,14 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo raise TransferExecutionConflictError( "成功整理执行检查点缺少可重放 TransferInfo" ) - successful_outcome = bool(transferinfo.success) - if successful_outcome != (outcome == "succeeded"): + expected_success = outcome is TransferExecutionOutcome.SUCCEEDED + expected_overwrite_skip = ( + outcome is TransferExecutionOutcome.OVERWRITE_SKIPPED + ) + if ( + bool(transferinfo.success) != expected_success + or bool(transferinfo.overwrite_skipped) != expected_overwrite_skip + ): raise TransferExecutionConflictError( "整理执行检查点终态与 TransferInfo 不一致" ) @@ -2451,7 +2527,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo return None repository = getattr(self, "_transfer_executions", None) if repository is None: - return None + raise RuntimeError("非预览整理缺少 execution repository") if not task.admission_task_id or not task.lease_token: raise TransferLeaseLostError("整理执行缺少持久任务身份或租约") return _DurableTransferStepRunner( @@ -2508,6 +2584,10 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo if not task.preview: self.__claim_task_for_execution(task) self.__assert_owned_lease(task) + if getattr(self, "durable_event_writer", None) is None: + raise RuntimeError( + "非预览整理缺少 durable 原子写入端口,拒绝开始外部执行" + ) checkpoint = task.plan_checkpoint if checkpoint is None: @@ -2559,6 +2639,9 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo self.__restore_planned_task(task) self.__assert_owned_lease(task) + if checkpoint.rejection_error: + return self.__execute_planning_rejection(task, checkpoint) + step_runner = self.__build_durable_step_runner(task, checkpoint) try: legacy_result = self.__execute_legacy_transfer_providers( @@ -2905,6 +2988,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo except Exception as error: message = f"旧整理兼容命令 durable 终态结算失败:{error}" logger.error(message) + self.__release_task_claim(task, error=message) return TransferInfo( success=False, message=message, @@ -2937,44 +3021,6 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo self._finish_scrape_batch_task(task) self.__ensure_recovery_scheduler(immediate=False) - def __discard_pending(self, task: TransferTask) -> bool: - """ - 注销一个待整理文件登记,整理到达终态(成功或失败)时调用。 - - 失败的文件不靠本表回放:整理历史里已有失败记录,分发器的历史门控会按 - 重试预算重新送入整理链;留在本表反而会每次重启都重复回放。 - :param task: 任务信息 - """ - if not task or not task.admission_task_id: - return True - if not task.lease_token: - logger.error( - f"整理任务缺少 lease token,拒绝伪装 durable 终态:" - f"{task.admission_task_id}" - ) - return False - discarded = 0 - try: - discarded = self._transfer_admissions.discard_claimed( - task_id=task.admission_task_id, - lease_token=task.lease_token, - ) - if not discarded: - logger.error( - f"整理任务终态注销被 CAS 拒绝:{task.admission_task_id}" - ) - except Exception as err: - logger.error( - "注销整理任务 durable admission 失败: " - f"{task.admission_task_id} - {err}" - ) - finally: - self.__forget_owned_lease( - task.admission_task_id, - task.lease_token, - ) - return bool(discarded) - def __put_to_jobview(self, task: TransferTask) -> bool: """ 添加到作业视图 @@ -3047,23 +3093,25 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo terminal: bool = True, terminal_settlement: Optional[bool] = None, ) -> bool: - """结束内存执行,并复用成功回调前已经提交的 durable 终态结果。""" + """结束内存执行,只接受原子终态回执并保留未结算任务供恢复。""" marker = getattr(self.jobview, "finish_execution", None) if marker: marker(task) + if task.preview: + return True if terminal: - if terminal_settlement is not None: - if ( - terminal_settlement - and task.admission_task_id - and task.lease_token - ): + if terminal_settlement: + if task.admission_task_id and task.lease_token: self.__forget_owned_lease( task.admission_task_id, task.lease_token, ) - return terminal_settlement - return self.__discard_pending(task) + return True + self.__release_task_claim( + task, + error="整理终态未完成 durable 原子结算", + ) + return False self.__release_task_claim(task) return True @@ -3220,23 +3268,16 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo ) -> Tuple[bool, str]: """由默认回调原子提交历史、事件与 durable 终态。""" nonlocal terminal_settlement - if ( - transferinfo.success - and not callback_task.preview - and callback_task.execution_checkpoint is None - ): - terminal_settlement = self.__discard_pending(callback_task) - if not terminal_settlement: - self.__fail_transfer_task(callback_task) - return False, "整理任务 durable 终态结算失去租约" if item.callback: - callback_result: Tuple[bool, str] = item.callback( - callback_task, - transferinfo, - ) - if callback_task.execution_checkpoint is not None: - terminal_settlement = callback_task.terminal_settled - return callback_result + try: + callback_result: Tuple[bool, str] = item.callback( + callback_task, + transferinfo, + ) + return callback_result + finally: + if not callback_task.preview: + terminal_settlement = callback_task.terminal_settled return transferinfo.success, transferinfo.message or "" try: @@ -3412,54 +3453,13 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo if not mediainfo: if task.preview: return False, "未识别到媒体信息" - # 未识别同样是整理失败,计入重试次数:TMDB 瞬断属于可恢复故障, - # 但文件名永远识别不出时不能无限重试刷通知 - record_transfer_failure( - task.fileitem.path if task.fileitem else None, - task.fileitem.storage if task.fileitem else None, - file_size=task.fileitem.size if task.fileitem else None, - file_modify_time=task.fileitem.modify_time if task.fileitem else None, - fileid=task.fileitem.fileid if task.fileitem else None, + transferinfo = self.__checkpoint_planning_rejection( + task, + "未识别到媒体信息", ) - # 新增整理失败历史记录 - his = add_transfer_fail( - fileitem=task.fileitem, - mode=task.transfer_type, - meta=task.meta, - downloader=task.downloader, - download_hash=task.download_hash, - transfer_history_oper=transferhis, - ) - self.queue_failed_transfer_notification( - task=task, - transferinfo=TransferInfo( - success=False, - fileitem=task.fileitem, - message="未识别到媒体信息", - transfer_type=task.transfer_type, - ), - history_id=his.id if his else None, - manual_identity=True, - ) - # 任务失败,直接移除task - self.jobview.remove_task(task.fileitem) - self.__mark_torrent_completed_if_done( - task.download_hash, task.downloader - ) - - # AI智能体自动重试整理 - if ( - his - and self.runtime_config.ai_agent_enable - and self.runtime_config.ai_agent_retry_transfer - ): - # 使用 download_hash 或源文件父目录作为分组键 - self._schedule_failed_transfer_retry( - his.id, - build_transfer_failure_group_key(task), - ) - - return False, "未识别到媒体信息" + if callback: + return cast(Tuple[bool, str], callback(task, transferinfo)) + return transferinfo.success, transferinfo.message or "" mediainfo_changed = True @@ -3538,17 +3538,15 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo else: error_message = "未识别到 TMDB 辅助信息,无法按媒体类别整理" logger.error(f"{task.fileitem.name} {error_message}") + if task.preview: + return False, error_message + transferinfo = self.__checkpoint_planning_rejection( + task, + error_message, + ) if callback: - return callback( - task, - TransferInfo( - success=False, - fileitem=task.fileitem, - transfer_type=task.transfer_type, - message=error_message, - ), - ) - return False, error_message + return cast(Tuple[bool, str], callback(task, transferinfo)) + return transferinfo.success, transferinfo.message or "" # 正在处理 self.jobview.running_task(task) @@ -4902,24 +4900,16 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo ) -> Tuple[bool, str]: """同步路径也由默认回调原子提交历史、事件与 durable 终态。""" nonlocal terminal_settlement - if ( - transferinfo.success - and not callback_task.preview - and callback_task.execution_checkpoint is None - ): - terminal_settlement = self.__discard_pending(callback_task) - if not terminal_settlement: - self.__fail_transfer_task(callback_task) - return False, "整理任务 durable 终态结算失去租约" callback = ( _preview_callback if preview else self.__default_callback ) - callback_result = callback(callback_task, transferinfo) - if callback_task.execution_checkpoint is not None: - terminal_settlement = callback_task.terminal_settled - return callback_result + try: + return callback(callback_task, transferinfo) + finally: + if not callback_task.preview: + terminal_settlement = callback_task.terminal_settled try: self.__claim_task_for_execution(transfer_task) diff --git a/app/db/adapters/chain.py b/app/db/adapters/chain.py index 639b8a2d0..70f1a7e85 100644 --- a/app/db/adapters/chain.py +++ b/app/db/adapters/chain.py @@ -26,6 +26,7 @@ from app.application.outbox import ( OutboxIntent, ) from app.application.transfer.execution import ( + TransferExecutionCheckpoint, TransferExecutionConflictError, TransferExecutionLeaseLostError, TransferExecutionState, @@ -403,6 +404,13 @@ class TransactionalChainDurableEventWriter(ChainDurableEventWriter): != settlement.execution_fingerprint ): raise TransferExecutionConflictError("整理终态与执行检查点不匹配") + checkpoint = TransferExecutionCheckpoint.from_payload( + pending.execution_payload, + fingerprint=settlement.execution_fingerprint, + ) + if pending.execution_version != checkpoint.version: + raise TransferExecutionConflictError("整理执行检查点列版本与内容不一致") + checkpoint.validate_settlement_outcome(settlement.outcome) return int(pending.settlement_revision) @staticmethod diff --git a/app/db/adapters/transfer/admission.py b/app/db/adapters/transfer/admission.py index 61fb5c854..ddb6582b8 100644 --- a/app/db/adapters/transfer/admission.py +++ b/app/db/adapters/transfer/admission.py @@ -142,18 +142,14 @@ class TransactionalTransferAdmissionRepository: *, storage: str, src_path: str, - planning_input: Optional[TransferPlanningInput] = None, + planning_input: TransferPlanningInput, ) -> TransferAdmission: """按输入指纹幂等持久化准入事实,并返回跨重启稳定身份。""" if not storage or not src_path: raise ValueError("整理任务的存储与源路径不能为空") - 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 + planning_input.source_fileitem.get("storage") != storage + or planning_input.source_fileitem.get("path") != src_path ): raise ValueError("整理规划输入的源文件身份与准入参数不一致") now_time = self._now() @@ -167,16 +163,16 @@ 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, + input_version=planning_input.schema_version, + planning_input=planning_input.to_payload(), + input_fingerprint=planning_input.fingerprint, ) if pending is None: raise TransferAdmissionConflictError( f"整理源文件已有持久终态回执: {storage}:{src_path}" ) session.flush() - self._assert_input_match(pending, effective_input) + self._assert_input_match(pending, planning_input) admission = self._project(pending) transaction.commit() return admission @@ -192,7 +188,7 @@ class TransactionalTransferAdmissionRepository: ) if pending is None: raise RuntimeError("并发准入冲突后未找到已提交记录") from error - self._assert_input_match(pending, effective_input) + self._assert_input_match(pending, planning_input) return self._project(pending) def claim_task( @@ -525,14 +521,14 @@ class TransactionalTransferAdmissionRepository: transaction.rollback() raise - def discard_claimed(self, *, task_id: str, lease_token: str) -> int: - """仅以当前未过期 token 删除终态任务,拒绝陈旧 worker 变更。""" + def abandon_unstarted(self, *, task_id: str, lease_token: str) -> int: + """仅以当前 token 删除无执行证据的缺失源任务,拒绝陈旧 worker。""" if not task_id or not lease_token: return 0 with self._session_factory() as session: transaction = SqlAlchemyUnitOfWork(session) try: - deleted = TransferPendingOper(db=session).stage_discard_claimed( + deleted = TransferPendingOper(db=session).stage_abandon_unstarted( task_id=task_id, lease_token=lease_token, now_time=self._format_lease_time(self._lease_now()), diff --git a/app/db/adapters/transfer/execution.py b/app/db/adapters/transfer/execution.py index c81ff9d2e..c2558fb75 100644 --- a/app/db/adapters/transfer/execution.py +++ b/app/db/adapters/transfer/execution.py @@ -4,7 +4,8 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, timezone -from typing import Optional +from pathlib import Path +from typing import Any, Optional from sqlalchemy import and_, func, or_, select from sqlalchemy.exc import IntegrityError @@ -27,6 +28,14 @@ from app.application.transfer.execution import ( TransferStepIntent, TransferStepResult, TransferStepState, + build_transfer_operation_id, +) +from app.application.transfer.workflow import ( + TRANSFER_ADMISSION_PLANNED, + TRANSFER_ADMISSION_PROVIDER_PENDING, + TransferPlanCheckpoint, + TransferProviderInvocationSnapshot, + TransferProviderReference, ) from app.db.models.transferexecutionstep import ( TransferExecutionStep as TransferExecutionStepModel, @@ -229,14 +238,332 @@ class TransactionalTransferExecutionRepository: )) @staticmethod - def _raise_fenced_failure( + def _plan_identity( + pending: TransferPending, + ) -> tuple[TransferPlanCheckpoint, str]: + """恢复完整冻结计划,并返回其规范 payload 的稳定指纹。""" + if pending.state not in { + TRANSFER_ADMISSION_PLANNED, + TRANSFER_ADMISSION_PROVIDER_PENDING, + }: + raise TransferExecutionConflictError("整理任务尚未进入可执行规划状态") + if ( + pending.checkpoint_version is None + or pending.checkpoint_payload is None + or pending.planned_at is None + ): + raise TransferExecutionConflictError("整理任务缺少完整计划检查点") + try: + checkpoint = TransferPlanCheckpoint.from_payload( + pending.checkpoint_payload + ) + except (TypeError, ValueError) as error: + raise TransferExecutionConflictError( + "整理任务计划检查点无法恢复" + ) from error + if pending.checkpoint_version != checkpoint.schema_version: + raise TransferExecutionConflictError("整理任务计划检查点版本不一致") + if checkpoint.planning_input.fingerprint != pending.input_fingerprint: + raise TransferExecutionConflictError("整理任务计划与准入输入指纹不一致") + expected_state = ( + TRANSFER_ADMISSION_PROVIDER_PENDING + if checkpoint.is_provider_pending + else TRANSFER_ADMISSION_PLANNED + ) + if pending.state != expected_state: + raise TransferExecutionConflictError("整理任务计划类型与准入状态不一致") + return checkpoint, checkpoint.fingerprint + + @staticmethod + def _provider_predecessor_checkpoint( + checkpoint: TransferPlanCheckpoint, + step: TransferExecutionStepModel, + ) -> Optional[TransferPlanCheckpoint]: + """重建 provider 回退前的冻结计划,证明序号零步骤的历史归属。""" + if ( + not checkpoint.pre_execution_cleanup_completed + or step.ordinal != 0 + or step.phase != "provider" + or step.kind != "legacy_transfer_provider_sequence" + ): + return None + providers_payload = step.intent_payload.get("providers") + invocation_payload = step.intent_payload.get("invocation") + if ( + not isinstance(providers_payload, list) + or not providers_payload + or not all(isinstance(item, dict) for item in providers_payload) + or not isinstance(invocation_payload, dict) + ): + return None + try: + providers = tuple( + TransferProviderReference.from_payload(item) + for item in providers_payload + ) + invocation = TransferProviderInvocationSnapshot.from_payload( + invocation_payload + ) + if invocation.fileitem != checkpoint.planning_input.source_fileitem: + return None + predecessor = TransferPlanCheckpoint( + planning_input=checkpoint.planning_input, + target_storage="", + root_target_path="", + final_target_path="", + resolved_transfer_type="", + items=(), + resolved_meta=invocation.meta, + resolved_meta_kind=invocation.meta_kind, + resolved_mediainfo=invocation.mediainfo, + resolved_mediainfo_kind=invocation.mediainfo_kind, + resolved_episodes_info=invocation.episodes_info, + legacy_transfer_providers=providers, + provider_invocation=invocation, + preview=invocation.preview, + ) + except (TypeError, ValueError): + return None + return predecessor + + @staticmethod + def _intent_belongs_to_checkpoint( + checkpoint: TransferPlanCheckpoint, + *, + phase: str, + kind: str, + payload: dict[str, Any], + previous_steps: list[TransferExecutionStepModel], + ) -> bool: + """校验步骤类型及稳定参数由冻结计划或其已提交发现证据导出。""" + if checkpoint.rejection_error: + return ( + phase == "planning" + and kind == "reject" + and payload == {"error": checkpoint.rejection_error} + ) + if checkpoint.is_provider_pending: + invocation = checkpoint.provider_invocation + if invocation is None: + return False + return ( + phase == "provider" + and kind == "legacy_transfer_provider_sequence" + and payload == { + "providers": [ + provider.to_payload() + for provider in checkpoint.legacy_transfer_providers + ], + "invocation": invocation.to_payload(), + } + ) + plan_items = tuple(checkpoint.items) + if phase == "transfer" and kind == checkpoint.resolved_transfer_type: + return any(payload == { + "source": item.source_fileitem.get("path"), + "target": item.target_path, + } for item in plan_items) + if phase == "transfer" and kind == "materialize_target": + return any(payload == { + "source": item.source_fileitem, + "target_storage": item.target_storage, + "target_path": item.target_path, + "transfer_type": ( + "copy" + if ( + checkpoint.resolved_transfer_type == "move" + and item.source_fileitem.get("storage") + != item.target_storage + ) + else checkpoint.resolved_transfer_type + ), + } for item in plan_items) + if phase == "transfer" and kind == "delete_move_source": + return ( + checkpoint.resolved_transfer_type == "move" + and any( + item.source_fileitem.get("storage") != item.target_storage + and payload == { + "source": item.source_fileitem, + "target_storage": item.target_storage, + "target_path": item.target_path, + } + for item in plan_items + ) + ) + source = checkpoint.planning_input.source_fileitem + if phase == "prepare" and kind == "cleanup_previous_destination": + return payload == {"source_path": source.get("path")} + if phase == "prepare" and kind == "ensure_target_directory": + target_path = Path(checkpoint.final_target_path) + directory_path = ( + target_path + if source.get("type") == "dir" + else target_path.parent + ) + return payload == { + "storage": checkpoint.target_storage, + "path": directory_path.as_posix(), + } + if phase == "prepare" and kind == "delete_overwrite_target": + return payload == { + "storage": checkpoint.target_storage, + "path": checkpoint.final_target_path, + } + if phase == "decision" and kind == "resolve_overwrite": + return payload == { + "source": source, + "target_storage": checkpoint.target_storage, + "target_path": checkpoint.final_target_path, + "transfer_type": checkpoint.resolved_transfer_type, + "overwrite_mode": checkpoint.overwrite_mode, + "need_notify": checkpoint.need_notify, + } + if phase == "decision" and kind == "plugin_transfer_intercept": + stable_payload = { + "source": source, + "target_storage": checkpoint.target_storage, + "target_path": checkpoint.final_target_path, + "transfer_type": checkpoint.resolved_transfer_type, + } + return payload == stable_payload or ( + set(payload) == {*stable_payload, "over_flag"} + and all(payload[key] == value for key, value in stable_payload.items()) + and isinstance(payload.get("over_flag"), bool) + ) + if phase == "prepare" and kind == "discover_version_targets": + return payload == { + "storage": checkpoint.target_storage, + "path": checkpoint.final_target_path, + } + if phase == "prepare" and kind == "delete_version_target": + candidate = payload.get("item") + if ( + set(payload) != {"storage", "item"} + or payload.get("storage") != checkpoint.target_storage + or not isinstance(candidate, dict) + ): + return False + return any( + step.phase == "prepare" + and step.kind == "discover_version_targets" + and step.state == TransferStepState.SUCCEEDED.value + and isinstance(step.result_payload, dict) + and candidate in step.result_payload.get("items", []) + for step in previous_steps + ) + return False + + @classmethod + def _validate_plan_steps( + cls, + *, + task_id: str, + checkpoint: TransferPlanCheckpoint, + checkpoint_fingerprint: str, + steps: list[TransferExecutionStepModel], + ) -> None: + """验证全部持久步骤属于冻结计划演进且身份与全局顺序未被篡改。""" + if tuple(step.ordinal for step in steps) != tuple(range(len(steps))): + raise TransferExecutionConflictError("整理步骤全局序号不连续") + for index, step in enumerate(steps): + if step.task_id != task_id: + raise TransferExecutionConflictError("整理步骤绑定了错误任务") + step_checkpoint = checkpoint + if step.checkpoint_fingerprint != checkpoint_fingerprint: + predecessor = cls._provider_predecessor_checkpoint( + checkpoint, + step, + ) + if ( + predecessor is None + or step.checkpoint_fingerprint != predecessor.fingerprint + ): + raise TransferExecutionConflictError( + "整理步骤不属于当前冻结计划或合法 provider 前驱计划" + ) + step_checkpoint = predecessor + if not cls._intent_belongs_to_checkpoint( + step_checkpoint, + phase=step.phase, + kind=step.kind, + payload=step.intent_payload, + previous_steps=steps[:index], + ): + raise TransferExecutionConflictError( + "整理步骤类型或参数不能由冻结计划导出" + ) + expected_operation_id = build_transfer_operation_id( + task_id=task_id, + checkpoint_fingerprint=step.checkpoint_fingerprint, + ordinal=step.ordinal, + phase=step.phase, + kind=step.kind, + intent_payload=step.intent_payload, + ) + if step.operation_id != expected_operation_id: + raise TransferExecutionConflictError("整理步骤 operation ID 与冻结意图不一致") + + @classmethod + def _validate_new_intent( + cls, + *, + task_id: str, + checkpoint: TransferPlanCheckpoint, + checkpoint_fingerprint: str, + steps: list[TransferExecutionStepModel], + intent: TransferStepIntent, + ) -> None: + """验证待准备意图属于当前冻结计划,并且只追加或幂等重放既有序号。""" + cls._validate_plan_steps( + task_id=task_id, + checkpoint=checkpoint, + checkpoint_fingerprint=checkpoint_fingerprint, + steps=steps, + ) + if intent.checkpoint_fingerprint != checkpoint_fingerprint: + raise TransferExecutionConflictError("整理步骤意图未绑定当前冻结计划指纹") + if not cls._intent_belongs_to_checkpoint( + checkpoint, + phase=intent.phase, + kind=intent.kind, + payload=intent.payload, + previous_steps=steps, + ): + raise TransferExecutionConflictError( + "整理步骤意图类型或参数不能由冻结计划导出" + ) + expected_operation_id = build_transfer_operation_id( + task_id=task_id, + checkpoint_fingerprint=intent.checkpoint_fingerprint, + ordinal=intent.ordinal, + phase=intent.phase, + kind=intent.kind, + intent_payload=intent.payload, + ) + if intent.operation_id != expected_operation_id: + raise TransferExecutionConflictError("整理步骤意图 operation ID 不可信") + existing = next( + (step for step in steps if step.operation_id == intent.operation_id), + None, + ) + if existing is not None: + if not cls._intent_matches(existing, task_id=task_id, intent=intent): + raise TransferExecutionConflictError( + "稳定 operation ID 已绑定不同步骤意图" + ) + return + if intent.ordinal != len(steps): + raise TransferExecutionConflictError("整理步骤意图必须按全局序号连续追加") + + @staticmethod + def _require_active_lease( pending: Optional[TransferPending], *, lease_token: str, now_utc: str, - detail: str, - ) -> None: - """区分租约丢失与同租约内的状态或 attempt 冲突。""" + ) -> TransferPending: + """返回仍由调用方持有的任务,并优先报告 lease fencing 失败。""" if ( pending is None or pending.lease_token != lease_token @@ -244,6 +571,23 @@ class TransactionalTransferExecutionRepository: or pending.lease_expires_at <= now_utc ): raise TransferExecutionLeaseLostError("整理任务租约已失效或被接管") + return pending + + @classmethod + def _raise_fenced_failure( + cls, + pending: Optional[TransferPending], + *, + lease_token: str, + now_utc: str, + detail: str, + ) -> None: + """区分租约丢失与同租约内的状态或 attempt 冲突。""" + cls._require_active_lease( + pending, + lease_token=lease_token, + now_utc=now_utc, + ) raise TransferExecutionConflictError(detail) def _times(self) -> tuple[str, str]: @@ -354,9 +698,29 @@ class TransactionalTransferExecutionRepository: try: pending_oper = TransferPendingOper(session) pending = pending_oper.get_by_task_id(task_id=task_id) + pending = self._require_active_lease( + pending, + lease_token=lease_token, + now_utc=now_utc, + ) + checkpoint, checkpoint_fingerprint = self._plan_identity(pending) + oper = TransferExecutionStepOper(session) + steps = self._execution_steps( + oper.list_by_task_id(task_id=task_id) + ) + self._validate_new_intent( + task_id=task_id, + checkpoint=checkpoint, + checkpoint_fingerprint=checkpoint_fingerprint, + steps=steps, + intent=intent, + ) updated = pending_oper.stage_execution_running( task_id=task_id, lease_token=lease_token, + admission_state=pending.state, + checkpoint_version=checkpoint.schema_version, + checkpoint_payload=checkpoint.to_payload(), now_utc=now_utc, updated_at=updated_at, ) @@ -367,7 +731,6 @@ class TransactionalTransferExecutionRepository: now_utc=now_utc, detail="整理任务当前状态不能准备外部步骤", ) - oper = TransferExecutionStepOper(session) step = oper.get_by_operation_id(operation_id=intent.operation_id) if step is None: step = oper.stage_prepare( @@ -498,9 +861,32 @@ class TransactionalTransferExecutionRepository: try: pending_oper = TransferPendingOper(session) pending = pending_oper.get_by_task_id(task_id=task_id) + pending = self._require_active_lease( + pending, + lease_token=lease_token, + now_utc=now_utc, + ) + checkpoint, checkpoint_fingerprint = self._plan_identity(pending) + oper = TransferExecutionStepOper(session) + steps = self._execution_steps( + oper.list_by_task_id(task_id=task_id) + ) + self._validate_plan_steps( + task_id=task_id, + checkpoint=checkpoint, + checkpoint_fingerprint=checkpoint_fingerprint, + steps=steps, + ) + if operation_id not in {step.operation_id for step in steps}: + raise TransferExecutionConflictError( + "待恢复步骤不属于冻结计划" + ) pending_updated = pending_oper.stage_execution_running( task_id=task_id, lease_token=lease_token, + admission_state=pending.state, + checkpoint_version=checkpoint.schema_version, + checkpoint_payload=checkpoint.to_payload(), now_utc=now_utc, updated_at=updated_at, ) @@ -511,7 +897,6 @@ class TransactionalTransferExecutionRepository: now_utc=now_utc, detail="重试任务未到期或当前状态不能恢复", ) - oper = TransferExecutionStepOper(session) updated = oper.stage_resume_failed_attempt( task_id=task_id, lease_token=lease_token, @@ -973,24 +1358,39 @@ class TransactionalTransferExecutionRepository: with self._session_factory() as session: transaction = SqlAlchemyUnitOfWork(session) try: + pending_oper = TransferPendingOper(session) + pending = pending_oper.get_by_task_id(task_id=task_id) + pending = self._require_active_lease( + pending, + lease_token=lease_token, + now_utc=now_utc, + ) + plan_checkpoint, plan_fingerprint = self._plan_identity(pending) step_oper = TransferExecutionStepOper(session) steps = self._execution_steps( step_oper.list_by_task_id(task_id=task_id) ) - step_ids = {step.operation_id for step in steps} - if step_ids != set(checkpoint.operation_ids): + self._validate_plan_steps( + task_id=task_id, + checkpoint=plan_checkpoint, + checkpoint_fingerprint=plan_fingerprint, + steps=steps, + ) + step_ids = tuple(step.operation_id for step in steps) + if step_ids != checkpoint.operation_ids: raise TransferExecutionConflictError( - "执行检查点引用的步骤集合与持久步骤不一致" + "执行检查点引用的步骤顺序与持久步骤不一致" ) if any(step.state != TransferStepState.SUCCEEDED.value for step in steps): raise TransferExecutionConflictError( "存在未成功步骤,不能提交执行检查点" ) - pending_oper = TransferPendingOper(session) - pending = pending_oper.get_by_task_id(task_id=task_id) running = pending_oper.stage_execution_running( task_id=task_id, lease_token=lease_token, + admission_state=pending.state, + checkpoint_version=plan_checkpoint.schema_version, + checkpoint_payload=plan_checkpoint.to_payload(), now_utc=now_utc, updated_at=updated_at, ) diff --git a/app/db/models/transferpending.py b/app/db/models/transferpending.py index fc0650b67..8637da340 100644 --- a/app/db/models/transferpending.py +++ b/app/db/models/transferpending.py @@ -1,5 +1,3 @@ -import hashlib -import json from datetime import datetime from typing import Any, List, Optional, cast from uuid import uuid4 @@ -12,66 +10,21 @@ from sqlalchemy import ( Text, UniqueConstraint, and_, + column, delete, + exists, func, or_, select, + table, update, ) from sqlalchemy.orm import Mapped, Session, mapped_column from app.db.base import Base, execute_dml, get_id_column - -def _legacy_planning_payload(storage: str, src_path: str) -> dict[str, Any]: - return { - "schema_version": 1, - "source_fileitem": {"storage": storage, "path": src_path}, - "meta": None, - "mediainfo": None, - "target_directory": None, - "target_storage": None, - "target_path": None, - "requested_transfer_type": None, - "media_source": None, - "media_id": None, - "media_type": None, - "need_scrape": False, - "need_rename": True, - "need_notify": True, - "overwrite_mode": None, - "episodes_info": [], - "preview": False, - "options": {"legacy_replan": True}, - } - - -def _planning_fingerprint(payload: dict[str, Any]) -> str: - canonical = json.dumps( - payload, - ensure_ascii=True, - sort_keys=True, - separators=(",", ":"), - allow_nan=False, - ) - return hashlib.sha256(canonical.encode("utf-8")).hexdigest() - - -def _default_planning_payload(context: Any) -> dict[str, Any]: - params = context.get_current_parameters() - return _legacy_planning_payload( - params.get("storage", ""), - params.get("src_path", ""), - ) - - -def _default_planning_fingerprint(context: Any) -> str: - params = context.get_current_parameters() - payload = params.get("planning_input") or _legacy_planning_payload( - params.get("storage", ""), - params.get("src_path", ""), - ) - return _planning_fingerprint(payload) +_TRANSFER_EXECUTION_STEP = table("transferexecutionstep", column("task_id")) +_TRANSFER_HISTORY = table("transferhistory", column("transfer_task_id")) class TransferPending(Base): @@ -112,11 +65,11 @@ class TransferPending(Base): input_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) # 版本化规划输入 JSON planning_input: Mapped[dict[str, Any]] = mapped_column( - JSON, nullable=False, default=_default_planning_payload + JSON, nullable=False ) # 规划输入规范 JSON 的 SHA-256 指纹 input_fingerprint: Mapped[str] = mapped_column( - String(64), nullable=False, default=_default_planning_fingerprint + String(64), nullable=False ) # 完整计划格式版本,尚未规划时为空 checkpoint_version: Mapped[Optional[int]] = mapped_column(Integer) @@ -203,9 +156,9 @@ class TransferPending(Base): @classmethod def stage_admit(cls, db: Session, *, task_id: str, storage: str, src_path: str, state: str, - now_time: str, input_version: int = 1, - planning_input: Optional[dict[str, Any]] = None, - input_fingerprint: Optional[str] = None) -> Optional["TransferPending"]: + now_time: str, input_version: int, + planning_input: dict[str, Any], + input_fingerprint: str) -> Optional["TransferPending"]: """ 在调用方会话中暂存一条持久接纳记录。 @@ -221,15 +174,20 @@ class TransferPending(Base): :param input_fingerprint: 规划输入规范 JSON 指纹 :return: 接纳记录 """ - if not task_id or not storage or not src_path or not state: + if ( + not task_id + or not storage + or not src_path + or not state + or not planning_input + or not input_fingerprint + ): return None pending = db.execute( select(cls).where(cls.storage == storage, cls.src_path == src_path) ).scalars().first() if pending: return cast("TransferPending", pending) - effective_input = planning_input or _legacy_planning_payload(storage, src_path) - effective_fingerprint = input_fingerprint or _planning_fingerprint(effective_input) pending = cls( task_id=task_id, storage=storage, @@ -238,8 +196,8 @@ class TransferPending(Base): created_at=now_time, updated_at=now_time, input_version=input_version, - planning_input=effective_input, - input_fingerprint=effective_fingerprint, + planning_input=planning_input, + input_fingerprint=input_fingerprint, ) db.add(pending) return pending @@ -425,17 +383,33 @@ class TransferPending(Base): *, task_id: str, lease_token: str, + admission_state: str, + checkpoint_version: int, + checkpoint_payload: dict[str, Any], now_utc: str, updated_at: str, ) -> int: - """以有效租约把可执行任务推进或保持为 running。""" - if not all((task_id, lease_token, now_utc, updated_at)): + """仅以有效租约和完整冻结计划把任务推进或保持为 running。""" + if not all(( + task_id, + lease_token, + admission_state, + checkpoint_version, + checkpoint_payload, + now_utc, + updated_at, + )): return 0 return execute_dml( db, update(cls) .where( cls.task_id == task_id, + cls.state == admission_state, + cls.state.in_(("planned", "provider_pending")), + cls.checkpoint_version == checkpoint_version, + cls.checkpoint_payload == checkpoint_payload, + cls.planned_at.is_not(None), cls.lease_token == lease_token, cls.lease_expires_at.is_not(None), cls.lease_expires_at > now_utc, @@ -470,9 +444,10 @@ class TransferPending(Base): update(cls) .where( cls.task_id == task_id, - cls.state == "planned", + cls.state.in_(("planned", "provider_pending")), cls.checkpoint_version.is_not(None), cls.checkpoint_payload.is_not(None), + cls.planned_at.is_not(None), cls.execution_state == "running", cls.lease_token == lease_token, cls.lease_expires_at.is_not(None), @@ -512,6 +487,10 @@ class TransferPending(Base): update(cls) .where( cls.task_id == task_id, + cls.state.in_(("planned", "provider_pending")), + cls.checkpoint_version.is_not(None), + cls.checkpoint_payload.is_not(None), + cls.planned_at.is_not(None), cls.execution_state.in_(("not_started", "running", "retry_wait")), cls.lease_token == lease_token, cls.lease_expires_at.is_not(None), @@ -558,9 +537,10 @@ class TransferPending(Base): update(cls) .where( cls.task_id == task_id, - cls.state == "planned", + cls.state.in_(("planned", "provider_pending")), cls.checkpoint_version.is_not(None), cls.checkpoint_payload.is_not(None), + cls.planned_at.is_not(None), cls.execution_state == "running", cls.lease_token == lease_token, cls.lease_expires_at.is_not(None), @@ -598,9 +578,10 @@ class TransferPending(Base): update(cls) .where( cls.task_id == task_id, - cls.state == "planned", + cls.state.in_(("planned", "provider_pending")), cls.checkpoint_version.is_not(None), cls.checkpoint_payload.is_not(None), + cls.planned_at.is_not(None), cls.execution_state == "running", cls.lease_token == lease_token, cls.lease_expires_at.is_not(None), @@ -886,7 +867,7 @@ class TransferPending(Base): ) @classmethod - def discard_claimed( + def abandon_unstarted( cls, db: Session, *, @@ -895,7 +876,10 @@ class TransferPending(Base): now_time: str, ) -> int: """ - 仅以当前且未过期的 token 删除已经到达终态的租约任务。 + 仅以当前且未过期的 token 删除确认从未执行的缺失源任务。 + + 任何执行状态、聚合检查点或步骤证据都意味着外部结果需要由状态机 + 判定;即使源文件已经消失,也不能以此推断 move 已经安全完成。 :param db: 数据库会话 :param task_id: 稳定任务标识 @@ -912,6 +896,29 @@ class TransferPending(Base): cls.lease_token == lease_token, cls.lease_expires_at.is_not(None), cls.lease_expires_at > now_time, + cls.state == "accepted", + cls.checkpoint_version.is_(None), + cls.checkpoint_payload.is_(None), + cls.planned_at.is_(None), + cls.execution_state == "not_started", + cls.execution_version.is_(None), + cls.execution_payload.is_(None), + cls.execution_fingerprint.is_(None), + cls.retry_generation == 0, + cls.retry_count == 0, + cls.retry_due_at.is_(None), + cls.settlement_revision == 0, + cls.terminal_history_id.is_(None), + ~exists( + select(_TRANSFER_EXECUTION_STEP.c.task_id).where( + _TRANSFER_EXECUTION_STEP.c.task_id == cls.task_id + ) + ), + ~exists( + select(_TRANSFER_HISTORY.c.transfer_task_id).where( + _TRANSFER_HISTORY.c.transfer_task_id == cls.task_id + ) + ), ), execution_options={"synchronize_session": False}, ) diff --git a/app/db/oper/transferpending.py b/app/db/oper/transferpending.py index c919146be..e594d8c17 100644 --- a/app/db/oper/transferpending.py +++ b/app/db/oper/transferpending.py @@ -13,9 +13,9 @@ class TransferPendingOper(DbOper): """ def stage_admit(self, *, task_id: str, storage: str, src_path: str, - state: str, now_time: str, input_version: int = 1, - planning_input: Optional[dict[str, Any]] = None, - input_fingerprint: Optional[str] = None) -> Optional[TransferPending]: + state: str, now_time: str, input_version: int, + planning_input: dict[str, Any], + input_fingerprint: str) -> Optional[TransferPending]: """ 在当前会话中暂存一条持久接纳记录。 @@ -287,7 +287,7 @@ class TransferPendingOper(DbOper): ) ) - def stage_discard_claimed( + def stage_abandon_unstarted( self, *, task_id: str, @@ -295,7 +295,7 @@ class TransferPendingOper(DbOper): now_time: str, ) -> int: """ - 使用当前会话按当前未过期 token 删除终态任务。 + 使用当前会话按当前未过期 token 删除确认从未执行的缺失源任务。 :param task_id: 稳定任务标识 :param lease_token: 当前租约令牌 @@ -303,7 +303,7 @@ class TransferPendingOper(DbOper): :return: 删除的记录数 """ return self._execute_sync_write( - lambda session: TransferPending.discard_claimed( + lambda session: TransferPending.abandon_unstarted( session, task_id=task_id, lease_token=lease_token, @@ -334,6 +334,9 @@ class TransferPendingOper(DbOper): *, task_id: str, lease_token: str, + admission_state: str, + checkpoint_version: int, + checkpoint_payload: dict[str, Any], now_utc: str, updated_at: str, ) -> int: @@ -343,6 +346,9 @@ class TransferPendingOper(DbOper): session, task_id=task_id, lease_token=lease_token, + admission_state=admission_state, + checkpoint_version=checkpoint_version, + checkpoint_payload=checkpoint_payload, now_utc=now_utc, updated_at=updated_at, ) diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index ffd5570af..1c345dd68 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -1,7 +1,8 @@ from enum import Enum as _Enum from typing import Dict, List, Literal, Optional, Union -from pydantic import BaseModel, Field, PrivateAttr, RootModel, field_validator +from pydantic import BaseModel, Field, RootModel, field_validator +from pydantic import PrivateAttr as _PrivateAttr from app.schemas.common import JsonData @@ -62,7 +63,7 @@ class Plugin(BaseModel): """ 插件信息 """ - _package_version: Optional[str] = PrivateAttr(default=None) + _package_version: Optional[str] = _PrivateAttr(default=None) id: str = None # 插件名称 diff --git a/app/sdk/_legacy/transferpending.py b/app/sdk/_legacy/transferpending.py index 98393fce3..3db5daaae 100644 --- a/app/sdk/_legacy/transferpending.py +++ b/app/sdk/_legacy/transferpending.py @@ -1,5 +1,7 @@ """兼容旧 ``app.db.transferpending_oper`` 的无 Session 数据访问接口。""" +import hashlib +import json from datetime import datetime from typing import Any, List, Optional, Tuple from uuid import uuid4 @@ -13,6 +15,42 @@ _TRANSFER_EXECUTION_STEP = table("transferexecutionstep", column("task_id")) _TRANSFER_HISTORY = table("transferhistory", column("transfer_task_id")) +def _legacy_planning_payload(storage: str, src_path: str) -> dict[str, Any]: + """只在旧登记 ABI 内构造可由新状态机保守重规划的最小输入。""" + return { + "schema_version": 1, + "source_fileitem": {"storage": storage, "path": src_path}, + "meta": None, + "mediainfo": None, + "target_directory": None, + "target_storage": None, + "target_path": None, + "requested_transfer_type": None, + "media_source": None, + "media_id": None, + "media_type": None, + "need_scrape": False, + "need_rename": True, + "need_notify": True, + "overwrite_mode": None, + "episodes_info": [], + "preview": False, + "options": {"legacy_replan": True}, + } + + +def _planning_fingerprint(payload: dict[str, Any]) -> str: + """生成兼容登记输入与 canonical DTO 一致的规范指纹。""" + canonical = json.dumps( + payload, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + def _safe_legacy_delete_predicates() -> tuple[Any, ...]: """只允许旧接口删除从未 claim、执行或结算的新鲜登记。""" return ( @@ -61,6 +99,7 @@ class TransferPendingOper(DbOper): :return: 登记记录 """ now_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + planning_input = _legacy_planning_payload(storage, src_path) return self._execute_sync_write( lambda session: _TransferPending.stage_admit( session, @@ -69,6 +108,9 @@ class TransferPendingOper(DbOper): src_path=src_path, state="accepted", now_time=now_time, + input_version=1, + planning_input=planning_input, + input_fingerprint=_planning_fingerprint(planning_input), ) ) diff --git a/database/versions/b1e7d3f5a9c2_3_0_13.py b/database/versions/b1e7d3f5a9c2_3_0_13.py index 4a3f66b88..bd5cea601 100644 --- a/database/versions/b1e7d3f5a9c2_3_0_13.py +++ b/database/versions/b1e7d3f5a9c2_3_0_13.py @@ -33,22 +33,49 @@ def _column_names() -> set[str]: } -def _has_task_id_constraint() -> bool: - """判断稳定任务标识唯一约束是否已经存在。""" - inspector = sa.inspect(op.get_bind()) - return any( - constraint.get("name") == _TASK_ID_CONSTRAINT - for constraint in inspector.get_unique_constraints(_TABLE_NAME) - ) +def _repair_task_id_constraint() -> None: + """把稳定任务身份约束修复为 task_id 单列唯一约束。""" + constraints = { + constraint.get("name"): tuple(constraint.get("column_names") or ()) + for constraint in sa.inspect(op.get_bind()).get_unique_constraints( + _TABLE_NAME + ) + if constraint.get("name") + } + current = constraints.get(_TASK_ID_CONSTRAINT) + if current is not None and current != ("task_id",): + with op.batch_alter_table(_TABLE_NAME) as batch_op: + batch_op.drop_constraint(_TASK_ID_CONSTRAINT, type_="unique") + current = None + if current is None: + with op.batch_alter_table(_TABLE_NAME) as batch_op: + batch_op.create_unique_constraint( + _TASK_ID_CONSTRAINT, + ["task_id"], + ) -def _has_state_created_index() -> bool: - """判断恢复主查询的复合索引是否已经存在。""" - inspector = sa.inspect(op.get_bind()) - return any( - index.get("name") == _STATE_CREATED_INDEX - for index in inspector.get_indexes(_TABLE_NAME) - ) +def _repair_state_created_index() -> None: + """按列顺序和非唯一语义修复恢复扫描索引。""" + indexes = { + index.get("name"): index + for index in sa.inspect(op.get_bind()).get_indexes(_TABLE_NAME) + if index.get("name") + } + current = indexes.get(_STATE_CREATED_INDEX) + if current is not None and ( + tuple(current.get("column_names") or ()) != ("state", "created_at", "id") + or bool(current.get("unique")) + ): + op.drop_index(_STATE_CREATED_INDEX, table_name=_TABLE_NAME) + current = None + if current is None: + op.create_index( + _STATE_CREATED_INDEX, + _TABLE_NAME, + ["state", "created_at", "id"], + unique=False, + ) def _backfill_admission_state() -> None: @@ -135,19 +162,8 @@ def upgrade() -> None: batch_op.alter_column( "updated_at", existing_type=sa.String(length=40), nullable=False ) - if not _has_task_id_constraint(): - with op.batch_alter_table(_TABLE_NAME) as batch_op: - batch_op.create_unique_constraint( - _TASK_ID_CONSTRAINT, - ["task_id"], - ) - if not _has_state_created_index(): - op.create_index( - _STATE_CREATED_INDEX, - _TABLE_NAME, - ["state", "created_at", "id"], - unique=False, - ) + _repair_task_id_constraint() + _repair_state_created_index() def downgrade() -> None: @@ -155,10 +171,20 @@ def downgrade() -> None: columns = _column_names() if not columns or not (_NEW_COLUMNS & columns): return - if _has_state_created_index(): + index_names = { + index.get("name") + for index in sa.inspect(op.get_bind()).get_indexes(_TABLE_NAME) + } + if _STATE_CREATED_INDEX in index_names: op.drop_index(_STATE_CREATED_INDEX, table_name=_TABLE_NAME) + constraint_names = { + constraint.get("name") + for constraint in sa.inspect(op.get_bind()).get_unique_constraints( + _TABLE_NAME + ) + } with op.batch_alter_table(_TABLE_NAME) as batch_op: - if "task_id" in columns and _has_task_id_constraint(): + if "task_id" in columns and _TASK_ID_CONSTRAINT in constraint_names: batch_op.drop_constraint(_TASK_ID_CONSTRAINT, type_="unique") for column_name in ("last_error", "updated_at", "state", "task_id"): if column_name in columns: diff --git a/database/versions/c2f8a4d6e1b3_3_0_14.py b/database/versions/c2f8a4d6e1b3_3_0_14.py index e13fbcb14..55a5b6c5a 100644 --- a/database/versions/c2f8a4d6e1b3_3_0_14.py +++ b/database/versions/c2f8a4d6e1b3_3_0_14.py @@ -97,27 +97,26 @@ def _backfill_planning_input() -> None: ) ).mappings().all() for row in rows: - if ( - row["input_version"] is not None - and row["planning_input"] is not None - and row["input_fingerprint"] - ): - continue payload = row["planning_input"] if not isinstance(payload, dict): payload = _legacy_planning_payload(row["storage"], row["src_path"]) payload_version = payload.get("schema_version") - input_version = row["input_version"] - if input_version is None: - input_version = payload_version if isinstance(payload_version, int) else 1 - input_fingerprint = row["input_fingerprint"] or _fingerprint(payload) + if ( + not isinstance(payload_version, int) + or isinstance(payload_version, bool) + or payload_version != 1 + ): + raise RuntimeError( + f"transferpending {row['id']} 的规划输入版本不受支持: " + f"{payload_version}" + ) connection.execute( pending.update() .where(pending.c.id == row["id"]) .values( - input_version=input_version, + input_version=payload_version, planning_input=payload, - input_fingerprint=input_fingerprint, + input_fingerprint=_fingerprint(payload), ) ) diff --git a/database/versions/d3a9e5f7b2c4_3_0_15.py b/database/versions/d3a9e5f7b2c4_3_0_15.py index 213a5b4dc..8541867e7 100644 --- a/database/versions/d3a9e5f7b2c4_3_0_15.py +++ b/database/versions/d3a9e5f7b2c4_3_0_15.py @@ -35,16 +35,28 @@ def _column_names() -> set[str]: } -def _index_names() -> set[str]: - """返回当前待整理登记表的索引名称集合。""" - inspector = sa.inspect(op.get_bind()) - if _TABLE_NAME not in inspector.get_table_names(): - return set() - return { - index["name"] - for index in inspector.get_indexes(_TABLE_NAME) +def _repair_lease_index() -> None: + """按列顺序和非唯一语义修复租约接管索引。""" + indexes = { + index.get("name"): index + for index in sa.inspect(op.get_bind()).get_indexes(_TABLE_NAME) if index.get("name") } + current = indexes.get(_LEASE_INDEX) + if current is not None and ( + tuple(current.get("column_names") or ()) + != ("state", "lease_expires_at", "created_at", "id") + or bool(current.get("unique")) + ): + op.drop_index(_LEASE_INDEX, table_name=_TABLE_NAME) + current = None + if current is None: + op.create_index( + _LEASE_INDEX, + _TABLE_NAME, + ["state", "lease_expires_at", "created_at", "id"], + unique=False, + ) def upgrade() -> None: @@ -96,13 +108,7 @@ def upgrade() -> None: nullable=False, ) - if _LEASE_INDEX not in _index_names(): - op.create_index( - _LEASE_INDEX, - _TABLE_NAME, - ["state", "lease_expires_at", "created_at", "id"], - unique=False, - ) + _repair_lease_index() def downgrade() -> None: @@ -110,7 +116,11 @@ def downgrade() -> None: columns = _column_names() if not columns or not (_LEASE_COLUMNS & columns): return - if _LEASE_INDEX in _index_names(): + index_names = { + index.get("name") + for index in sa.inspect(op.get_bind()).get_indexes(_TABLE_NAME) + } + if _LEASE_INDEX in index_names: op.drop_index(_LEASE_INDEX, table_name=_TABLE_NAME) with op.batch_alter_table(_TABLE_NAME) as batch_op: for column_name in ( diff --git a/database/versions/e3d9f4b7c806_3_0_4.py b/database/versions/e3d9f4b7c806_3_0_4.py index 1e0a9e4ce..7aba89717 100644 --- a/database/versions/e3d9f4b7c806_3_0_4.py +++ b/database/versions/e3d9f4b7c806_3_0_4.py @@ -10,14 +10,18 @@ Revises: 7f5c1d2e3a4b Create Date: 2026-08-10 """ -from alembic import op import sqlalchemy as sa +from alembic import op revision = "e3d9f4b7c806" down_revision = "7f5c1d2e3a4b" branch_labels = None depends_on = None +_TABLE_NAME = "transferpending" +_INDEX_NAME = "ux_transferpending_storage_path" +_EXPECTED_COLUMNS = {"id", "storage", "src_path", "created_at"} + def _has_table(table_name: str) -> bool: """检查数据表是否已存在。""" @@ -25,33 +29,110 @@ def _has_table(table_name: str) -> bool: return table_name in inspector.get_table_names() -def upgrade() -> None: - """ - 创建待整理文件登记表。 - """ - if _has_table("transferpending"): - return +def _table_row_count() -> int: + """返回待整理表行数,用于判断残缺结构能否无损重建。""" + return op.get_bind().execute( + sa.select(sa.func.count()).select_from(sa.table(_TABLE_NAME)) + ).scalar_one() + + +def _create_table() -> None: + """创建 3.0.4 定义的完整待整理登记表。""" op.create_table( - "transferpending", + _TABLE_NAME, sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), sa.Column("storage", sa.String, nullable=False), sa.Column("src_path", sa.String, nullable=False), sa.Column("created_at", sa.String), ) - # 同一个文件重复入队只保留一条,回放时不会重复送入整理链 - op.create_index( - "ux_transferpending_storage_path", - "transferpending", - ["storage", "src_path"], - unique=True, + + +def _validate_or_recreate_table() -> None: + """校验中断升级留下的表结构,仅允许空残表自动重建。""" + inspector = sa.inspect(op.get_bind()) + columns = inspector.get_columns(_TABLE_NAME) + column_names = {column["name"] for column in columns} + nullable = { + column["name"] + for column in columns + if column.get("nullable", True) + } + primary_key = tuple( + inspector.get_pk_constraint(_TABLE_NAME).get("constrained_columns") or () ) + column_types = { + column["name"]: column["type"] + for column in columns + } + wrong_types = ( + not isinstance(column_types.get("id"), sa.Integer) + or any( + not isinstance(column_types.get(column_name), sa.String) + for column_name in ("storage", "src_path", "created_at") + ) + ) + malformed = ( + column_names != _EXPECTED_COLUMNS + or nullable != {"created_at"} + or primary_key != ("id",) + or wrong_types + ) + if not malformed: + return + if _table_row_count() > 0: + raise RuntimeError( + "检测到含数据的不完整 transferpending 表," + "无法自动恢复 3.0.4 迁移" + ) + op.drop_table(_TABLE_NAME) + _create_table() + + +def _repair_storage_path_index() -> None: + """把源身份索引修复为指定列上的唯一索引。""" + indexes = { + index["name"]: index + for index in sa.inspect(op.get_bind()).get_indexes(_TABLE_NAME) + if index.get("name") + } + current = indexes.get(_INDEX_NAME) + if current is not None and ( + tuple(current.get("column_names") or ()) != ("storage", "src_path") + or not current.get("unique") + ): + op.drop_index(_INDEX_NAME, table_name=_TABLE_NAME) + current = None + if current is None: + op.create_index( + _INDEX_NAME, + _TABLE_NAME, + ["storage", "src_path"], + unique=True, + ) + + +def upgrade() -> None: + """ + 创建待整理文件登记表。 + """ + if not _has_table(_TABLE_NAME): + _create_table() + else: + _validate_or_recreate_table() + _repair_storage_path_index() def downgrade() -> None: """ 删除待整理文件登记表。 """ - if not _has_table("transferpending"): + if not _has_table(_TABLE_NAME): return - op.drop_index("ux_transferpending_storage_path", table_name="transferpending") - op.drop_table("transferpending") + index_names = { + index["name"] + for index in sa.inspect(op.get_bind()).get_indexes(_TABLE_NAME) + if index.get("name") + } + if _INDEX_NAME in index_names: + op.drop_index(_INDEX_NAME, table_name=_TABLE_NAME) + op.drop_table(_TABLE_NAME) diff --git a/database/versions/e5c7a9b1d3f6_3_0_16.py b/database/versions/e5c7a9b1d3f6_3_0_16.py index c2f5c8667..aaef8b9a0 100644 --- a/database/versions/e5c7a9b1d3f6_3_0_16.py +++ b/database/versions/e5c7a9b1d3f6_3_0_16.py @@ -5,9 +5,9 @@ Revises: d3a9e5f7b2c4 Create Date: 2026-08-27 """ -from collections.abc import Callable import hashlib import json +from collections.abc import Callable import sqlalchemy as sa from alembic import op @@ -21,6 +21,7 @@ _PENDING_TABLE = "transferpending" _HISTORY_TABLE = "transferhistory" _STEP_TABLE = "transferexecutionstep" _RECEIPT_TABLE = "transfersettlementreceipt" +_RECEIPT_ARCHIVE_TABLE = "transfersettlementreceipt_3_0_16_archive" _PENDING_INDEX = "ix_transferpending_execution_due" _HISTORY_INDEX = "ux_transferhistory_transfer_task_id" _STEP_OPERATION_UNIQUE = "uq_transferexecutionstep_operation_id" @@ -247,6 +248,35 @@ def _repair_indexes( ) +def _repair_owned_index( + *, + table_name: str, + index_name: str, + columns: tuple[str, ...], + unique: bool, +) -> None: + """按列顺序和唯一性精确修复宿主表上的迁移自有索引。""" + indexes = { + item["name"]: item + for item in sa.inspect(op.get_bind()).get_indexes(table_name) + if item.get("name") and not item.get("duplicates_constraint") + } + current = indexes.get(index_name) + if current is not None and ( + tuple(current.get("column_names") or ()) != columns + or bool(current.get("unique")) != unique + ): + op.drop_index(index_name, table_name=table_name) + current = None + if current is None: + op.create_index( + index_name, + table_name, + list(columns), + unique=unique, + ) + + def _column_type_signature(column_type: sa.types.TypeEngine) -> tuple[str, object]: """把方言反射类型归一为迁移可稳定比较的类型与长度。""" if isinstance(column_type, sa.JSON): @@ -554,6 +584,27 @@ def _create_receipt_table() -> None: ) +def _restore_receipt_archive() -> None: + """在重升时原子恢复降级保留的 append-only 结算证据。""" + tables = _table_names() + if _RECEIPT_ARCHIVE_TABLE not in tables: + return + if _RECEIPT_TABLE not in tables: + op.rename_table(_RECEIPT_ARCHIVE_TABLE, _RECEIPT_TABLE) + return + archive_count = _table_row_count(_RECEIPT_ARCHIVE_TABLE) + live_count = _table_row_count(_RECEIPT_TABLE) + if archive_count > 0 and live_count > 0: + raise RuntimeError( + "结算回执现表与降级归档同时含数据,无法自动判定恢复顺序" + ) + if archive_count > 0: + op.drop_table(_RECEIPT_TABLE) + op.rename_table(_RECEIPT_ARCHIVE_TABLE, _RECEIPT_TABLE) + else: + op.drop_table(_RECEIPT_ARCHIVE_TABLE) + + def _create_or_repair_receipt_table() -> None: """创建结算回执表,并只对空的残缺表执行无损重建。""" if _RECEIPT_TABLE not in _table_names(): @@ -704,30 +755,29 @@ def upgrade() -> None: tables = _table_names() if _PENDING_TABLE not in tables: return + _restore_receipt_archive() _add_pending_columns() _backfill_pending() - if _PENDING_INDEX not in _index_names(_PENDING_TABLE): - op.create_index( - _PENDING_INDEX, - _PENDING_TABLE, - [ - "execution_state", - "retry_due_at", - "state", - "created_at", - "id", - ], - unique=False, - ) + _repair_owned_index( + table_name=_PENDING_TABLE, + index_name=_PENDING_INDEX, + columns=( + "execution_state", + "retry_due_at", + "state", + "created_at", + "id", + ), + unique=False, + ) if _HISTORY_TABLE in tables: _add_history_columns() - if _HISTORY_INDEX not in _index_names(_HISTORY_TABLE): - op.create_index( - _HISTORY_INDEX, - _HISTORY_TABLE, - ["transfer_task_id"], - unique=True, - ) + _repair_owned_index( + table_name=_HISTORY_TABLE, + index_name=_HISTORY_INDEX, + columns=("transfer_task_id",), + unique=True, + ) _create_or_repair_receipt_table() _create_or_repair_step_table() _backfill_legacy_review_steps() @@ -817,8 +867,24 @@ def downgrade() -> None: _mark_downgrade_uncertain() if _STEP_TABLE in _table_names(): op.drop_table(_STEP_TABLE) - if _RECEIPT_TABLE in _table_names(): - op.drop_table(_RECEIPT_TABLE) + tables = _table_names() + if _RECEIPT_TABLE in tables: + live_count = _table_row_count(_RECEIPT_TABLE) + if _RECEIPT_ARCHIVE_TABLE in tables: + archive_count = _table_row_count(_RECEIPT_ARCHIVE_TABLE) + if live_count > 0 and archive_count > 0: + raise RuntimeError( + "结算回执现表与降级归档同时含数据,拒绝覆盖恢复证据" + ) + if archive_count > 0: + op.drop_table(_RECEIPT_TABLE) + live_count = 0 + else: + op.drop_table(_RECEIPT_ARCHIVE_TABLE) + if live_count > 0: + op.rename_table(_RECEIPT_TABLE, _RECEIPT_ARCHIVE_TABLE) + elif _RECEIPT_TABLE in _table_names(): + op.drop_table(_RECEIPT_TABLE) if _HISTORY_TABLE in _table_names(): history_columns = _column_names(_HISTORY_TABLE) if _HISTORY_INDEX in _index_names(_HISTORY_TABLE): diff --git a/database/versions/f6d8b0c2e4a7_3_0_17.py b/database/versions/f6d8b0c2e4a7_3_0_17.py new file mode 100644 index 000000000..df4169d8d --- /dev/null +++ b/database/versions/f6d8b0c2e4a7_3_0_17.py @@ -0,0 +1,703 @@ +"""3.0.17 收口整理规划状态与执行恢复证据。 + +Revision ID: f6d8b0c2e4a7 +Revises: e5c7a9b1d3f6 +Create Date: 2026-08-27 +""" + +import hashlib +import json +from typing import Any + +import sqlalchemy as sa +from alembic import op + +revision = "f6d8b0c2e4a7" +down_revision = "e5c7a9b1d3f6" +branch_labels = None +depends_on = None + +_PENDING_TABLE = "transferpending" +_STEP_TABLE = "transferexecutionstep" +_RECEIPT_TABLE = "transfersettlementreceipt" +_HISTORY_TABLE = "transferhistory" +_ALLOWED_EXECUTION_STATES = { + "not_started", + "running", + "retry_wait", + "settling", + "failed", + "manual_review", +} +_REVIEW_DIAGNOSTIC = "升级检测到不完整执行状态,需人工确认后再处理" +_REVIEW_STEP_KIND = "legacy_execution_review" +_REVIEW_STEP_ORDINAL = 2_147_483_647 +_FALLBACK_TIME = "1970-01-01 00:00:00" + + +def _table_names() -> set[str]: + """返回当前数据库的表名集合。""" + return set(sa.inspect(op.get_bind()).get_table_names()) + + +def _canonical_json(payload: dict[str, Any]) -> str: + """生成与运行时一致的稳定 JSON 表达。""" + return json.dumps( + payload, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + + +def _fingerprint(payload: dict[str, Any]) -> str: + """计算版本化 JSON 的 SHA-256 指纹。""" + return hashlib.sha256(_canonical_json(payload).encode("utf-8")).hexdigest() + + +def _is_nonempty_string(value: object) -> bool: + """判断值是否为非空文本。""" + return isinstance(value, str) and bool(value) + + +def _is_source_fileitem(value: object) -> bool: + """判断 JSON 对象是否包含可恢复的源文件身份。""" + return ( + isinstance(value, dict) + and _is_nonempty_string(value.get("storage")) + and _is_nonempty_string(value.get("path")) + ) + + +def _is_optional_mapping(value: object) -> bool: + """判断值是否为 JSON 对象或空值。""" + return value is None or isinstance(value, dict) + + +def _is_optional_string(value: object) -> bool: + """判断值是否为字符串或空值。""" + return value is None or isinstance(value, str) + + +def _is_planning_input( + value: object, + *, + expected_fingerprint: object, +) -> bool: + """按第一版运行时契约校验规划输入及其持久指纹。""" + if not isinstance(value, dict) or value.get("schema_version") != 1: + return False + if not _is_source_fileitem(value.get("source_fileitem")): + return False + if not all( + _is_optional_mapping(value.get(field_name)) + for field_name in ("meta", "mediainfo", "target_directory") + ): + return False + if not all( + _is_optional_string(value.get(field_name)) + for field_name in ( + "target_storage", + "target_path", + "requested_transfer_type", + "media_source", + "media_id", + "media_type", + "overwrite_mode", + ) + ): + return False + if not all( + isinstance(value.get(field_name, default), bool) + for field_name, default in ( + ("need_scrape", False), + ("need_rename", True), + ("need_notify", True), + ("preview", False), + ) + ): + return False + episodes = value.get("episodes_info", []) + if ( + not isinstance(episodes, list) + or not all(isinstance(item, dict) for item in episodes) + or not isinstance(value.get("options", {}), dict) + ): + return False + try: + return _fingerprint(value) == expected_fingerprint + except (TypeError, ValueError): + return False + + +def _is_provider_reference(value: object) -> bool: + """判断旧 provider 引用是否包含稳定身份与固定方法。""" + return ( + isinstance(value, dict) + and _is_nonempty_string(value.get("plugin_id")) + and _is_nonempty_string(value.get("plugin_name")) + and value.get("method", "transfer") == "transfer" + ) + + +def _is_provider_invocation(value: object) -> bool: + """按第一版旧 ABI 契约校验 provider 调用快照。""" + if not isinstance(value, dict) or value.get("schema_version") != 1: + return False + if not _is_source_fileitem(value.get("fileitem")): + return False + for field_name in ("meta", "mediainfo", "target_directory"): + field_value = value.get(field_name) + if field_value is not None and not isinstance(field_value, dict): + return False + for field_name in ("meta_kind", "mediainfo_kind"): + field_value = value.get(field_name) + if field_value is not None and not _is_nonempty_string(field_value): + return False + for field_name in ("target_storage", "target_path", "transfer_type"): + field_value = value.get(field_name) + if field_value is not None and not isinstance(field_value, str): + return False + for field_name in ( + "scrape", + "library_type_folder", + "library_category_folder", + ): + field_value = value.get(field_name) + if field_value is not None and not isinstance(field_value, bool): + return False + episodes = value.get("episodes_info", []) + return ( + isinstance(value.get("preview", False), bool) + and isinstance(episodes, list) + and all(isinstance(item, dict) for item in episodes) + ) + + +def _is_plan_item(value: object, *, sequence: int) -> bool: + """判断计划叶子项是否具有连续序号和完整源目标身份。""" + return ( + isinstance(value, dict) + and value.get("sequence") == sequence + and _is_source_fileitem(value.get("source_fileitem")) + and _is_nonempty_string(value.get("target_storage")) + and _is_nonempty_string(value.get("target_path")) + and _is_nonempty_string(value.get("action", "transfer")) + ) + + +def _classify_checkpoint( + *, + checkpoint_version: object, + checkpoint_payload: object, + input_fingerprint: object, +) -> str | None: + """把完整计划检查点分类为宿主计划或 provider 待执行态。""" + if ( + checkpoint_version != 1 + or not isinstance(checkpoint_payload, dict) + or checkpoint_payload.get("schema_version") != 1 + or not _is_planning_input( + checkpoint_payload.get("planning_input"), + expected_fingerprint=input_fingerprint, + ) + ): + return None + providers = checkpoint_payload.get("legacy_transfer_providers", []) + if ( + not isinstance(providers, list) + or not all(_is_provider_reference(item) for item in providers) + ): + return None + if not all( + _is_optional_mapping(checkpoint_payload.get(field_name)) + for field_name in ("resolved_meta", "resolved_mediainfo") + ): + return None + if not all( + value is None or _is_nonempty_string(value) + for value in ( + checkpoint_payload.get("resolved_meta_kind"), + checkpoint_payload.get("resolved_mediainfo_kind"), + ) + ): + return None + resolved_episodes = checkpoint_payload.get("resolved_episodes_info", []) + if ( + not isinstance(resolved_episodes, list) + or not all(isinstance(item, dict) for item in resolved_episodes) + or not all( + isinstance(checkpoint_payload.get(field_name, default), bool) + for field_name, default in ( + ("pre_execution_cleanup_completed", False), + ("need_scrape", False), + ("need_rename", False), + ("need_notify", True), + ("preview", False), + ) + ) + or not _is_optional_string(checkpoint_payload.get("overwrite_mode")) + or not _is_optional_string(checkpoint_payload.get("skip_reason")) + or not _is_optional_string(checkpoint_payload.get("rejection_error")) + ): + return None + invocation = checkpoint_payload.get("provider_invocation") + if invocation is not None: + if ( + not providers + or not _is_provider_invocation(invocation) + or not invocation.get("meta") + or not _is_nonempty_string(invocation.get("meta_kind")) + or not invocation.get("mediainfo") + or not _is_nonempty_string(invocation.get("mediainfo_kind")) + or not checkpoint_payload.get("resolved_meta") + or not _is_nonempty_string(checkpoint_payload.get("resolved_meta_kind")) + or not checkpoint_payload.get("resolved_mediainfo") + or not _is_nonempty_string( + checkpoint_payload.get("resolved_mediainfo_kind") + ) + or checkpoint_payload.get("pre_execution_cleanup_completed", False) + or any(checkpoint_payload.get(field_name) for field_name in ( + "target_storage", + "root_target_path", + "final_target_path", + "resolved_transfer_type", + "items", + "skip_reason", + )) + ): + return None + return "provider_pending" + items = checkpoint_payload.get("items", []) + rejection_error = checkpoint_payload.get("rejection_error") + if ( + not isinstance(items, list) + or not all( + _is_plan_item(item, sequence=sequence) + for sequence, item in enumerate(items) + ) + or not all( + _is_nonempty_string(checkpoint_payload.get(field_name)) + for field_name in ( + "target_storage", + "root_target_path", + "final_target_path", + "resolved_transfer_type", + ) + ) + or ( + not items + and not checkpoint_payload.get("preview", False) + and not _is_nonempty_string(checkpoint_payload.get("skip_reason")) + and not _is_nonempty_string(rejection_error) + ) + or ( + rejection_error is not None + and ( + not _is_nonempty_string(rejection_error) + or not rejection_error.strip() + or bool(items) + ) + ) + ): + return None + return "planned" + + +def _normalize_legacy_planning_states() -> None: + """把旧 planning manual_review 恢复为运行时可领取的稳定状态。""" + pending = sa.table( + _PENDING_TABLE, + sa.column("id", sa.Integer()), + sa.column("state", sa.String(32)), + sa.column("input_fingerprint", sa.String(64)), + sa.column("checkpoint_version", sa.Integer()), + sa.column("checkpoint_payload", sa.JSON()), + sa.column("planned_at", sa.String(40)), + ) + bind = op.get_bind() + rows = bind.execute( + sa.select( + pending.c.id, + pending.c.state, + pending.c.input_fingerprint, + pending.c.checkpoint_version, + pending.c.checkpoint_payload, + ).where(pending.c.state.in_(( + "accepted", + "planned", + "provider_pending", + "manual_review", + ))) + ).mappings().all() + for row in rows: + state = _classify_checkpoint( + checkpoint_version=row["checkpoint_version"], + checkpoint_payload=row["checkpoint_payload"], + input_fingerprint=row["input_fingerprint"], + ) + target_state = state + values: dict[str, object] = {"state": target_state or "accepted"} + if target_state is None: + values.update({ + "checkpoint_version": None, + "checkpoint_payload": sa.null(), + "planned_at": None, + }) + bind.execute( + pending.update().where(pending.c.id == row["id"]).values(**values) + ) + + +def _is_execution_checkpoint(row: dict[str, Any]) -> bool: + """校验执行检查点三元组的完整性、版本和内容指纹。""" + values = ( + row["execution_version"], + row["execution_payload"], + row["execution_fingerprint"], + ) + if all(value is None for value in values): + return True + if ( + row["execution_version"] != 1 + or not isinstance(row["execution_payload"], dict) + or not _is_nonempty_string(row["execution_fingerprint"]) + ): + return False + payload = row["execution_payload"] + operation_ids = payload.get("operation_ids") + skip_reason = payload.get("skip_reason") + execution_result = payload.get("payload") + if ( + payload.get("schema_version") != 1 + or not isinstance(execution_result, dict) + or not isinstance(operation_ids, list) + or not all(_is_nonempty_string(item) for item in operation_ids) + or len(operation_ids) != len(set(operation_ids)) + or (not operation_ids and not _is_nonempty_string(skip_reason)) + or (skip_reason is not None and not isinstance(skip_reason, str)) + ): + return False + outcome = execution_result.get("outcome") + if outcome not in {"succeeded", "failed", "overwrite_skipped"}: + return False + transferinfo = execution_result.get("transferinfo") + if transferinfo is not None: + if not isinstance(transferinfo, dict): + return False + if ( + bool(transferinfo.get("success")) != (outcome == "succeeded") + or bool(transferinfo.get("overwrite_skipped")) + != (outcome == "overwrite_skipped") + ): + return False + elif outcome == "overwrite_skipped": + return False + try: + return _fingerprint(payload) == row["execution_fingerprint"] + except (TypeError, ValueError): + return False + + +def _has_failed_receipt(row: dict[str, Any]) -> bool: + """判断失败 pending 是否具有匹配历史与 append-only 回执。""" + if ( + _RECEIPT_TABLE not in _table_names() + or _HISTORY_TABLE not in _table_names() + or not isinstance(row["terminal_history_id"], int) + or not isinstance(row["settlement_revision"], int) + or row["settlement_revision"] <= 0 + ): + return False + receipts = sa.table( + _RECEIPT_TABLE, + sa.column("task_id", sa.String(64)), + sa.column("history_id", sa.Integer()), + sa.column("settlement_revision", sa.Integer()), + sa.column("outcome", sa.String(16)), + ) + history = sa.table( + _HISTORY_TABLE, + sa.column("id", sa.Integer()), + ) + bind = op.get_bind() + receipt_exists = bind.execute( + sa.select(sa.literal(1)).select_from(receipts).where( + receipts.c.task_id == row["task_id"], + receipts.c.history_id == row["terminal_history_id"], + receipts.c.settlement_revision == row["settlement_revision"], + receipts.c.outcome == "failed", + ).limit(1) + ).first() is not None + history_exists = bind.execute( + sa.select(sa.literal(1)).select_from(history).where( + history.c.id == row["terminal_history_id"] + ).limit(1) + ).first() is not None + return receipt_exists and history_exists + + +def _checkpoint_steps_complete(row: dict[str, Any]) -> bool: + """判断执行检查点引用的每个外部操作是否已有确定结果。""" + payload = row["execution_payload"] + if not isinstance(payload, dict): + return False + operation_ids = payload.get("operation_ids") + if not isinstance(operation_ids, list): + return False + if not operation_ids: + return True + steps = sa.table( + _STEP_TABLE, + sa.column("task_id", sa.String(64)), + sa.column("operation_id", sa.String(64)), + sa.column("state", sa.String(32)), + ) + completed = set(op.get_bind().execute( + sa.select(steps.c.operation_id).where( + steps.c.task_id == row["task_id"], + steps.c.operation_id.in_(operation_ids), + steps.c.state.in_(("succeeded", "failed")), + ) + ).scalars().all()) + return completed == set(operation_ids) + + +def _has_manual_review_step(task_id: str) -> bool: + """判断人工复核态是否具有运行时可发现的对应步骤证据。""" + steps = sa.table( + _STEP_TABLE, + sa.column("task_id", sa.String(64)), + sa.column("state", sa.String(32)), + ) + return op.get_bind().execute( + sa.select(sa.literal(1)).select_from(steps).where( + steps.c.task_id == task_id, + steps.c.state == "manual_review", + ).limit(1) + ).first() is not None + + +def _execution_issue(row: dict[str, Any]) -> str | None: + """返回阻止执行状态安全恢复的持久不变量缺口。""" + state = row["execution_state"] + if state not in _ALLOWED_EXECUTION_STATES: + return f"未知执行状态: {state}" + checkpoint_valid = _is_execution_checkpoint(row) + has_checkpoint = row["execution_payload"] is not None + if not checkpoint_valid: + return "执行检查点三元组不完整或内容无效" + if state != "not_started" and row["state"] not in { + "planned", + "provider_pending", + }: + return "执行态缺少完整 planned 或 provider_pending 计划" + lease_values = ( + row["lease_owner"], + row["lease_token"], + row["lease_expires_at"], + ) + if state != "manual_review" and any(lease_values) and not all(lease_values): + return "执行租约身份不完整" + if state == "not_started" and has_checkpoint: + return "未开始态错误携带执行检查点" + if state == "running" and has_checkpoint: + return "运行态错误携带终态执行检查点" + if state == "manual_review" and has_checkpoint: + return "人工复核态错误携带终态执行检查点" + if state == "manual_review" and not _has_manual_review_step(row["task_id"]): + return "人工复核态缺少步骤证据" + if state == "retry_wait" and not row["retry_due_at"]: + return "重试等待态缺少到期时间" + if state == "retry_wait" and any(lease_values): + return "重试等待态错误持有执行租约" + if state == "retry_wait" and has_checkpoint and ( + not _checkpoint_steps_complete(row) or not _has_failed_receipt(row) + ): + return "终态重试缺少匹配步骤、历史或结算回执" + if state == "settling" and ( + not has_checkpoint or not _checkpoint_steps_complete(row) + ): + return "结算态缺少完整检查点或步骤结果" + if state == "failed" and ( + not has_checkpoint + or any(lease_values) + or not _checkpoint_steps_complete(row) + or not _has_failed_receipt(row) + ): + return "失败终态缺少匹配步骤、检查点、历史或结算回执" + return None + + +def _review_operation_id(task_id: str) -> str: + """生成 3.0.17 数据修复步骤的确定性身份。""" + return hashlib.sha256( + f"moviepilot:3.0.17:execution-review:{task_id}".encode("utf-8") + ).hexdigest() + + +def _append_review_error(last_error: object, issue: str) -> str: + """保留原始错误并追加可识别的迁移诊断。""" + diagnostic = f"{_REVIEW_DIAGNOSTIC}({issue})" + if not _is_nonempty_string(last_error): + return diagnostic + if _REVIEW_DIAGNOSTIC in last_error: + return last_error + return f"{last_error}\n{diagnostic}" + + +def _next_review_ordinal(task_id: str) -> int: + """选择不会覆盖既有步骤证据的高位人工复核序号。""" + steps = sa.table( + _STEP_TABLE, + sa.column("task_id", sa.String(64)), + sa.column("ordinal", sa.Integer()), + ) + ordinals = set(op.get_bind().execute( + sa.select(steps.c.ordinal).where(steps.c.task_id == task_id) + ).scalars().all()) + ordinal = _REVIEW_STEP_ORDINAL + while ordinal in ordinals: + ordinal -= 1 + return ordinal + + +def _insert_review_step(row: dict[str, Any], *, issue: str) -> None: + """把非法执行组合冻结为可人工判定的 synthetic 审计步骤。""" + operation_id = _review_operation_id(row["task_id"]) + steps = sa.table( + _STEP_TABLE, + sa.column("task_id", sa.String(64)), + sa.column("operation_id", sa.String(64)), + sa.column("checkpoint_fingerprint", sa.String(64)), + sa.column("ordinal", sa.Integer()), + sa.column("phase", sa.String(32)), + sa.column("kind", sa.String(32)), + sa.column("state", sa.String(32)), + sa.column("attempt_token", sa.String(64)), + sa.column("attempt_count", sa.Integer()), + sa.column("intent_version", sa.Integer()), + sa.column("intent_payload", sa.JSON()), + sa.column("result_version", sa.Integer()), + sa.column("result_payload", sa.JSON()), + sa.column("last_error", sa.Text()), + sa.column("prepared_at", sa.String(40)), + sa.column("started_at", sa.String(40)), + sa.column("completed_at", sa.String(40)), + sa.column("updated_at", sa.String(40)), + ) + bind = op.get_bind() + if bind.execute( + sa.select(sa.literal(1)).select_from(steps).where( + steps.c.operation_id == operation_id + ).limit(1) + ).first() is not None: + return + evidence = { + "schema_version": 1, + "origin": "3.0.17_migration", + "diagnostic": issue, + "legacy_state": row["state"], + "legacy_execution_state": row["execution_state"], + "execution_version": row["execution_version"], + "execution_payload": row["execution_payload"], + "execution_fingerprint": row["execution_fingerprint"], + "settlement_revision": row["settlement_revision"], + "terminal_history_id": row["terminal_history_id"], + "lease_owner": row["lease_owner"], + "lease_token": row["lease_token"], + } + evidence_time = row["updated_at"] or row["created_at"] or _FALLBACK_TIME + bind.execute(steps.insert().values( + task_id=row["task_id"], + operation_id=operation_id, + checkpoint_fingerprint=_fingerprint(evidence), + ordinal=_next_review_ordinal(row["task_id"]), + phase="legacy_upgrade", + kind=_REVIEW_STEP_KIND, + state="manual_review", + attempt_token=None, + attempt_count=row["attempt_count"] or 0, + intent_version=1, + intent_payload=evidence, + result_version=None, + result_payload=None, + last_error=_append_review_error(row["last_error"], issue), + prepared_at=evidence_time, + started_at=None, + completed_at=None, + updated_at=evidence_time, + )) + + +def _reconcile_execution_states() -> None: + """隔离非法执行组合,并统一清除人工复核态残留租约。""" + pending = sa.table( + _PENDING_TABLE, + sa.column("id", sa.Integer()), + sa.column("task_id", sa.String(64)), + sa.column("state", sa.String(32)), + sa.column("created_at", sa.String(40)), + sa.column("updated_at", sa.String(40)), + sa.column("last_error", sa.Text()), + sa.column("lease_owner", sa.String(128)), + sa.column("lease_token", sa.String(64)), + sa.column("lease_expires_at", sa.String(40)), + sa.column("heartbeat_at", sa.String(40)), + sa.column("attempt_count", sa.Integer()), + sa.column("execution_state", sa.String(32)), + sa.column("execution_version", sa.Integer()), + sa.column("execution_payload", sa.JSON()), + sa.column("execution_fingerprint", sa.String(64)), + sa.column("retry_due_at", sa.String(40)), + sa.column("settlement_revision", sa.Integer()), + sa.column("terminal_history_id", sa.Integer()), + ) + bind = op.get_bind() + rows = [ + dict(row) + for row in bind.execute(sa.select(pending)).mappings().all() + ] + for row in rows: + issue = _execution_issue(row) + if issue is not None: + _insert_review_step(row, issue=issue) + bind.execute( + pending.update().where(pending.c.id == row["id"]).values( + execution_state="manual_review", + execution_version=None, + execution_payload=sa.null(), + execution_fingerprint=None, + lease_owner=None, + lease_token=None, + lease_expires_at=None, + heartbeat_at=None, + retry_due_at=None, + last_error=_append_review_error(row["last_error"], issue), + ) + ) + elif row["execution_state"] == "manual_review": + bind.execute( + pending.update().where(pending.c.id == row["id"]).values( + lease_owner=None, + lease_token=None, + lease_expires_at=None, + heartbeat_at=None, + ) + ) + + +def upgrade() -> None: + """归一旧人工态并隔离无法安全自动恢复的执行组合。""" + tables = _table_names() + if _PENDING_TABLE not in tables: + return + if _STEP_TABLE not in tables: + raise RuntimeError("缺少 3.0.16 transferexecutionstep 表,拒绝执行 3.0.17") + _normalize_legacy_planning_states() + _reconcile_execution_states() + + +def downgrade() -> None: + """保留更安全的状态归一和人工证据,结构由 3.0.16 负责降级。""" diff --git a/docs/architecture-optimization-checklist.md b/docs/architecture-optimization-checklist.md index edce89cbe..ca80e264e 100644 --- a/docs/architecture-optimization-checklist.md +++ b/docs/architecture-optimization-checklist.md @@ -77,8 +77,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | | Python 源码量 | 约 271,400 行 | 60 个文件超过 1,000 行,14 个超过 2,000 行 | | 长方法 | 281 个超过 80 行 | 67 个超过 150 行,23 个超过 250 行;大量是私有方法 | -| 全量 mypy 历史债务 | 11,827 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 | -| Ruff 历史诊断 | 889 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | +| 全量 mypy 历史债务 | 11,820 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 | +| Ruff 历史诊断 | 888 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率低水位 | Application 78.63%,Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | ### 3.3 热点文件 diff --git a/docs/architecture-refactor-roadmap.md b/docs/architecture-refactor-roadmap.md index dbef163da..db7e58947 100644 --- a/docs/architecture-refactor-roadmap.md +++ b/docs/architecture-refactor-roadmap.md @@ -144,7 +144,7 @@ G-ARCH 只有在以下条件全部满足后才可完成: | S4-L2 Event strict contract | `PLANNED` | S0-L2.6,S1-L6 | 宿主事件输入/输出按风险 strict,诊断例外只属于第三方插件兼容 | | S4-L3 Complexity v2 | `PLANNED` | S3 | 私有方法、class/file、圈复杂度进入门禁;所有超限通过职责拆分归零 | | S4-L4 全量 mypy 清零 | `PLANNED` | S3,S4-L1,S4-L2 | `mypy-baseline.json` 归零并删除债务接受路径,全宿主 strict 类型通过 | -| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 889 条诊断归零,规则集扩展经过独立审查且新增诊断为零 | +| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 888 条诊断归零,规则集扩展经过独立审查且新增诊断为零 | | S4-L6 Coverage/并发/质量证据 | `PLANNED` | S3,S4-L1,S4-L2 | 高风险包纳入 coverage;raw concurrency 分类清零;Module Quality 有真实 evidence test | ### S5:Plugin、Agent、Domain、Startup 与最终收口 diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index c76f7e21d..53cc82d30 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -550,6 +550,9 @@ expired claimed task remains exclusively owned by fenced recovery APIs. - 物理模块仍存在但公开符号已经迁走时(例如 `app.domain.media` 的身份原语、 `app.schemas` 的整理工作项),兼容 Finder 在标准 Loader 执行后叠加白名单符号路由; canonical 模块不得为兼容而反向 import `app.runtime.compat`。 +- 符号级插件 ABI 只保证显式导入和属性访问;兼容符号不加入物理包的 `__all__`, + 不支持依赖 `from ... import *` 获得迁移符号。宿主源码不得消费 `SYMBOL_ALIASES` + 中的旧符号,必须直接导入 canonical owner,避免包根形成第二份宿主导出面。 - Canonical implementation packages may not import `app/runtime/compat` or `app/sdk`. - Host code uses canonical paths. Only `app/plugins/` and compatibility tests diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 15337674d..3e75a6bf2 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1442,7 +1442,7 @@ } }, "edge_count": 6898, - "edge_sha256": "c73faf7e29ee12a862cf2332f1803fd4eb8a3f4d2994ed81075ff198e896f8df", + "edge_sha256": "9bceb8aaa1bb5857ba6ee075c50214455cfb4f59030cee49b3e5af751cc95972", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -4450,7 +4450,6 @@ "app.application.transfer.workflow -> app.adapters.system", "app.application.transfer.workflow -> app.adapters.system.host", "app.application.transfer.workflow -> app.application", - "app.application.transfer.workflow -> app.application.agent", "app.application.transfer.workflow -> app.application.transfer", "app.application.transfer.workflow -> app.application.transfer.execution", "app.application.transfer.workflow -> app.domain", @@ -5187,6 +5186,7 @@ "app.db.adapters.transfer.execution -> app.application", "app.db.adapters.transfer.execution -> app.application.transfer", "app.db.adapters.transfer.execution -> app.application.transfer.execution", + "app.db.adapters.transfer.execution -> app.application.transfer.workflow", "app.db.adapters.transfer.execution -> app.db", "app.db.adapters.transfer.execution -> app.db.models", "app.db.adapters.transfer.execution -> app.db.models.transferexecutionstep", diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index 7c2a61cdc..d90f7522b 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -1568,19 +1568,19 @@ "union-attr": 2 }, "app/chain/transfer.py": { - "arg-type": 57, + "arg-type": 53, "assignment": 25, "attr-defined": 4, "func-returns-value": 1, "misc": 2, - "no-any-return": 3, + "no-any-return": 2, "no-untyped-call": 8, "no-untyped-def": 12, "operator": 3, "return-value": 2, "truthy-function": 5, "type-arg": 10, - "union-attr": 33, + "union-attr": 31, "var-annotated": 4 }, "app/chain/user.py": { diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index ff914f626..303d4825f 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -3,6 +3,7 @@ import json from functools import lru_cache from pathlib import Path +from app.runtime.compat.manifest import SYMBOL_ALIASES from scripts.architecture.baseline import ( collect_current_event_facts as _collect_current_event_facts, ) @@ -200,6 +201,58 @@ def _legacy_imports(path: Path) -> set[str]: return imports +def _attribute_parts(node: ast.Attribute) -> list[str]: + """将静态属性访问还原为从根名称开始的完整路径片段。""" + parts = [node.attr] + value = node.value + while isinstance(value, ast.Attribute): + parts.append(value.attr) + value = value.value + if not isinstance(value, ast.Name): + return [] + parts.append(value.id) + return list(reversed(parts)) + + +def _compat_symbol_references(tree: ast.AST) -> set[tuple[int, str]]: + """收集显式导入或静态属性访问命中的兼容符号。""" + compatibility_symbols = { + (module_name, symbol_name) + for module_name, symbols in SYMBOL_ALIASES.items() + for symbol_name in symbols + } + module_bindings: dict[str, str] = {} + references: set[tuple[int, str]] = set() + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + binding = alias.asname or alias.name.split(".", maxsplit=1)[0] + module_bindings[binding] = alias.name if alias.asname else binding + elif isinstance(node, ast.ImportFrom) and node.module: + for alias in node.names: + if (node.module, alias.name) in compatibility_symbols: + references.add((node.lineno, f"{node.module}.{alias.name}")) + continue + binding = alias.asname or alias.name + module_bindings[binding] = f"{node.module}.{alias.name}" + + for node in ast.walk(tree): + if not isinstance(node, ast.Attribute): + continue + parts = _attribute_parts(node) + if len(parts) < 2: + continue + resolved_root = module_bindings.get(parts[0], parts[0]) + resolved = [*resolved_root.split("."), *parts[1:]] + module_name = ".".join(resolved[:-1]) + symbol_name = resolved[-1] + if (module_name, symbol_name) in compatibility_symbols: + references.add((node.lineno, f"{module_name}.{symbol_name}")) + + return references + + def test_legacy_roots_contain_no_python_sources(): """旧目录只能作为运行时虚拟包存在,仓库中不得重新出现源码。""" leftovers = sorted( @@ -270,6 +323,48 @@ def test_host_code_does_not_import_legacy_roots(): assert violations == {} +def test_compat_symbol_scanner_covers_static_import_shapes() -> None: + """兼容符号扫描必须覆盖显式导入、模块别名和完整属性链。""" + tree = ast.parse( + """ +from app.schemas import TransferTask +import app.schemas as schema_alias +schema_alias.TransferQueue +import app.schemas +app.schemas.TransferTask +from app.application import transfer as transfer_package +transfer_package.TransferQueue +""" + ) + + assert _compat_symbol_references(tree) == { + (2, "app.schemas.TransferTask"), + (4, "app.schemas.TransferQueue"), + (6, "app.schemas.TransferTask"), + (8, "app.application.transfer.TransferQueue"), + } + + +def test_host_code_does_not_use_compat_symbol_aliases() -> None: + """宿主必须导入 canonical 符号,不得反向消费插件兼容覆盖。""" + violations: list[str] = [] + for path in APP_ROOT.rglob("*.py"): + relative = path.relative_to(APP_ROOT) + if ( + relative.parts[0] == "plugins" + or relative.parts[:2] == ("runtime", "compat") + or relative.parts[:2] == ("sdk", "_legacy") + ): + continue + tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) + violations.extend( + f"{relative.as_posix()}:{line}:{symbol}" + for line, symbol in sorted(_compat_symbol_references(tree)) + ) + + assert violations == [] + + def test_host_code_uses_explicit_runtime_facade_getters(): """宿主消费者必须显式调用 getter,不得把兼容 Facade 当作新代码入口。""" forbidden_imports = { diff --git a/tests/test_chain_durable_events.py b/tests/test_chain_durable_events.py index 4d540b8a4..6bb4de442 100644 --- a/tests/test_chain_durable_events.py +++ b/tests/test_chain_durable_events.py @@ -22,8 +22,11 @@ from app.application.chain.events import ( ) from app.application.history import TransferHistoryMutationCommand from app.application.transfer.execution import ( + TransferExecutionCheckpoint, + TransferExecutionConflictError, TransferExecutionLeaseLostError, TransferSettlementResult, + build_transfer_checkpoint_fingerprint, ) from app.db.adapters.chain import TransactionalChainDurableEventWriter from app.db.base import Base @@ -81,16 +84,44 @@ def _objects(): return meta, media, context, fileitem, transferinfo +def _execution_checkpoint( + *, + outcome: str, + identity: str = "execution-1", +) -> TransferExecutionCheckpoint: + """构造 outcome 与完整指纹一致的测试执行检查点。""" + overwrite_skipped = outcome == "overwrite_skipped" + transferinfo = ( + TransferInfo(success=False, overwrite_skipped=True).model_dump(mode="json") + if overwrite_skipped + else None + ) + return TransferExecutionCheckpoint.create( + payload={ + "outcome": outcome, + "test_identity": identity, + **({"transferinfo": transferinfo} if transferinfo else {}), + }, + operation_ids=() if overwrite_skipped else ("operation-1",), + skip_reason="overwrite_skipped" if overwrite_skipped else None, + ) + + def _add_settling_pending( factory, *, task_id: str = "task-1", lease_token: str = "lease-1", - execution_fingerprint: str = "execution-1", + execution_outcome: str = "succeeded", + execution_identity: str = "execution-1", settlement_revision: int = 0, src_path: str | None = None, -) -> None: +) -> TransferExecutionCheckpoint: """写入具备有效长租约和执行检查点的待结算任务。""" + checkpoint = _execution_checkpoint( + outcome=execution_outcome, + identity=execution_identity, + ) with factory() as session: session.add(TransferPending( task_id=task_id, @@ -111,14 +142,15 @@ def _add_settling_pending( heartbeat_at="2026-08-27 01:00:00.000000", attempt_count=1, execution_state="settling", - execution_version=1, - execution_payload={"schema_version": 1}, - execution_fingerprint=execution_fingerprint, + execution_version=checkpoint.version, + execution_payload=checkpoint.to_payload(), + execution_fingerprint=checkpoint.fingerprint, retry_generation=0, retry_count=0, settlement_revision=settlement_revision, )) session.commit() + return checkpoint def _settlement( @@ -126,13 +158,18 @@ def _settlement( outcome: str, task_id: str = "task-1", lease_token: str = "lease-1", - execution_fingerprint: str = "execution-1", + checkpoint_outcome: str | None = None, + execution_identity: str = "execution-1", ) -> TransferResultSettlement: """构造测试使用的稳定终态结算身份。""" + checkpoint = _execution_checkpoint( + outcome=checkpoint_outcome or outcome, + identity=execution_identity, + ) return TransferResultSettlement( task_id=task_id, lease_token=lease_token, - execution_fingerprint=execution_fingerprint, + execution_fingerprint=checkpoint.fingerprint, outcome=outcome, error="目标文件校验失败" if outcome == "failed" else None, ) @@ -519,7 +556,9 @@ def test_task_success_settlement_atomically_deletes_pending_and_steps(): assert receipt.task_id == "task-1" assert receipt.history_id == history.id assert receipt.outcome == "succeeded" - assert receipt.execution_fingerprint == "execution-1" + assert receipt.execution_fingerprint == _execution_checkpoint( + outcome="succeeded" + ).fingerprint assert receipt.lease_token == "lease-1" assert receipt.history_status is True assert receipt.src == "/downloads/task-1.mkv" @@ -599,7 +638,7 @@ def test_multiple_same_source_tasks_keep_independent_replay_receipts(): factory, task_id="new-task", lease_token="lease-2", - execution_fingerprint="execution-2", + execution_identity="execution-2", src_path=shared_src, ) @@ -617,7 +656,7 @@ def test_multiple_same_source_tasks_keep_independent_replay_receipts(): outcome="succeeded", task_id="new-task", lease_token="lease-2", - execution_fingerprint="execution-2", + execution_identity="execution-2", ), ) old_replay = writer.transfer_result( @@ -684,7 +723,11 @@ def test_task_settlement_without_public_topic_commits_no_outbox(): def test_task_settlement_binds_receipt_without_overwriting_success_history(): """不覆盖裁决只绑定任务回执,保留旧成功历史的全部业务字段。""" factory = _session_factory() - _add_settling_pending(factory, task_id="declined-task") + _add_settling_pending( + factory, + task_id="declined-task", + execution_outcome="overwrite_skipped", + ) with factory() as session: session.add(TransferHistory( src="/downloads/declined-task.mkv", @@ -699,6 +742,7 @@ def test_task_settlement_binds_receipt_without_overwriting_success_history(): settlement = _settlement( outcome="succeeded", task_id="declined-task", + checkpoint_outcome="overwrite_skipped", ) first = writer.transfer_result( @@ -738,6 +782,170 @@ def test_task_settlement_binds_receipt_without_overwriting_success_history(): assert history.transfer_settlement_revision is None +def test_task_overwrite_skip_without_success_history_settles_failed(): + """覆盖跳过找不到成功历史时,显式执行事实仍可裁决为失败。""" + factory = _session_factory() + _add_settling_pending(factory, execution_outcome="overwrite_skipped") + writer = TransactionalChainDurableEventWriter(factory) + + result = writer.transfer_result( + topic="transfer.failed", + stage_history=lambda repository: _stage_result_history( + repository, + task_id="task-1", + succeeded=False, + ), + event_payload={}, + publish=None, + settlement=_settlement( + outcome="failed", + checkpoint_outcome="overwrite_skipped", + ), + ) + + assert isinstance(result, TransferSettlementResult) + assert result.pending_deleted is False + with factory() as session: + pending = session.execute(select(TransferPending)).scalar_one() + history = session.execute(select(TransferHistory)).scalar_one() + receipt = session.execute(select(TransferSettlementReceipt)).scalar_one() + assert pending.execution_state == "failed" + assert history.status is False + assert receipt.outcome == "failed" + + +@pytest.mark.parametrize( + ("checkpoint_outcome", "settlement_outcome"), + [ + ("failed", "succeeded"), + ("succeeded", "failed"), + ], +) +def test_task_settlement_rejects_checkpoint_outcome_conflicts_before_writes( + checkpoint_outcome, + settlement_outcome, +): + """执行证据与结算方向冲突或未知时,不得进入历史暂存。""" + factory = _session_factory() + _add_settling_pending( + factory, + execution_outcome=checkpoint_outcome, + ) + writer = TransactionalChainDurableEventWriter(factory) + + with pytest.raises(TransferExecutionConflictError): + writer.transfer_result( + topic="transfer.completed", + stage_history=lambda _repository: pytest.fail("冲突结算不得写历史"), + event_payload={}, + publish=lambda _payload: pytest.fail("冲突结算不得发布"), + settlement=_settlement( + outcome=settlement_outcome, + checkpoint_outcome=checkpoint_outcome, + ), + ) + + with factory() as session: + pending = session.execute(select(TransferPending)).scalar_one() + assert session.execute(select(TransferHistory)).scalar_one_or_none() is None + assert session.execute( + select(TransferSettlementReceipt) + ).scalar_one_or_none() is None + assert session.execute(select(OutboxMessage)).scalar_one_or_none() is None + assert pending.execution_state == "settling" + assert pending.settlement_revision == 0 + + +def test_task_settlement_rejects_corrupted_checkpoint_outcome_before_writes(): + """持久层出现未知执行 outcome 时必须隔离,不得构造历史或事件。""" + factory = _session_factory() + _add_settling_pending(factory) + corrupted_payload = { + "schema_version": 1, + "payload": {"outcome": "unknown", "test_identity": "execution-1"}, + "operation_ids": ["operation-1"], + "skip_reason": None, + } + corrupted_fingerprint = build_transfer_checkpoint_fingerprint( + corrupted_payload + ) + with factory() as session: + pending = session.execute(select(TransferPending)).scalar_one() + pending.execution_payload = corrupted_payload + pending.execution_fingerprint = corrupted_fingerprint + session.commit() + writer = TransactionalChainDurableEventWriter(factory) + settlement = TransferResultSettlement( + task_id="task-1", + lease_token="lease-1", + execution_fingerprint=corrupted_fingerprint, + outcome="succeeded", + ) + + with pytest.raises(TransferExecutionConflictError): + writer.transfer_result( + topic="transfer.completed", + stage_history=lambda _repository: pytest.fail("损坏检查点不得写历史"), + event_payload={}, + publish=lambda _payload: pytest.fail("损坏检查点不得发布"), + settlement=settlement, + ) + + with factory() as session: + pending = session.execute(select(TransferPending)).scalar_one() + assert session.execute(select(TransferHistory)).scalar_one_or_none() is None + assert session.execute( + select(TransferSettlementReceipt) + ).scalar_one_or_none() is None + assert session.execute(select(OutboxMessage)).scalar_one_or_none() is None + assert pending.execution_state == "settling" + assert pending.settlement_revision == 0 + + +def test_task_settlement_rejects_malformed_checkpoint_before_writes(): + """指纹自洽但结构损坏的数据库检查点也不得驱动终态写入。""" + factory = _session_factory() + _add_settling_pending(factory) + malformed_payload = { + "schema_version": 1, + "payload": {"outcome": "succeeded"}, + "operation_ids": "operation-1", + "skip_reason": None, + } + fingerprint = build_transfer_checkpoint_fingerprint(malformed_payload) + with factory() as session: + pending = session.execute(select(TransferPending)).scalar_one() + pending.execution_payload = malformed_payload + pending.execution_fingerprint = fingerprint + session.commit() + writer = TransactionalChainDurableEventWriter(factory) + settlement = TransferResultSettlement( + task_id="task-1", + lease_token="lease-1", + execution_fingerprint=fingerprint, + outcome="succeeded", + ) + + with pytest.raises(TransferExecutionConflictError): + writer.transfer_result( + topic="transfer.completed", + stage_history=lambda _repository: pytest.fail("损坏检查点不得写历史"), + event_payload={}, + publish=lambda _payload: pytest.fail("损坏检查点不得发布"), + settlement=settlement, + ) + + with factory() as session: + pending = session.execute(select(TransferPending)).scalar_one() + assert session.execute(select(TransferHistory)).scalar_one_or_none() is None + assert session.execute( + select(TransferSettlementReceipt) + ).scalar_one_or_none() is None + assert session.execute(select(OutboxMessage)).scalar_one_or_none() is None + assert pending.execution_state == "settling" + assert pending.settlement_revision == 0 + + @pytest.mark.parametrize("cleanup", ["delete", "truncate"]) def test_receipt_replay_survives_real_history_command_cleanup(cleanup): """真实历史删除或清空命令执行后,独立回执仍可重放成功终态。""" @@ -857,7 +1065,7 @@ def test_success_receipt_allows_expiry_and_legacy_same_source_replace(): def test_task_failure_settlement_is_replayable_and_retry_advances_revision(): """失败保留终态证据,重复调用幂等,显式重试后才递增修订号。""" factory = _session_factory() - _add_settling_pending(factory) + _add_settling_pending(factory, execution_outcome="failed") writer = TransactionalChainDurableEventWriter(factory) calls = [] first_settlement = _settlement(outcome="failed") @@ -890,8 +1098,14 @@ def test_task_failure_settlement_is_replayable_and_retry_advances_revision(): assert pending.lease_token is None assert pending.settlement_revision == 1 assert pending.terminal_history_id == first.history_id + retry_checkpoint = _execution_checkpoint( + outcome="failed", + identity="execution-2", + ) pending.execution_state = "settling" - pending.execution_fingerprint = "execution-2" + pending.execution_version = retry_checkpoint.version + pending.execution_payload = retry_checkpoint.to_payload() + pending.execution_fingerprint = retry_checkpoint.fingerprint pending.lease_owner = "worker-2" pending.lease_token = "lease-2" pending.lease_expires_at = "2099-01-01 00:00:00.000000" @@ -909,7 +1123,7 @@ def test_task_failure_settlement_is_replayable_and_retry_advances_revision(): settlement=_settlement( outcome="failed", lease_token="lease-2", - execution_fingerprint="execution-2", + execution_identity="execution-2", ), ) @@ -949,10 +1163,15 @@ def test_task_failure_settlement_is_replayable_and_retry_advances_revision(): assert [receipt.settlement_revision for receipt in receipts] == [1, 2] assert all(receipt.task_id == "task-1" for receipt in receipts) assert all(receipt.history_id == first.history_id for receipt in receipts) - assert receipts[0].execution_fingerprint == "execution-1" + assert receipts[0].execution_fingerprint == _execution_checkpoint( + outcome="failed" + ).fingerprint assert receipts[0].lease_token == "lease-1" assert receipts[1].outcome == "failed" - assert receipts[1].execution_fingerprint == "execution-2" + assert receipts[1].execution_fingerprint == _execution_checkpoint( + outcome="failed", + identity="execution-2", + ).fingerprint assert receipts[1].lease_token == "lease-2" assert receipts[1].pending_deleted is False assert receipts[1].error == "目标文件校验失败" @@ -966,7 +1185,7 @@ def test_task_failure_settlement_is_replayable_and_retry_advances_revision(): def test_failed_revision_replays_after_later_success_deleted_pending(): """后续重试成功删除 pending 后,旧失败修订仍按原执行身份幂等回读。""" factory = _session_factory() - _add_settling_pending(factory) + _add_settling_pending(factory, execution_outcome="failed") writer = TransactionalChainDurableEventWriter(factory) failed_settlement = _settlement(outcome="failed") failed = writer.transfer_result( @@ -982,8 +1201,14 @@ def test_failed_revision_replays_after_later_success_deleted_pending(): ) with factory() as session: pending = session.execute(select(TransferPending)).scalar_one() + retry_checkpoint = _execution_checkpoint( + outcome="succeeded", + identity="execution-2", + ) pending.execution_state = "settling" - pending.execution_fingerprint = "execution-2" + pending.execution_version = retry_checkpoint.version + pending.execution_payload = retry_checkpoint.to_payload() + pending.execution_fingerprint = retry_checkpoint.fingerprint pending.lease_owner = "worker-2" pending.lease_token = "lease-2" pending.lease_expires_at = "2099-01-01 00:00:00.000000" @@ -991,7 +1216,7 @@ def test_failed_revision_replays_after_later_success_deleted_pending(): succeeded_settlement = _settlement( outcome="succeeded", lease_token="lease-2", - execution_fingerprint="execution-2", + execution_identity="execution-2", ) succeeded = writer.transfer_result( topic="transfer.completed", diff --git a/tests/test_db_media_identity_normalizer.py b/tests/test_db_media_identity_normalizer.py index 5e52f0916..1d5311225 100644 --- a/tests/test_db_media_identity_normalizer.py +++ b/tests/test_db_media_identity_normalizer.py @@ -11,6 +11,7 @@ flush 前的事件。因此这里断言的是「绕过 Oper 直接建模写库 """ import pytest +from app.application.transfer.workflow import TransferPlanningInput from app.db.models.transferhistory import TransferHistory from app.db.models.transferpending import TransferPending from app.schemas.types import MediaSource @@ -158,6 +159,12 @@ def test_tables_without_identity_columns_are_untouched(db): 不带身份列的表不受影响——事件挂在 Mapper 上覆盖全部映射,必须靠列名检查收窄, 否则会去动一张根本没有这两列的表。 """ + planning_input = TransferPlanningInput(source_fileitem={ + "storage": "local", + "path": "/mnt/a.mkv", + "type": "file", + "name": "a.mkv", + }) TransferPending.stage_admit( db.session, task_id="identity-free-table", @@ -165,6 +172,9 @@ def test_tables_without_identity_columns_are_untouched(db): src_path="/mnt/a.mkv", state="accepted", now_time="2026-08-14 10:00:00", + input_version=planning_input.schema_version, + planning_input=planning_input.to_payload(), + input_fingerprint=planning_input.fingerprint, ) row = TransferPending.get_by_identity( diff --git a/tests/test_db_transferpending_queries.py b/tests/test_db_transferpending_queries.py index 2535b50d6..54b822f74 100644 --- a/tests/test_db_transferpending_queries.py +++ b/tests/test_db_transferpending_queries.py @@ -11,8 +11,10 @@ import pytest from sqlalchemy import create_engine, select from sqlalchemy.orm import sessionmaker +from app.application.transfer.workflow import TransferPlanningInput from app.db import base as db_base from app.db.adapters.transfer.admission import TransactionalTransferAdmissionRepository +from app.db.models.transferexecutionstep import TransferExecutionStep from app.db.models.transferhistory import TransferHistory from app.db.models.transferpending import TransferPending from app.db.oper.transferpending import TransferPendingOper @@ -22,6 +24,60 @@ from app.db.oper.transferpending import TransferPendingOper def _track(db): """把待整理表纳入用例级回收。""" db.watermark(TransferPending) + db.watermark(TransferExecutionStep) + + +def _leased_pending( + *, + task_id: str, + execution_state: str, + admission_state: str = "accepted", +) -> TransferPending: + """构造持有有效租约且其余执行证据为空的 pending。""" + return TransferPending( + task_id=task_id, + storage="local", + src_path=f"/mnt/{task_id}.mkv", + state=admission_state, + created_at="2026-08-27 10:00:00", + updated_at="2026-08-27 10:00:00", + input_version=1, + planning_input={"schema_version": 1}, + input_fingerprint="input", + lease_owner="worker", + lease_token=f"lease-{task_id}", + lease_expires_at="2099-01-01 00:00:00.000000", + heartbeat_at="2026-08-27 10:00:00.000000", + attempt_count=1, + execution_state=execution_state, + retry_generation=0, + retry_count=0, + settlement_revision=0, + ) + + +def _planning_input(path: str) -> TransferPlanningInput: + """构造准入仓储要求的真实版本化规划输入。""" + return TransferPlanningInput( + source_fileitem={ + "storage": "local", + "path": path, + "type": "file", + "name": path.rsplit("/", 1)[-1], + }, + meta=None, + mediainfo=None, + ) + + +def _planning_fields(path: str) -> dict[str, object]: + """返回 direct model/Oper 准入所需的显式版本化字段。""" + planning_input = _planning_input(path) + return { + "input_version": planning_input.schema_version, + "planning_input": planning_input.to_payload(), + "input_fingerprint": planning_input.fingerprint, + } def test_stage_admit_is_idempotent_and_keeps_stable_task_id(db): @@ -33,6 +89,7 @@ def test_stage_admit_is_idempotent_and_keeps_stable_task_id(db): src_path="/mnt/durable.mkv", state="accepted", now_time="2026-08-27 10:00:00", + **_planning_fields("/mnt/durable.mkv"), ) second = TransferPending.stage_admit( db.session, @@ -41,6 +98,7 @@ def test_stage_admit_is_idempotent_and_keeps_stable_task_id(db): src_path="/mnt/durable.mkv", state="accepted", now_time="2026-08-27 11:00:00", + **_planning_fields("/mnt/durable.mkv"), ) assert first is second @@ -81,7 +139,9 @@ def test_state_queries_and_failure_record_share_stable_identity(db): src_path="/mnt/accepted.mkv", state="accepted", now_time="2026-08-27 10:00:00", + **_planning_fields("/mnt/accepted.mkv"), ) + other_fields = _planning_fields("/mnt/other.mkv") db.add(TransferPending( task_id="task-other", storage="local", @@ -89,6 +149,7 @@ def test_state_queries_and_failure_record_share_stable_identity(db): state="other", created_at="2026-08-27 10:00:01", updated_at="2026-08-27 10:00:01", + **other_fields, )) db.session.flush() @@ -130,6 +191,7 @@ def test_oper_staging_reuses_explicit_write_session(db, monkeypatch): src_path="/mnt/explicit-stage.mkv", state="accepted", now_time="2026-08-27 10:00:00", + **_planning_fields("/mnt/explicit-stage.mkv"), ) assert pending.task_id == "task-explicit" assert oper.get_by_task_id(task_id="task-explicit").state == "accepted" @@ -146,16 +208,19 @@ def test_transactional_repository_commits_frozen_projections(tmp_path): engine = create_engine(f"sqlite:///{tmp_path / 'transfer.db'}") TransferHistory.__table__.create(engine) TransferPending.__table__.create(engine) + TransferExecutionStep.__table__.create(engine) factory = sessionmaker(bind=engine) repository = TransactionalTransferAdmissionRepository(factory) admitted = repository.admit( storage="local", src_path="/mnt/repository.mkv", + planning_input=_planning_input("/mnt/repository.mkv"), ) repeated = repository.admit( storage="local", src_path="/mnt/repository.mkv", + planning_input=_planning_input("/mnt/repository.mkv"), ) assert repeated == admitted assert admitted.task_id @@ -178,7 +243,7 @@ def test_transactional_repository_commits_frozen_projections(tmp_path): lease_seconds=60, ) assert claimed is not None - assert repository.discard_claimed( + assert repository.abandon_unstarted( task_id=admitted.task_id, lease_token=claimed.lease_token, ) == 1 @@ -191,6 +256,120 @@ def test_transactional_repository_commits_frozen_projections(tmp_path): engine.dispose() +@pytest.mark.parametrize( + ("execution_state", "expected_deleted"), + [ + ("not_started", 1), + ("running", 0), + ("retry_wait", 0), + ("settling", 0), + ("failed", 0), + ("manual_review", 0), + ], +) +def test_abandon_unstarted_allows_only_pristine_execution_state( + db, + execution_state, + expected_deleted, +) -> None: + """缺失源注销必须拒绝所有已开始、待重试、结算和人工状态。""" + task_id = f"abandon-{execution_state}" + db.add(_leased_pending(task_id=task_id, execution_state=execution_state)) + db.session.flush() + + deleted = TransferPending.abandon_unstarted( + db.session, + task_id=task_id, + lease_token=f"lease-{task_id}", + now_time="2026-08-27 10:01:00.000000", + ) + db.session.flush() + + assert deleted == expected_deleted + remaining = db.session.execute( + select(TransferPending).where(TransferPending.task_id == task_id) + ).scalar_one_or_none() + assert (remaining is None) is bool(expected_deleted) + + +@pytest.mark.parametrize("admission_state", ["planned", "provider_pending"]) +def test_abandon_unstarted_rejects_nonaccepted_admission_state( + db, + admission_state, +) -> None: + """已进入 provider 或计划态的任务即使无步骤也不得按缺失源删除。""" + task_id = f"abandon-{admission_state}" + db.add(_leased_pending( + task_id=task_id, + execution_state="not_started", + admission_state=admission_state, + )) + + deleted = TransferPending.abandon_unstarted( + db.session, + task_id=task_id, + lease_token=f"lease-{task_id}", + now_time="2026-08-27 10:01:00.000000", + ) + + assert deleted == 0 + + +def test_abandon_unstarted_rejects_task_with_any_step_evidence(db) -> None: + """即使聚合状态尚未推进,已落库步骤也必须阻止删除 pending。""" + task_id = "abandon-with-step" + db.add(_leased_pending(task_id=task_id, execution_state="not_started")) + db.add(TransferExecutionStep( + task_id=task_id, + operation_id="operation-with-step", + checkpoint_fingerprint="plan", + ordinal=0, + phase="transfer", + kind="move", + state="prepared", + attempt_count=0, + intent_version=1, + intent_payload={"source": "/mnt/source.mkv"}, + prepared_at="2026-08-27 10:00:00", + updated_at="2026-08-27 10:00:00", + )) + db.session.flush() + + deleted = TransferPending.abandon_unstarted( + db.session, + task_id=task_id, + lease_token=f"lease-{task_id}", + now_time="2026-08-27 10:01:00.000000", + ) + + assert deleted == 0 + assert db.session.execute( + select(TransferPending).where(TransferPending.task_id == task_id) + ).scalar_one_or_none() is not None + + +def test_abandon_unstarted_rejects_task_with_terminal_history_evidence(db) -> None: + """已有任务关联历史时不得删除 pending,避免掩盖未闭环结算。""" + task_id = "abandon-with-history" + db.add(_leased_pending(task_id=task_id, execution_state="not_started")) + db.add(TransferHistory( + transfer_task_id=task_id, + transfer_settlement_revision=1, + src="/mnt/abandon-with-history.mkv", + src_storage="local", + status=False, + )) + + deleted = TransferPending.abandon_unstarted( + db.session, + task_id=task_id, + lease_token=f"lease-{task_id}", + now_time="2026-08-27 10:01:00.000000", + ) + + assert deleted == 0 + + def test_transactional_repository_rolls_back_failed_write(monkeypatch): """适配器写入异常时必须回滚自身 UoW 并传播原异常。""" class SessionContext: diff --git a/tests/test_legacy_import_compat.py b/tests/test_legacy_import_compat.py index 9cf25d2e4..999014e14 100644 --- a/tests/test_legacy_import_compat.py +++ b/tests/test_legacy_import_compat.py @@ -220,6 +220,58 @@ def test_transfer_package_exposes_plugin_symbols_only_through_overlay() -> None: reset_legacy_import_diagnostics() +def test_transfer_legacy_symbols_support_all_explicit_imports() -> None: + """六个旧整理符号显式导入应在隔离进程中共享同一兼容类型。""" + code = """ +from app.application.transfer import TransferTask as ApplicationTask +from app.application.transfer import TransferQueue as ApplicationQueue +from app.schemas import TransferTask as SchemaTask +from app.schemas import TransferQueue as SchemaQueue +from app.schemas.transfer import TransferTask as TransferSchemaTask +from app.schemas.transfer import TransferQueue as TransferSchemaQueue + +import app.application.transfer as application_package +import app.schemas as schemas_package +import app.schemas.transfer as transfer_schema +from app.application.transfer.workflow import TransferQueue as CanonicalQueue +from app.application.transfer.workflow import TransferTask as CanonicalTask +from app.sdk._legacy.transfer import TransferQueue as LegacyQueue +from app.sdk._legacy.transfer import TransferTask as LegacyTask + + +class LegacyPayload: + def model_dump(self): + return {"kind": "legacy"} + + +task_types = (ApplicationTask, SchemaTask, TransferSchemaTask) +queue_types = (ApplicationQueue, SchemaQueue, TransferSchemaQueue) +assert all(task_type is LegacyTask for task_type in task_types) +assert all(queue_type is LegacyQueue for queue_type in queue_types) +assert issubclass(LegacyTask, CanonicalTask) +assert issubclass(LegacyQueue, CanonicalQueue) + +task = ApplicationTask( + fileitem={"storage": "local", "path": "/downloads/movie.mkv", "type": "file"}, + meta=LegacyPayload(), +) +assert isinstance(task, CanonicalTask) +assert task.to_dict()["meta"] == {"kind": "legacy"} +for queue_type in queue_types: + queue = queue_type(task=task) + assert queue.task is task + +for package in (application_package, schemas_package, transfer_schema): + assert "TransferTask" not in package.__all__ + assert "TransferQueue" not in package.__all__ +""" + subprocess.run( + [sys.executable, "-c", code], + cwd=Path(__file__).parents[1], + check=True, + ) + + def test_virtual_package_exports_resolve_exact_manifest_symbols(): """合成旧包仅公开 manifest 声明的符号,并记录 DEBUG 兼容警告。""" legacy_package = "app.core.meta" diff --git a/tests/test_music_transfer.py b/tests/test_music_transfer.py index 36649e6c9..67909863c 100644 --- a/tests/test_music_transfer.py +++ b/tests/test_music_transfer.py @@ -5,6 +5,10 @@ from unittest.mock import Mock from jinja2 import Template from app.application.messaging.message import TemplateHelper +from app.application.transfer.execution import ( + TransferExecutionCheckpoint, + TransferSettlementResult, +) from app.application.transfer.workflow import TransferTask from app.chain.media import MediaChain from app.chain.transfer import JobManager, TransferChain @@ -494,6 +498,18 @@ def test_success_file_aggregation_is_isolated_between_music_jobs_in_same_directo chain.eventmanager = Mock() chain.transfer_completed = Mock() chain.send_transfer_message = Mock() + + def transfer_result(**kwargs): + """执行测试历史暂存并返回 task-aware 原子结算回执。""" + history = kwargs["stage_history"](SimpleNamespace()) + return TransferSettlementResult( + history_id=history.id, + settlement_revision=1, + pending_deleted=True, + ) + + chain.durable_event_writer = Mock() + chain.durable_event_writer.transfer_result.side_effect = transfer_result album_infos = [ MusicInfo( music_type="album", @@ -544,8 +560,21 @@ def test_success_file_aggregation_is_isolated_between_music_jobs_in_same_directo lambda **kwargs: SimpleNamespace(id=1), ) - for task in tasks: - chain._TransferChain__default_callback(task, transfer_info(task)) + for sequence, task in enumerate(tasks): + result = transfer_info(task) + task.bind_admission_task_id(f"music-terminal-{sequence}") + task.bind_execution_lease( + owner_id="music-test-owner", + lease_token=f"music-lease-{sequence}", + ) + task.bind_execution_checkpoint(TransferExecutionCheckpoint.create( + payload={ + "outcome": "succeeded", + "transferinfo": result.model_dump(mode="json"), + }, + operation_ids=(f"music-operation-{sequence}",), + )) + chain._TransferChain__default_callback(task, result) notified_lists = [ call.kwargs["transferinfo"].file_list_new diff --git a/tests/test_transfer_admission_migration.py b/tests/test_transfer_admission_migration.py index d7668cb35..60a41653e 100644 --- a/tests/test_transfer_admission_migration.py +++ b/tests/test_transfer_admission_migration.py @@ -170,6 +170,46 @@ def test_transfer_admission_upgrade_downgrade_reupgrade( } +def test_replayed_upgrade_repairs_named_constraint_and_index(monkeypatch) -> None: + """同名但列或唯一性错误的准入约束与索引必须精确重建。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + _create_legacy_table(connection) + migration = _bind_migration(monkeypatch, connection) + migration.upgrade() + with migration.op.batch_alter_table("transferpending") as batch_op: + batch_op.drop_constraint( + "uq_transferpending_task_id", + type_="unique", + ) + batch_op.create_unique_constraint( + "uq_transferpending_task_id", + ["src_path"], + ) + connection.execute(sa.text( + "DROP INDEX ix_transferpending_state_created" + )) + connection.execute(sa.text( + "CREATE UNIQUE INDEX ix_transferpending_state_created " + "ON transferpending (task_id)" + )) + + migration.upgrade() + + inspector = sa.inspect(connection) + constraint = next( + item for item in inspector.get_unique_constraints("transferpending") + if item["name"] == "uq_transferpending_task_id" + ) + index = next( + item for item in inspector.get_indexes("transferpending") + if item["name"] == "ix_transferpending_state_created" + ) + assert constraint["column_names"] == ["task_id"] + assert index["column_names"] == ["state", "created_at", "id"] + assert index["unique"] == 0 + + def test_transfer_admission_migration_runs_on_postgresql(monkeypatch) -> None: """隔离 PostgreSQL 应真实执行准入字段、约束、索引和可逆回滚。""" prefix = "MOVIEPILOT_TEST_POSTGRESQL_" diff --git a/tests/test_transfer_execution_migration.py b/tests/test_transfer_execution_migration.py index 1a7deb3df..84888a533 100644 --- a/tests/test_transfer_execution_migration.py +++ b/tests/test_transfer_execution_migration.py @@ -378,6 +378,83 @@ def test_downgrade_marks_step_evidence_then_reupgrade_keeps_manual_review( engine.dispose() +def test_downgrade_archives_and_reupgrade_restores_settlement_receipts( + monkeypatch, +) -> None: + """降级必须归档 append-only 终态证据,重复降级和重升均不得丢失。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + pending, _ = _create_legacy_tables(connection) + _insert_legacy_rows(connection, pending) + migration = _bind_migration(monkeypatch, connection) + migration.upgrade() + connection.execute(sa.text( + "INSERT INTO transfersettlementreceipt (" + "task_id, history_id, settlement_revision, outcome, " + "execution_fingerprint, lease_token, history_status, src, src_storage, " + "pending_deleted, error, created_at, updated_at" + ") VALUES (" + "'settled', 42, 1, 'succeeded', 'fingerprint', 'lease', 1, " + "'/source', 'local', 1, NULL, " + "'2026-08-27 12:00:00', '2026-08-27 12:00:00'" + ")" + )) + + migration.downgrade() + migration.downgrade() + tables = sa.inspect(connection).get_table_names() + assert "transfersettlementreceipt" not in tables + assert "transfersettlementreceipt_3_0_16_archive" in tables + migration.upgrade() + migration.upgrade() + receipt = connection.execute(sa.text( + "SELECT task_id, outcome, settlement_revision " + "FROM transfersettlementreceipt" + )).one() + assert receipt == ("settled", "succeeded", 1) + assert "transfersettlementreceipt_3_0_16_archive" not in ( + sa.inspect(connection).get_table_names() + ) + + +def test_upgrade_repairs_owned_indexes_by_columns_and_uniqueness(monkeypatch) -> None: + """关键索引同名但列或唯一性错误时必须按 ORM 契约重建。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + pending, _ = _create_legacy_tables(connection) + _insert_legacy_rows(connection, pending) + migration = _bind_migration(monkeypatch, connection) + migration._add_pending_columns() + migration._backfill_pending() + migration._add_history_columns() + connection.execute(sa.text( + "CREATE UNIQUE INDEX ix_transferpending_execution_due " + "ON transferpending (task_id)" + )) + connection.execute(sa.text( + "CREATE INDEX ux_transferhistory_transfer_task_id " + "ON transferhistory (src_storage)" + )) + + migration.upgrade() + + inspector = sa.inspect(connection) + pending_index = next( + item for item in inspector.get_indexes("transferpending") + if item["name"] == "ix_transferpending_execution_due" + ) + history_index = next( + item for item in inspector.get_indexes("transferhistory") + if item["name"] == "ux_transferhistory_transfer_task_id" + ) + assert pending_index["column_names"] == [ + "execution_state", "retry_due_at", "state", "created_at", "id", + ] + assert pending_index["unique"] == 0 + assert history_index["column_names"] == ["transfer_task_id"] + assert history_index["unique"] == 1 + + def test_upgrade_without_pending_table_is_a_safe_noop(monkeypatch): """全新数据库尚未执行前置迁移时本版本应安全等待迁移链建表。""" engine = sa.create_engine("sqlite://") @@ -659,7 +736,7 @@ def test_upgrade_adds_synthetic_review_when_nonmanual_step_already_exists( def test_migrated_legacy_reviews_are_discoverable_resolvable_and_retryable( monkeypatch, ) -> None: - """迁移遗留任务应可分页判定,并在判定后准备真实首步骤。""" + """迁移遗留任务应可分页判定,判定后仍须重新规划才能准备步骤。""" engine = sa.create_engine("sqlite://") with engine.begin() as connection: pending, _ = _create_legacy_tables(connection) @@ -753,17 +830,32 @@ def test_migrated_legacy_reviews_are_discoverable_resolvable_and_retryable( snapshot = repository.get_snapshot(task_id=task_id) assert snapshot is not None assert snapshot.steps == () - prepared = command.prepare( - task_id=task_id, - lease_token=f"lease-{task_id}", - intent=TransferStepIntent.create( + with factory() as session: + pending = session.scalar( + sa.select(TransferPending).where( + TransferPending.task_id == task_id + ) + ) + assert pending is not None + assert pending.state in {"accepted", "planned"} + with pytest.raises( + TransferExecutionConflictError, + match="可执行规划状态|完整计划检查点|无法恢复", + ): + command.prepare( task_id=task_id, - checkpoint_fingerprint="f" * 64, - ordinal=0, - phase="transfer", - kind="copy", - payload={"source": task_id}, - ), - ) - assert prepared.ordinal == 0 + lease_token=f"lease-{task_id}", + intent=TransferStepIntent.create( + task_id=task_id, + checkpoint_fingerprint="f" * 64, + ordinal=0, + phase="transfer", + kind="copy", + payload={"source": task_id}, + ), + ) + current = repository.get_snapshot(task_id=task_id) + assert current is not None + assert current.state is TransferExecutionState.RETRY_WAIT + assert current.steps == () engine.dispose() diff --git a/tests/test_transfer_execution_persistence.py b/tests/test_transfer_execution_persistence.py index 1247dd910..ec7af3afb 100644 --- a/tests/test_transfer_execution_persistence.py +++ b/tests/test_transfer_execution_persistence.py @@ -1,5 +1,6 @@ """验证整理执行证据、CAS fencing 与终态结算持久化。""" +from dataclasses import replace from datetime import datetime, timezone import pytest @@ -20,6 +21,13 @@ from app.application.transfer.execution import ( build_transfer_checkpoint_fingerprint, build_transfer_operation_id, ) +from app.application.transfer.workflow import ( + TransferPlanCheckpoint, + TransferPlanItem, + TransferPlanningInput, + TransferProviderInvocationSnapshot, + TransferProviderReference, +) from app.db.adapters.transfer.execution import ( TransactionalTransferExecutionRepository, ) @@ -48,22 +56,70 @@ def execution_store(): engine.dispose() -def _seed_pending(factory, *, task_id: str = "task-1", lease_token: str = "lease-1"): +def _planning_input(task_id: str) -> TransferPlanningInput: + """构造与测试源文件一致的持久规划输入。""" + return TransferPlanningInput( + source_fileitem={ + "storage": "local", + "path": f"/{task_id}.mkv", + "type": "file", + }, + target_storage="local", + target_path="/media", + requested_transfer_type="copy", + ) + + +def _plan_checkpoint(task_id: str) -> TransferPlanCheckpoint: + """构造包含一个叶子文件计划的完整宿主检查点。""" + planning_input = _planning_input(task_id) + return TransferPlanCheckpoint( + planning_input=planning_input, + target_storage="local", + root_target_path="/media", + final_target_path=f"/media/{task_id}.mkv", + resolved_transfer_type="copy", + items=(TransferPlanItem( + sequence=0, + source_fileitem=planning_input.source_fileitem, + target_storage="local", + target_path=f"/media/{task_id}.mkv", + ),), + ) + + +def _plan_fingerprint(task_id: str) -> str: + """返回测试冻结计划使用的 canonical 指纹。""" + return build_transfer_checkpoint_fingerprint( + _plan_checkpoint(task_id).to_payload() + ) + + +def _seed_pending( + factory, + *, + task_id: str = "task-1", + lease_token: str = "lease-1", + state: str = "planned", + with_checkpoint: bool = True, +): """写入一条带有效租约与合法 planning checkpoint 的待执行任务。""" + planning_input = _planning_input(task_id) + checkpoint = _plan_checkpoint(task_id) if with_checkpoint else None with factory() as session: session.add(TransferPending( task_id=task_id, storage="local", src_path=f"/{task_id}.mkv", created_at="2026-08-27 09:00:00", - state="planned", + state=state, updated_at="2026-08-27 09:00:00", input_version=1, - planning_input={"schema_version": 1, "source": task_id}, - input_fingerprint="input-fingerprint", - checkpoint_version=1, - checkpoint_payload={"schema_version": 1, "task_id": task_id}, - planned_at="2026-08-27 09:00:00", + planning_input=planning_input.to_payload(), + input_fingerprint=planning_input.fingerprint, + checkpoint_version=checkpoint.schema_version if checkpoint else None, + checkpoint_payload=checkpoint.to_payload() if checkpoint else None, + planned_at="2026-08-27 09:00:00" if checkpoint else None, lease_owner="worker-1", lease_token=lease_token, lease_expires_at="2099-01-01 00:00:00.000000", @@ -93,13 +149,19 @@ def _repository(factory, token_values: list[str] | None = None): def _intent(*, task_id: str = "task-1", ordinal: int = 0) -> TransferStepIntent: """构造稳定且可重复计算身份的测试步骤意图。""" + planning_input = _planning_input(task_id) return TransferStepIntent.create( task_id=task_id, - checkpoint_fingerprint="plan-fingerprint", + checkpoint_fingerprint=_plan_fingerprint(task_id), ordinal=ordinal, phase="transfer", - kind="copy", - payload={"src": f"/{task_id}.mkv", "dest": f"/media/{task_id}.mkv"}, + kind="materialize_target", + payload={ + "source": planning_input.source_fileitem, + "target_storage": "local", + "target_path": f"/media/{task_id}.mkv", + "transfer_type": "copy", + }, ) @@ -138,6 +200,143 @@ def test_stable_operation_and_checkpoint_identities_are_canonical(): assert intent.payload == {"path": "/original"} +@pytest.mark.parametrize( + ("state", "with_checkpoint"), + (("accepted", False), ("planned", False)), +) +def test_prepare_rejects_task_without_executable_plan( + execution_store, + state, + with_checkpoint, +): + """接纳态或缺失完整 checkpoint 的任务不得进入外部步骤准备。""" + _seed_pending( + execution_store, + state=state, + with_checkpoint=with_checkpoint, + ) + _, command = _repository(execution_store) + with pytest.raises(TransferExecutionConflictError): + command.prepare( + task_id="task-1", + lease_token="lease-1", + intent=_intent(), + ) + with execution_store() as session: + pending = session.scalar(select(TransferPending)) + assert pending is not None + assert pending.execution_state == "not_started" + assert session.scalar(select(TransferExecutionStep)) is None + + +def test_prepare_rejects_noncanonical_plan_fingerprint(execution_store): + """步骤 intent 必须精确绑定当前持久计划的 canonical 指纹。""" + _seed_pending(execution_store) + _, command = _repository(execution_store) + intent = TransferStepIntent.create( + task_id="task-1", + checkpoint_fingerprint="0" * 64, + ordinal=0, + phase="transfer", + kind="materialize_target", + payload=_intent().payload, + ) + with pytest.raises(TransferExecutionConflictError, match="当前冻结计划指纹"): + command.prepare( + task_id="task-1", + lease_token="lease-1", + intent=intent, + ) + + +def test_prepare_rejects_forged_operation_id(execution_store): + """调用方手工构造的伪 operation ID 不得绕过稳定身份计算。""" + _seed_pending(execution_store) + _, command = _repository(execution_store) + valid = _intent() + forged = TransferStepIntent( + operation_id="f" * 64, + checkpoint_fingerprint=valid.checkpoint_fingerprint, + ordinal=valid.ordinal, + phase=valid.phase, + kind=valid.kind, + payload=valid.payload, + ) + with pytest.raises(TransferExecutionConflictError, match="operation ID 不可信"): + command.prepare( + task_id="task-1", + lease_token="lease-1", + intent=forged, + ) + + +def test_prepare_rejects_arbitrary_intent_with_known_plan_fingerprint( + execution_store, +): + """已知计划指纹也不能构造计划未授权的操作类型或参数。""" + _seed_pending(execution_store) + _, command = _repository(execution_store) + arbitrary = TransferStepIntent.create( + task_id="task-1", + checkpoint_fingerprint=_plan_fingerprint("task-1"), + ordinal=0, + phase="transfer", + kind="delete_move_source", + payload={ + "source": _planning_input("task-1").source_fileitem, + "target_storage": "local", + "target_path": "/media/task-1.mkv", + }, + ) + with pytest.raises(TransferExecutionConflictError, match="冻结计划导出"): + command.prepare( + task_id="task-1", + lease_token="lease-1", + intent=arbitrary, + ) + + +def test_prepare_rejects_noncontiguous_ordinal(execution_store): + """新步骤只能在完整既有序列尾部连续追加。""" + _seed_pending(execution_store) + _, command = _repository(execution_store) + with pytest.raises(TransferExecutionConflictError, match="连续追加"): + command.prepare( + task_id="task-1", + lease_token="lease-1", + intent=_intent(ordinal=1), + ) + + +def test_stage_execution_running_cas_binds_exact_plan_identity(execution_store): + """running CAS 必须拒绝读取后已变化的准入状态或 checkpoint payload。""" + _seed_pending(execution_store) + checkpoint = _plan_checkpoint("task-1") + with execution_store() as session: + stale = TransferPending.stage_execution_running( + session, + task_id="task-1", + lease_token="lease-1", + admission_state="planned", + checkpoint_version=checkpoint.schema_version, + checkpoint_payload={"schema_version": checkpoint.schema_version}, + now_utc="2026-08-27 01:30:00.000000", + updated_at="2026-08-27 09:30:00", + ) + assert stale == 0 + current = TransferPending.stage_execution_running( + session, + task_id="task-1", + lease_token="lease-1", + admission_state="planned", + checkpoint_version=checkpoint.schema_version, + checkpoint_payload=checkpoint.to_payload(), + now_utc="2026-08-27 01:30:00.000000", + updated_at="2026-08-27 09:30:00", + ) + assert current == 1 + + def test_success_path_persists_steps_and_execution_checkpoint(execution_store): """成功路径应保留每步证据,并提交可供唯一 durable writer 结算的检查点。""" _seed_pending(execution_store) @@ -161,7 +360,7 @@ def test_success_path_persists_steps_and_execution_checkpoint(execution_store): result=TransferStepResult(payload={"dest_exists": True}), ) checkpoint = TransferExecutionCheckpoint.create( - payload={"dest": "/media/task-1.mkv"}, + payload={"outcome": "succeeded", "dest": "/media/task-1.mkv"}, operation_ids=(succeeded.operation_id,), ) snapshot = command.checkpoint( @@ -178,6 +377,244 @@ def test_success_path_persists_steps_and_execution_checkpoint(execution_store): assert step is not None and step.state == "succeeded" +def test_provider_predecessor_remains_owned_after_host_plan_promotion( + execution_store, +): + """provider 回退升级宿主计划后,序号零证据仍应被严格重建并纳入 checkpoint。""" + task_id = "task-1" + planning_input = _planning_input(task_id) + provider = TransferProviderReference( + plugin_id="provider-a", + plugin_name="Provider A", + ) + invocation = TransferProviderInvocationSnapshot( + fileitem=planning_input.source_fileitem, + meta={"title": "Movie"}, + meta_kind="MetaVideo", + mediainfo={"title": "Movie"}, + mediainfo_kind="MediaInfo", + ) + provider_checkpoint = TransferPlanCheckpoint( + planning_input=planning_input, + target_storage="", + root_target_path="", + final_target_path="", + resolved_transfer_type="", + items=(), + resolved_meta=invocation.meta, + resolved_meta_kind=invocation.meta_kind, + resolved_mediainfo=invocation.mediainfo, + resolved_mediainfo_kind=invocation.mediainfo_kind, + legacy_transfer_providers=(provider,), + provider_invocation=invocation, + ) + _seed_pending(execution_store) + with execution_store() as session: + pending = session.scalar(select(TransferPending)) + assert pending is not None + pending.state = "provider_pending" + pending.checkpoint_payload = provider_checkpoint.to_payload() + session.commit() + _, command = _repository(execution_store) + provider_intent = TransferStepIntent.create( + task_id=task_id, + checkpoint_fingerprint=build_transfer_checkpoint_fingerprint( + provider_checkpoint.to_payload() + ), + ordinal=0, + phase="provider", + kind="legacy_transfer_provider_sequence", + payload={ + "providers": [provider.to_payload()], + "invocation": invocation.to_payload(), + }, + ) + prepared = command.prepare( + task_id=task_id, + lease_token="lease-1", + intent=provider_intent, + ) + started = command.begin( + task_id=task_id, + lease_token="lease-1", + operation_id=prepared.operation_id, + ) + provider_succeeded = command.complete( + task_id=task_id, + lease_token="lease-1", + step=started, + result=TransferStepResult(payload={"handled": False}), + ) + promoted_checkpoint = replace( + _plan_checkpoint(task_id), + pre_execution_cleanup_completed=True, + ) + with execution_store() as session: + pending = session.scalar(select(TransferPending)) + assert pending is not None + pending.state = "planned" + pending.checkpoint_payload = promoted_checkpoint.to_payload() + session.commit() + promoted_fingerprint = build_transfer_checkpoint_fingerprint( + promoted_checkpoint.to_payload() + ) + host_intent = TransferStepIntent.create( + task_id=task_id, + checkpoint_fingerprint=promoted_fingerprint, + ordinal=1, + phase="transfer", + kind="materialize_target", + payload=_intent().payload, + ) + host_prepared = command.prepare( + task_id=task_id, + lease_token="lease-1", + intent=host_intent, + ) + host_started = command.begin( + task_id=task_id, + lease_token="lease-1", + operation_id=host_prepared.operation_id, + ) + host_succeeded = command.complete( + task_id=task_id, + lease_token="lease-1", + step=host_started, + result=TransferStepResult(payload={"dest_exists": True}), + ) + execution_checkpoint = TransferExecutionCheckpoint.create( + payload={"outcome": "succeeded", "dest": "/media/task-1.mkv"}, + operation_ids=( + provider_succeeded.operation_id, + host_succeeded.operation_id, + ), + ) + snapshot = command.checkpoint( + task_id=task_id, + lease_token="lease-1", + checkpoint=execution_checkpoint, + ) + assert snapshot.state is TransferExecutionState.SETTLING + assert tuple(step.ordinal for step in snapshot.steps) == (0, 1) + + +def test_checkpoint_rejects_operation_ids_out_of_ordinal_order(execution_store): + """执行检查点必须按严格 ordinal 保存操作身份,集合相同也不能乱序。""" + _seed_pending(execution_store) + _, command = _repository(execution_store) + completed = [] + for ordinal in range(2): + prepared = command.prepare( + task_id="task-1", + lease_token="lease-1", + intent=_intent(ordinal=ordinal), + ) + started = command.begin( + task_id="task-1", + lease_token="lease-1", + operation_id=prepared.operation_id, + ) + completed.append(command.complete( + task_id="task-1", + lease_token="lease-1", + step=started, + result=TransferStepResult(payload={"ordinal": ordinal}), + )) + checkpoint = TransferExecutionCheckpoint.create( + payload={"outcome": "succeeded", "dest": "/media/task-1.mkv"}, + operation_ids=tuple(step.operation_id for step in reversed(completed)), + ) + with pytest.raises(TransferExecutionConflictError, match="步骤顺序"): + command.checkpoint( + task_id="task-1", + lease_token="lease-1", + checkpoint=checkpoint, + ) + + +def test_checkpoint_rejects_corrupted_persisted_operation_id(execution_store): + """checkpoint 前必须重新计算每个持久步骤的 operation ID。""" + _seed_pending(execution_store) + _, command = _repository(execution_store) + prepared = command.prepare( + task_id="task-1", + lease_token="lease-1", + intent=_intent(), + ) + started = command.begin( + task_id="task-1", + lease_token="lease-1", + operation_id=prepared.operation_id, + ) + command.complete( + task_id="task-1", + lease_token="lease-1", + step=started, + result=TransferStepResult(payload={"dest_exists": True}), + ) + with execution_store() as session: + step = session.scalar(select(TransferExecutionStep)) + assert step is not None + step.operation_id = "e" * 64 + session.commit() + checkpoint = TransferExecutionCheckpoint.create( + payload={"outcome": "succeeded", "dest": "/media/task-1.mkv"}, + operation_ids=("e" * 64,), + ) + with pytest.raises(TransferExecutionConflictError, match="冻结意图"): + command.checkpoint( + task_id="task-1", + lease_token="lease-1", + checkpoint=checkpoint, + ) + + +def test_checkpoint_rejects_noncontiguous_persisted_ordinals(execution_store): + """checkpoint 前必须拒绝缺口或从非零开始的持久步骤序列。""" + _seed_pending(execution_store) + _, command = _repository(execution_store) + prepared = command.prepare( + task_id="task-1", + lease_token="lease-1", + intent=_intent(), + ) + started = command.begin( + task_id="task-1", + lease_token="lease-1", + operation_id=prepared.operation_id, + ) + command.complete( + task_id="task-1", + lease_token="lease-1", + step=started, + result=TransferStepResult(payload={"dest_exists": True}), + ) + with execution_store() as session: + step = session.scalar(select(TransferExecutionStep)) + assert step is not None + step.ordinal = 2 + step.operation_id = build_transfer_operation_id( + task_id=step.task_id, + checkpoint_fingerprint=step.checkpoint_fingerprint, + ordinal=step.ordinal, + phase=step.phase, + kind=step.kind, + intent_payload=step.intent_payload, + ) + corrupted_operation_id = step.operation_id + session.commit() + checkpoint = TransferExecutionCheckpoint.create( + payload={"outcome": "succeeded", "dest": "/media/task-1.mkv"}, + operation_ids=(corrupted_operation_id,), + ) + with pytest.raises(TransferExecutionConflictError, match="全局序号不连续"): + command.checkpoint( + task_id="task-1", + lease_token="lease-1", + checkpoint=checkpoint, + ) + + def test_retry_wait_resumes_same_failed_operation_with_new_attempt(execution_store): """到期重试必须复用 operation ID、保留失败证据并轮换 attempt token。""" _seed_pending(execution_store) @@ -264,7 +701,7 @@ def test_zero_side_effect_checkpoint_is_vacuously_complete(execution_store): _seed_pending(execution_store) _, command = _repository(execution_store) checkpoint = TransferExecutionCheckpoint.create( - payload={"preview": True, "accepted": False}, + payload={"outcome": "failed", "preview": True, "accepted": False}, operation_ids=(), skip_reason="preview", ) diff --git a/tests/test_transfer_execution_runner.py b/tests/test_transfer_execution_runner.py index 74bfc37f0..8fd9c313f 100644 --- a/tests/test_transfer_execution_runner.py +++ b/tests/test_transfer_execution_runner.py @@ -15,6 +15,12 @@ from app.application.transfer.execution import ( TransferOperationObservationState, TransferStepIntent, TransferStepResult, + build_transfer_checkpoint_fingerprint, +) +from app.application.transfer.workflow import ( + TransferPlanCheckpoint, + TransferPlanItem, + TransferPlanningInput, ) from app.chain import transfer as transfer_chain_module from app.db.adapters.transfer.execution import ( @@ -28,6 +34,33 @@ from app.modules.filemanager.transhandler import TransHandler from app.schemas.workflow import FileItem +def _runner_plan_checkpoint() -> TransferPlanCheckpoint: + """构造 runner fixture 使用的完整冻结计划。""" + planning_input = TransferPlanningInput( + source_fileitem={ + "storage": "local", + "path": "/source.mkv", + "type": "file", + }, + target_storage="local", + target_path="/target.mkv", + requested_transfer_type="copy", + ) + return TransferPlanCheckpoint( + planning_input=planning_input, + target_storage="local", + root_target_path="/", + final_target_path="/target.mkv", + resolved_transfer_type="copy", + items=(TransferPlanItem( + sequence=0, + source_fileitem=planning_input.source_fileitem, + target_storage="local", + target_path="/target.mkv", + ),), + ) + + @pytest.fixture def execution_repository(): """构造带有效 pending 租约的独立执行仓储。""" @@ -41,6 +74,8 @@ def execution_repository(): ], ) factory = sessionmaker(bind=engine, expire_on_commit=False) + plan_checkpoint = _runner_plan_checkpoint() + planning_input = plan_checkpoint.planning_input with factory() as session: session.add(TransferPending( task_id="task-runner", @@ -50,10 +85,10 @@ def execution_repository(): state="planned", updated_at="2026-08-27 09:00:00", input_version=1, - planning_input={"schema_version": 1}, - input_fingerprint="input", + planning_input=planning_input.to_payload(), + input_fingerprint=planning_input.fingerprint, checkpoint_version=1, - checkpoint_payload={"schema_version": 1}, + checkpoint_payload=plan_checkpoint.to_payload(), planned_at="2026-08-27 09:00:00", lease_owner="worker", lease_token="lease", @@ -82,11 +117,30 @@ def _runner(repository): return transfer_chain_module._DurableTransferStepRunner( task_id="task-runner", lease_token="lease", - checkpoint_fingerprint="plan", + checkpoint_fingerprint=_runner_plan_fingerprint(), repository=repository, ) +def _runner_plan_fingerprint() -> str: + """返回 runner fixture 中完整冻结计划的 canonical 指纹。""" + return build_transfer_checkpoint_fingerprint( + _runner_plan_checkpoint().to_payload() + ) + + +def _runner_step_payload() -> dict: + """返回由 runner 冻结计划唯一叶操作导出的目标落地参数。""" + checkpoint = _runner_plan_checkpoint() + item = checkpoint.items[0] + return { + "source": item.source_fileitem, + "target_storage": item.target_storage, + "target_path": item.target_path, + "transfer_type": checkpoint.resolved_transfer_type, + } + + def test_runner_replay_returns_persisted_result_without_repeating_side_effect( execution_repository, ): @@ -94,8 +148,8 @@ def test_runner_replay_returns_persisted_result_without_repeating_side_effect( calls = [] first = _runner(execution_repository).run( phase="transfer", - kind="copy", - payload={"source": "/source.mkv", "target": "/target.mkv"}, + kind="materialize_target", + payload=_runner_step_payload(), execute=lambda: calls.append("executed") or TransferStepResult( payload={"item": {"path": "/target.mkv"}} ), @@ -103,8 +157,8 @@ def test_runner_replay_returns_persisted_result_without_repeating_side_effect( ) second = _runner(execution_repository).run( phase="transfer", - kind="copy", - payload={"source": "/source.mkv", "target": "/target.mkv"}, + kind="materialize_target", + payload=_runner_step_payload(), execute=lambda: pytest.fail("已成功步骤不得重复执行"), observe=lambda: pytest.fail("已成功步骤不得执行恢复探测"), ) @@ -122,11 +176,11 @@ def test_runner_routes_unknown_orphaned_attempt_to_manual_review( lease_token="lease", intent=TransferStepIntent.create( task_id="task-runner", - checkpoint_fingerprint="plan", - ordinal=0, - phase="provider", - kind="opaque", - payload={"provider": "legacy"}, + checkpoint_fingerprint=_runner_plan_fingerprint(), + ordinal=0, + phase="transfer", + kind="materialize_target", + payload=_runner_step_payload(), ), ) command.begin( @@ -140,9 +194,9 @@ def test_runner_routes_unknown_orphaned_attempt_to_manual_review( match="禁止自动重放", ): _runner(execution_repository).run( - phase="provider", - kind="opaque", - payload={"provider": "legacy"}, + phase="transfer", + kind="materialize_target", + payload=_runner_step_payload(), execute=lambda: pytest.fail("未知遗留步骤不得重放"), observe=lambda: TransferOperationObservation( state=TransferOperationObservationState.UNKNOWN, @@ -154,6 +208,138 @@ def test_runner_routes_unknown_orphaned_attempt_to_manual_review( assert snapshot.state is TransferExecutionState.MANUAL_REVIEW +def test_runner_observes_applied_after_execute_error_and_completes_step( + execution_repository, +) -> None: + """execute 抛错后若外部事实已生效,必须以观察证据完成而非重放。""" + evidence = TransferStepResult(payload={"target": "/target.mkv"}) + + def execute() -> TransferStepResult: + """模拟副作用成功后调用方在返回前崩溃。""" + raise OSError("connection reset after apply") + + result = _runner(execution_repository).run( + phase="transfer", + kind="materialize_target", + payload=_runner_step_payload(), + execute=execute, + observe=lambda: TransferOperationObservation( + state=TransferOperationObservationState.APPLIED, + evidence=evidence, + ), + ) + + snapshot = execution_repository.get_snapshot(task_id="task-runner") + assert result == evidence + assert snapshot is not None + assert snapshot.state is TransferExecutionState.RUNNING + assert snapshot.steps[0].result == evidence + assert snapshot.steps[0].state.value == "succeeded" + + +def test_runner_defers_after_execute_error_observed_not_applied( + execution_repository, +) -> None: + """execute 抛错且确认未生效时只能进入持久退避,不得误判成功。""" + evidence = TransferStepResult(payload={"target_exists": False}) + + def execute() -> TransferStepResult: + """模拟外部操作在应用前失败。""" + raise OSError("write rejected") + + with pytest.raises(transfer_chain_module._TransferRetryDeferred): + _runner(execution_repository).run( + phase="transfer", + kind="materialize_target", + payload=_runner_step_payload(), + execute=execute, + observe=lambda: TransferOperationObservation( + state=TransferOperationObservationState.NOT_APPLIED, + evidence=evidence, + ), + ) + + snapshot = execution_repository.get_snapshot(task_id="task-runner") + assert snapshot is not None + assert snapshot.state is TransferExecutionState.RETRY_WAIT + assert snapshot.steps[0].result == evidence + assert snapshot.steps[0].state.value == "failed" + + +@pytest.mark.parametrize( + "observation_state", + [ + TransferOperationObservationState.UNKNOWN, + TransferOperationObservationState.CONFLICT, + ], +) +def test_runner_freezes_uncertain_execute_error_for_manual_review( + execution_repository, + observation_state, +) -> None: + """execute 异常后的未知或冲突结果必须冻结,禁止自动重放。""" + evidence = TransferStepResult(payload={"receipt": "ambiguous"}) + + def execute() -> TransferStepResult: + """模拟外部结果未知的执行异常。""" + raise TimeoutError("provider timeout") + + with pytest.raises( + transfer_chain_module._TransferManualReviewRequired, + match=observation_state.value, + ): + _runner(execution_repository).run( + phase="transfer", + kind="materialize_target", + payload=_runner_step_payload(), + execute=execute, + observe=lambda: TransferOperationObservation( + state=observation_state, + evidence=evidence, + ), + ) + + snapshot = execution_repository.get_snapshot(task_id="task-runner") + assert snapshot is not None + assert snapshot.state is TransferExecutionState.MANUAL_REVIEW + assert snapshot.steps[0].result == evidence + assert snapshot.steps[0].state.value == "manual_review" + + +def test_runner_freezes_when_observer_errors_after_execute_error( + execution_repository, +) -> None: + """execute 与 observer 同时失败时必须保留双重证据并进入人工复核。""" + def execute() -> TransferStepResult: + """模拟调用结果未知的执行超时。""" + raise TimeoutError("execute timeout") + + def observe() -> TransferOperationObservation: + """模拟外部状态查询端点同时不可用。""" + raise ConnectionError("observer unavailable") + + with pytest.raises( + transfer_chain_module._TransferManualReviewRequired, + match="unknown", + ): + _runner(execution_repository).run( + phase="transfer", + kind="materialize_target", + payload=_runner_step_payload(), + execute=execute, + observe=observe, + ) + + snapshot = execution_repository.get_snapshot(task_id="task-runner") + assert snapshot is not None + assert snapshot.state is TransferExecutionState.MANUAL_REVIEW + assert snapshot.steps[0].result is not None + assert snapshot.steps[0].result.payload == { + "execute_error": "execute timeout", + "observe_error": "observer unavailable", + } + + class _ImmediateStepRunner: """记录 TransHandler 拆分顺序并立即执行步骤的测试 runner。""" diff --git a/tests/test_transfer_failed_retry_scheduler.py b/tests/test_transfer_failed_retry_scheduler.py deleted file mode 100644 index 4fb794fd2..000000000 --- a/tests/test_transfer_failed_retry_scheduler.py +++ /dev/null @@ -1,126 +0,0 @@ -"""失败整理 AI 重试调度器的生命周期测试。""" - -import asyncio -from unittest.mock import Mock, patch - -import pytest - -from app.application.transfer.workflow import FailedRetryScheduler - - -def test_retry_scheduler_close_cancels_buffered_timer_and_rejects_new_work(): - """关闭应取消尚未触发的 timer、清空缓冲并拒绝新增记录。""" - - async def exercise() -> None: - """在独立事件循环内验证 timer 与关闭状态。""" - scheduler = FailedRetryScheduler() - scheduler.RETRY_TRANSFER_DEBOUNCE_SECONDS = 60 - await scheduler.schedule_retry(11, group_key="media:test") - timer = scheduler._retry_transfer_timers["media:test"] - - await scheduler.close() - await scheduler.close() - - assert timer.cancelled() - assert scheduler._retry_transfer_buffer == {} - assert scheduler._retry_transfer_timers == {} - with pytest.raises(RuntimeError, match="正在关闭"): - await scheduler.schedule_retry(12, group_key="media:test") - - asyncio.run(exercise()) - - -def test_retry_scheduler_close_cancels_and_waits_for_active_flush_task(): - """关闭返回前应等待已经启动的 flush 任务完成取消收尾。""" - - async def exercise() -> None: - """启动一个不会自行结束的 flush,并通过关闭流程取消它。""" - scheduler = FailedRetryScheduler() - scheduler.RETRY_TRANSFER_DEBOUNCE_SECONDS = 0 - started = asyncio.Event() - stopped = asyncio.Event() - - async def blocking_flush(_group_key: str, _generation: int) -> None: - """等待取消信号,并在 finally 中证明收尾已经完成。""" - started.set() - try: - await asyncio.Event().wait() - finally: - stopped.set() - - scheduler._flush_retry_transfer = blocking_flush - await scheduler.schedule_retry(11, group_key="media:test") - await asyncio.wait_for(started.wait(), timeout=1) - task = next(iter(scheduler._retry_transfer_tasks)) - - await scheduler.close() - - assert stopped.is_set() - assert task.cancelled() - assert task.get_name() == "transfer.failed_retry.flush" - assert scheduler._retry_transfer_tasks == set() - - asyncio.run(exercise()) - - -def test_retry_scheduler_observes_unexpected_background_task_error(): - """flush 协程越过自身防线的异常仍应由任务 owner 统一观察。""" - - async def exercise() -> None: - """让受管 flush 任务直接失败,并等待完成回调处理异常。""" - scheduler = FailedRetryScheduler() - scheduler.RETRY_TRANSFER_DEBOUNCE_SECONDS = 0 - - async def failing_flush(_group_key: str, _generation: int) -> None: - """模拟 flush 外层出现未处理异常。""" - raise RuntimeError("flush failed") - - scheduler._flush_retry_transfer = failing_flush - with patch("app.application.transfer.workflow.logger.error", Mock()) as log_error: - await scheduler.schedule_retry(11, group_key="media:test") - for _ in range(5): - await asyncio.sleep(0) - if scheduler._retry_transfer_tasks: - break - assert scheduler._retry_transfer_tasks - for _ in range(5): - await asyncio.sleep(0) - if not scheduler._retry_transfer_tasks: - break - - assert scheduler._retry_transfer_tasks == set() - log_error.assert_called_once() - assert "flush failed" in log_error.call_args.args[0] - await scheduler.close() - - asyncio.run(exercise()) - - -def test_retry_scheduler_old_flush_cannot_consume_renewed_generation(): - """旧 timer 已建 task 后的新失败应续期,不能被旧 flush 提前取走。""" - - async def exercise() -> None: - """稳定复现 timer callback 与同组新 schedule 交错的窗口。""" - scheduler = FailedRetryScheduler() - scheduler.RETRY_TRANSFER_DEBOUNCE_SECONDS = 3600 - await scheduler.schedule_retry(11, group_key="media:test") - old_timer = scheduler._retry_transfer_timers["media:test"] - old_generation = scheduler._retry_transfer_generations["media:test"] - - # 模拟旧 timer callback 已进入事件循环,但 flush task 尚未取得分组锁。 - scheduler._start_retry_transfer_task("media:test", old_generation) - await scheduler.schedule_retry(12, group_key="media:test") - renewed_timer = scheduler._retry_transfer_timers["media:test"] - await asyncio.sleep(0) - await asyncio.sleep(0) - - assert old_timer.cancelled() - assert renewed_timer.cancelled() is False - assert scheduler._retry_transfer_buffer["media:test"] == [11, 12] - assert scheduler._retry_transfer_timers["media:test"] is renewed_timer - assert scheduler._retry_transfer_tasks == set() - - await scheduler.close() - assert renewed_timer.cancelled() - - asyncio.run(exercise()) diff --git a/tests/test_transfer_job_manager.py b/tests/test_transfer_job_manager.py index 0b2b142e3..f73bff5f5 100644 --- a/tests/test_transfer_job_manager.py +++ b/tests/test_transfer_job_manager.py @@ -9,6 +9,10 @@ from app.application.history import ( failed_retry_count, record_transfer_failure, ) +from app.application.transfer.execution import ( + TransferExecutionCheckpoint, + TransferSettlementResult, +) from app.application.transfer.workflow import ( TransferAdmission, TransferPlanningInput, @@ -223,13 +227,80 @@ def make_transfer_chain() -> TransferChain: admissions.admit.side_effect = admit admissions.claim_task.side_effect = claim_task admissions.checkpoint_plan.side_effect = checkpoint_plan - admissions.discard_claimed.return_value = 1 + admissions.abandon_unstarted.return_value = 1 admissions.release_claim.return_value = True chain._transfer_admissions = admissions chain._TransferChain__ensure_lease_heartbeat_owner = MagicMock() + + class ImmediateStepRunner: + """为 JobManager 测试提交纯内存步骤与聚合执行检查点。""" + + def run(self, *, phase, kind, payload, execute, observe): + """立即执行确定性测试步骤,不使用恢复探测。""" + del phase, kind, payload, observe + return execute() + + def checkpoint(self, transferinfo): + """把测试整理结果冻结为可供 task-aware writer 使用的检查点。""" + return TransferExecutionCheckpoint.create( + payload={ + "outcome": "succeeded" if transferinfo.success else "failed", + "transferinfo": transferinfo.model_dump(mode="json"), + }, + operation_ids=("job-test-operation",), + ) + + step_runner = ImmediateStepRunner() + chain._TransferChain__build_durable_step_runner = MagicMock( + return_value=step_runner + ) + + def transfer_result(**kwargs): + """执行测试历史暂存与发布,并返回已删除 pending 的原子回执。""" + staging = SimpleNamespace( + get_success_by_src=lambda *_args, **_kwargs: SimpleNamespace( + id=99, + status=True, + ) + ) + history = kwargs["stage_history"](staging) + if kwargs["publish"] is not None: + kwargs["publish"](kwargs["event_payload"]) + return TransferSettlementResult( + history_id=getattr(history, "id", 1) if history is not None else 1, + settlement_revision=1, + pending_deleted=True, + ) + + chain.durable_event_writer = MagicMock() + chain.durable_event_writer.transfer_result.side_effect = transfer_result return chain +def bind_terminal_checkpoint( + task: TransferTask, + transferinfo: TransferInfo, +) -> None: + """为直接回调测试绑定 task identity、lease 与聚合执行检查点。""" + task_id = f"terminal-{abs(hash(task.fileitem.path))}" + task.bind_admission_task_id(task_id) + task.bind_execution_lease( + owner_id="job-test-owner", + lease_token=f"lease-{task_id}", + ) + task.bind_execution_checkpoint(TransferExecutionCheckpoint.create( + payload={ + "outcome": ( + "overwrite_skipped" + if transferinfo.overwrite_skipped + else "succeeded" if transferinfo.success else "failed" + ), + "transferinfo": transferinfo.model_dump(mode="json"), + }, + operation_ids=("job-test-operation",), + )) + + def make_fileitem(path: str, size: int = 1024) -> FileItem: file_path = path name = file_path.rsplit("/", 1)[-1] @@ -545,6 +616,7 @@ class TransferJobManagerTest(unittest.TestCase): need_scrape=True, need_notify=False, ) + bind_terminal_checkpoint(task, transferinfo) with patch( "app.chain.transfer.get_chain_transfer_history_port", return_value=SimpleNamespace() @@ -969,6 +1041,7 @@ class TransferJobManagerTest(unittest.TestCase): transfer_type="copy", need_notify=False, ) + bind_terminal_checkpoint(task, failed_transferinfo) failed_history_oper = SimpleNamespace() with patch( "app.chain.transfer.get_chain_transfer_history_port", @@ -1010,6 +1083,7 @@ class TransferJobManagerTest(unittest.TestCase): need_scrape=False, need_notify=False, ) + bind_terminal_checkpoint(task, success_transferinfo) with patch( "app.chain.transfer.get_chain_transfer_history_port", return_value=SimpleNamespace() ), patch( @@ -1023,7 +1097,8 @@ class TransferJobManagerTest(unittest.TestCase): finally: _reset_failed_retries(src_path, storage) - def test_unrecognized_task_marks_downloader_hash_completed(self): + def test_unrecognized_task_waits_for_durable_settlement_before_completion(self): + """拒绝检查点建立后、writer 结算前不得提前完成下载种子或移除作业。""" chain = make_transfer_chain() chain.post_message = lambda *_args, **_kwargs: None completed = [] @@ -1058,15 +1133,14 @@ class TransferJobManagerTest(unittest.TestCase): self.assertFalse(state) self.assertEqual("未识别到媒体信息", errmsg) - self.assertEqual([("abc123", "qbittorrent")], completed) - self.assertEqual([], chain.jobview.list_jobs()) + self.assertEqual([], completed) + self.assertIsNotNone(task.plan_checkpoint) + self.assertIsNotNone(task.execution_checkpoint) + self.assertEqual(1, len(chain.jobview.list_jobs())) + chain.durable_event_writer.transfer_result.assert_not_called() - def test_unrecognized_task_survives_missing_failure_history(self): - """ - 写整理历史失败(``add_transfer_fail`` 返回 None)时,未识别分支仍须走完 - 通知、作业清理与种子完成标记:历史落库是通知的附属信息,不是前置条件。 - 通知正文只省去 ``/redo`` 指引,不得因读取 ``his.id`` 抛 NoneType。 - """ + def test_unrecognized_task_does_not_read_history_before_writer(self): + """拒绝步骤完成但 writer 未调用时,不得读取失败历史或发送通知。""" chain = make_transfer_chain() notifications = [] chain.post_message = lambda message, **_kwargs: notifications.append(message) @@ -1100,21 +1174,13 @@ class TransferJobManagerTest(unittest.TestCase): self.assertFalse(state) self.assertEqual("未识别到媒体信息", errmsg) - # 种子完成标记与作业清理都排在通知之后,通知崩掉会把它们一并跳过 - self.assertEqual([("abc123", "qbittorrent")], completed) - self.assertEqual([], chain.jobview.list_jobs()) - # 通知照发,但不含无法使用的 /redo 指引 - self.assertEqual(1, len(notifications)) - notification = notifications[0] - self.assertIn("未识别到媒体信息", notification.text) - self.assertNotIn("/redo", notification.text) - self.assertIsNone(notification.buttons) + self.assertEqual([], completed) + self.assertEqual([], notifications) + self.assertEqual(1, len(chain.jobview.list_jobs())) + chain.durable_event_writer.transfer_result.assert_not_called() - def test_unrecognized_task_keeps_redo_hint_when_history_written(self): - """ - 整理历史正常落库时,未识别通知须保留两条 ``/redo`` 指引与操作按钮, - 防止上一条用例被「一律删掉 /redo」这种偷懒实现蒙混过关。 - """ + def test_unrecognized_task_does_not_publish_redo_before_writer(self): + """即使历史函数可用,未经过 task-aware writer 也不得发布 redo。""" chain = make_transfer_chain() notifications = [] chain.post_message = lambda message, **_kwargs: notifications.append(message) @@ -1141,22 +1207,8 @@ class TransferJobManagerTest(unittest.TestCase): media_chain_cls.return_value.recognize_by_meta.return_value = None chain._TransferChain__handle_transfer(task) - self.assertEqual(1, len(notifications)) - notification = notifications[0] - self.assertIn("/redo 77\n", notification.text) - self.assertIn("/redo 77 [media_source]|[media_id]|[类型]", notification.text) - self.assertEqual( - [ - [ - {"text": "重试", "callback_data": "transfer_retry_77"}, - { - "text": "智能助手接管", - "callback_data": "transfer_ai_retry_77", - }, - ] - ], - notification.buttons, - ) + self.assertEqual([], notifications) + chain.durable_event_writer.transfer_result.assert_not_called() def test_do_transfer_syncs_same_stem_extra_files_by_default(self): chain = make_transfer_chain() @@ -1594,6 +1646,7 @@ class TransferJobManagerTest(unittest.TestCase): ) as storage_chain_cls: storage_chain_cls.return_value.is_bluray_folder.return_value = False for task, transferinfo in zip(tasks, transferinfos): + bind_terminal_checkpoint(task, transferinfo) chain._TransferChain__default_callback(task, transferinfo) chain._finish_scrape_batch_task(task) @@ -1649,6 +1702,7 @@ class TransferJobManagerTest(unittest.TestCase): need_scrape=True, need_notify=False, ) + bind_terminal_checkpoint(task, transferinfo) with patch( "app.chain.transfer.get_chain_transfer_history_port", return_value=SimpleNamespace() diff --git a/tests/test_transfer_lease_migration.py b/tests/test_transfer_lease_migration.py index 9f23c82e2..cbbd3f308 100644 --- a/tests/test_transfer_lease_migration.py +++ b/tests/test_transfer_lease_migration.py @@ -185,6 +185,33 @@ def test_transfer_lease_upgrade_downgrade_reupgrade(monkeypatch) -> None: engine.dispose() +def test_replayed_upgrade_repairs_named_lease_index(monkeypatch) -> None: + """同名但列和唯一性错误的租约索引必须精确重建。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + _create_planning_table(connection) + migration = _bind_migration(monkeypatch, connection) + migration.upgrade() + connection.execute(sa.text( + "DROP INDEX ix_transferpending_recovery_lease" + )) + connection.execute(sa.text( + "CREATE UNIQUE INDEX ix_transferpending_recovery_lease " + "ON transferpending (task_id)" + )) + + migration.upgrade() + + index = next( + item for item in sa.inspect(connection).get_indexes("transferpending") + if item["name"] == "ix_transferpending_recovery_lease" + ) + assert index["column_names"] == [ + "state", "lease_expires_at", "created_at", "id", + ] + assert index["unique"] == 0 + + def test_partial_transfer_lease_upgrade_preserves_existing_owner( monkeypatch, ) -> None: diff --git a/tests/test_transfer_lease_persistence.py b/tests/test_transfer_lease_persistence.py index b63c99a38..23e2966f7 100644 --- a/tests/test_transfer_lease_persistence.py +++ b/tests/test_transfer_lease_persistence.py @@ -20,6 +20,7 @@ from app.application.transfer.workflow import ( TransferProviderReference, ) from app.db.adapters.transfer.admission import TransactionalTransferAdmissionRepository +from app.db.models.transferexecutionstep import TransferExecutionStep from app.db.models.transferhistory import TransferHistory from app.db.models.transferpending import TransferPending from app.db.oper.transferpending import TransferPendingOper @@ -93,6 +94,7 @@ def repository_factory(tmp_path): ) TransferHistory.__table__.create(engine) TransferPending.__table__.create(engine) + TransferExecutionStep.__table__.create(engine) factory = sessionmaker(bind=engine) yield lambda: TransactionalTransferAdmissionRepository(factory) engine.dispose() @@ -207,7 +209,7 @@ def test_claim_heartbeat_expired_takeover_and_stale_token_guards( lease_token=first.lease_token, error="expired worker", ) is False - assert repository.discard_claimed( + assert repository.abandon_unstarted( task_id=admitted.task_id, lease_token=first.lease_token, ) == 0 @@ -231,7 +233,7 @@ def test_claim_heartbeat_expired_takeover_and_stale_token_guards( lease_token=first.lease_token, error="stale worker", ) is False - assert repository.discard_claimed( + assert repository.abandon_unstarted( task_id=admitted.task_id, lease_token=first.lease_token, ) == 0 @@ -257,7 +259,7 @@ def test_claim_heartbeat_expired_takeover_and_stale_token_guards( ) assert third is not None assert third.attempt_count == 3 - assert repository.discard_claimed( + assert repository.abandon_unstarted( task_id=admitted.task_id, lease_token=third.lease_token, ) == 1 diff --git a/tests/test_transfer_legacy_terminal_compat.py b/tests/test_transfer_legacy_terminal_compat.py index 8a0c3488f..389f64267 100644 --- a/tests/test_transfer_legacy_terminal_compat.py +++ b/tests/test_transfer_legacy_terminal_compat.py @@ -76,9 +76,9 @@ def _compat_chain(result_factory): task.bind_execution_checkpoint(TransferExecutionCheckpoint.create( payload={ "outcome": ( - "succeeded" - if result.success - else "failed" + "overwrite_skipped" + if result.overwrite_skipped + else "succeeded" if result.success else "failed" ), "transferinfo": result.model_dump(mode="json"), }, @@ -192,6 +192,34 @@ def test_legacy_settlement_response_loss_replays_receipt_by_same_task_id() -> No assert second["settlement"] == first["settlement"] +def test_legacy_settlement_double_failure_releases_claim_without_deleting_evidence( +) -> None: + """两次 writer 均失败时必须释放 lease,并保留 pending 与步骤供恢复。""" + chain, executed = _compat_chain(lambda task: _result(task, success=True)) + chain._transfer_admissions = Mock() + chain._transfer_admissions.release_claim.return_value = True + chain._TransferChain__ensure_recovery_scheduler = Mock() + chain.durable_event_writer.transfer_result.side_effect = RuntimeError( + "writer unavailable" + ) + + returned = _invoke(chain, _fileitem()) + + assert returned.success is False + assert "writer unavailable" in (returned.message or "") + assert executed == ["source-v1"] + assert chain.durable_event_writer.transfer_result.call_count == 2 + chain._transfer_admissions.release_claim.assert_called_once_with( + task_id="task-source-v1", + lease_token="lease-source-v1", + error=( + "旧整理兼容命令 durable 终态结算失败:writer unavailable" + ), + ) + chain._transfer_admissions.abandon_unstarted.assert_not_called() + assert chain._owned_leases == {} + + def test_legacy_overwrite_skip_binds_existing_success_in_atomic_writer() -> None: """覆盖跳过复用既有成功历史并以 succeeded 终态结算。""" chain, executed = _compat_chain( diff --git a/tests/test_transfer_manual_review_discovery.py b/tests/test_transfer_manual_review_discovery.py index 00a722ff3..f6f33eae7 100644 --- a/tests/test_transfer_manual_review_discovery.py +++ b/tests/test_transfer_manual_review_discovery.py @@ -15,6 +15,11 @@ from app.application.transfer.execution import ( TransferStepIntent, TransferStepResult, ) +from app.application.transfer.workflow import ( + TransferPlanCheckpoint, + TransferPlanItem, + TransferPlanningInput, +) from app.db.adapters.transfer.execution import ( TransactionalTransferExecutionRepository, ) @@ -58,19 +63,44 @@ def _put_in_manual_review(factory, *, task_id: str) -> tuple[ str, ]: """建立一个外部结果 UNKNOWN 且已释放租约的人工复核任务。""" + source_path = f"/downloads/{task_id}.mkv" + target_path = f"/library/{task_id}.mkv" + planning_input = TransferPlanningInput( + source_fileitem={ + "storage": "local", + "path": source_path, + "type": "file", + }, + target_storage="local", + target_path=target_path, + requested_transfer_type="copy", + ) + checkpoint = TransferPlanCheckpoint( + planning_input=planning_input, + target_storage="local", + root_target_path="/library", + final_target_path=target_path, + resolved_transfer_type="copy", + items=(TransferPlanItem( + sequence=0, + source_fileitem=planning_input.source_fileitem, + target_storage="local", + target_path=target_path, + ),), + ) with factory() as session: session.add(TransferPending( task_id=task_id, storage="local", - src_path=f"/downloads/{task_id}.mkv", + src_path=source_path, created_at="2026-08-27 09:00:00", state="planned", updated_at="2026-08-27 09:00:00", input_version=1, - planning_input={"schema_version": 1, "source": task_id}, - input_fingerprint=f"input-{task_id}", + planning_input=planning_input.to_payload(), + input_fingerprint=planning_input.fingerprint, checkpoint_version=1, - checkpoint_payload={"schema_version": 1, "task_id": task_id}, + checkpoint_payload=checkpoint.to_payload(), planned_at="2026-08-27 09:00:00", lease_owner="worker-secret", lease_token=f"lease-{task_id}", @@ -90,13 +120,15 @@ def _put_in_manual_review(factory, *, task_id: str) -> tuple[ ) intent = TransferStepIntent.create( task_id=task_id, - checkpoint_fingerprint=f"checkpoint-{task_id}", + checkpoint_fingerprint=checkpoint.fingerprint, ordinal=0, phase="transfer", kind="materialize_target", payload={ - "source": f"/downloads/{task_id}.mkv", - "target": f"/library/{task_id}.mkv", + "source": planning_input.source_fileitem, + "target_storage": "local", + "target_path": target_path, + "transfer_type": "copy", }, ) prepared = command.prepare( @@ -189,7 +221,10 @@ def test_unknown_manual_review_is_discoverable_and_resumes_via_api( } assert discovered.step.operation_id == operation_id assert discovered.step.kind == "materialize_target" - assert discovered.step.intent["target"] == f"/library/task-{decision}.mkv" + assert ( + discovered.step.intent["target_path"] + == f"/library/task-{decision}.mkv" + ) assert discovered.step.evidence == { "observation": "unknown", "target_exists": True, diff --git a/tests/test_transfer_overwrite_declined.py b/tests/test_transfer_overwrite_declined.py index 9f3a28aa0..2e8a622ce 100644 --- a/tests/test_transfer_overwrite_declined.py +++ b/tests/test_transfer_overwrite_declined.py @@ -15,11 +15,12 @@ from app.application.transfer.execution import ( TransferExecutionCheckpoint, TransferSettlementResult, ) -from app.chain.transfer import TransferChain +from app.chain.transfer import TransferChain, _DurableTransferStepRunner from app.schemas.transfer import TransferInfo from app.schemas.types import EventType from tests.test_transfer_job_manager import ( FakeMedia, + bind_terminal_checkpoint, make_fileitem, make_task, make_transfer_chain, @@ -130,16 +131,19 @@ def test_overwrite_declined_uses_successful_durable_settlement(): task = make_task(1) task.bind_admission_task_id("task-overwrite-declined") task.bind_execution_lease(owner_id="worker", lease_token="lease") - task.bind_execution_checkpoint(TransferExecutionCheckpoint.create( - payload={"outcome": "overwrite_skipped"}, - operation_ids=(), - skip_reason="overwrite_declined", - )) transferinfo = TransferInfo( success=False, overwrite_skipped=True, message="目标已存在,按覆盖策略跳过覆盖", ) + task.bind_execution_checkpoint(TransferExecutionCheckpoint.create( + payload={ + "outcome": "overwrite_skipped", + "transferinfo": transferinfo.model_dump(mode="json"), + }, + operation_ids=(), + skip_reason="overwrite_declined", + )) settlement = TransferChain._TransferChain__build_transfer_result_settlement( task, @@ -152,6 +156,27 @@ def test_overwrite_declined_uses_successful_durable_settlement(): assert settlement.error is None +def test_durable_step_runner_records_overwrite_skip_as_explicit_outcome(): + """生产 runner 必须冻结覆盖跳过事实,不能提前把它记成普通失败。""" + runner = object.__new__(_DurableTransferStepRunner) + runner._task_id = "task-overwrite-skipped" + runner._lease_token = "lease" + runner._operation_ids = [] + runner._command = MagicMock() + runner._command.checkpoint.side_effect = lambda **kwargs: SimpleNamespace( + checkpoint=kwargs["checkpoint"] + ) + transferinfo = TransferInfo( + success=False, + overwrite_skipped=True, + message="目标已存在,按覆盖策略跳过覆盖", + ) + + checkpoint = runner.checkpoint(transferinfo) + + assert checkpoint.payload["outcome"] == "overwrite_skipped" + + def test_overwrite_skip_without_success_history_uses_failed_settlement(): """未核实既有成功历史时,覆盖跳过标志不能伪造成功终态。""" task = make_task(1) @@ -216,6 +241,7 @@ def test_default_callback_skips_history_and_notification_when_overwrite_declined overwrite_skipped=True, need_notify=False, ) + bind_terminal_checkpoint(task, transferinfo) with patch( "app.chain.transfer.get_chain_transfer_history_port", @@ -262,6 +288,7 @@ def test_default_callback_keeps_original_failure_semantics_without_success_histo overwrite_skipped=True, need_notify=False, ) + bind_terminal_checkpoint(task, transferinfo) with patch( "app.chain.transfer.get_chain_transfer_history_port", @@ -312,7 +339,6 @@ def test_durable_callback_settles_overwrite_skip_without_history_as_failed(): overwrite_skipped=True, need_notify=False, ) - def durable_transfer_result(**kwargs): """执行失败历史暂存并返回 task-aware 结算回执。""" history = kwargs["stage_history"](SimpleNamespace()) @@ -363,6 +389,7 @@ def test_default_callback_delegates_primary_failure_to_durable_writer(): transfer_type="copy", need_notify=False, ) + bind_terminal_checkpoint(task, transferinfo) def durable_transfer_result(**kwargs): """执行 writer 收到的历史暂存与提交后发布回调。""" @@ -371,7 +398,11 @@ def test_default_callback_delegates_primary_failure_to_durable_writer(): payload["transfer_history_id"] = history.id payload["idempotency_key"] = f"transfer.failed:{history.id}:v1" kwargs["publish"](payload) - return history + return TransferSettlementResult( + history_id=history.id, + settlement_revision=1, + pending_deleted=True, + ) chain.durable_event_writer.transfer_result.side_effect = durable_transfer_result with patch( diff --git a/tests/test_transfer_pending_initial_migration.py b/tests/test_transfer_pending_initial_migration.py new file mode 100644 index 000000000..d93e1b464 --- /dev/null +++ b/tests/test_transfer_pending_initial_migration.py @@ -0,0 +1,97 @@ +"""整理待处理表 3.0.4 初始迁移的中断恢复测试。""" + +import importlib + +import pytest +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations + +MIGRATION = "database.versions.e3d9f4b7c806_3_0_4" + + +def _bind_migration(monkeypatch, connection): + """把 3.0.4 迁移绑定到隔离 SQLite 连接。""" + migration = importlib.import_module(MIGRATION) + monkeypatch.setattr( + migration, + "op", + Operations(MigrationContext.configure(connection)), + ) + return migration + + +def test_upgrade_repairs_missing_and_malformed_identity_index(monkeypatch) -> None: + """建表后中断或同名错误索引都必须收敛为精确唯一索引。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + connection.execute(sa.text( + "CREATE TABLE transferpending (" + "id INTEGER PRIMARY KEY, storage VARCHAR NOT NULL, " + "src_path VARCHAR NOT NULL, created_at VARCHAR)" + )) + migration = _bind_migration(monkeypatch, connection) + migration.upgrade() + connection.execute(sa.text( + "DROP INDEX ux_transferpending_storage_path" + )) + connection.execute(sa.text( + "CREATE INDEX ux_transferpending_storage_path " + "ON transferpending (src_path)" + )) + + migration.upgrade() + + index = sa.inspect(connection).get_indexes("transferpending")[0] + assert index["name"] == "ux_transferpending_storage_path" + assert index["column_names"] == ["storage", "src_path"] + assert index["unique"] == 1 + + +def test_upgrade_recreates_empty_partial_table(monkeypatch) -> None: + """中断留下的空残表可以无损重建为完整 3.0.4 结构。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + connection.execute(sa.text( + "CREATE TABLE transferpending (id INTEGER PRIMARY KEY)" + )) + migration = _bind_migration(monkeypatch, connection) + + migration.upgrade() + + assert { + column["name"] + for column in sa.inspect(connection).get_columns("transferpending") + } == {"id", "storage", "src_path", "created_at"} + + +def test_upgrade_rejects_nonempty_partial_table(monkeypatch) -> None: + """含数据残表无法可靠推断源身份时必须显式拒绝迁移。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + connection.execute(sa.text( + "CREATE TABLE transferpending (id INTEGER PRIMARY KEY)" + )) + connection.execute(sa.text( + "INSERT INTO transferpending (id) VALUES (1)" + )) + migration = _bind_migration(monkeypatch, connection) + + with pytest.raises(RuntimeError, match="含数据的不完整 transferpending"): + migration.upgrade() + + +def test_downgrade_tolerates_interrupted_missing_index(monkeypatch) -> None: + """索引创建前中断时降级仍应安全删除残留表。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + connection.execute(sa.text( + "CREATE TABLE transferpending (" + "id INTEGER PRIMARY KEY, storage VARCHAR NOT NULL, " + "src_path VARCHAR NOT NULL, created_at VARCHAR)" + )) + migration = _bind_migration(monkeypatch, connection) + + migration.downgrade() + + assert "transferpending" not in sa.inspect(connection).get_table_names() diff --git a/tests/test_transfer_pending_replay.py b/tests/test_transfer_pending_replay.py index 1b55e50ef..1341d04b7 100644 --- a/tests/test_transfer_pending_replay.py +++ b/tests/test_transfer_pending_replay.py @@ -11,9 +11,20 @@ import threading import time from dataclasses import replace from pathlib import Path +from types import SimpleNamespace from unittest.mock import MagicMock -from app.application.transfer.workflow import TransferAdmission, TransferPlanningInput, TransferTask +import pytest + +from app.application.transfer.execution import ( + TransferExecutionSnapshot, + TransferExecutionState, +) +from app.application.transfer.workflow import ( + TransferAdmission, + TransferPlanningInput, + TransferTask, +) from app.chain.transfer import TransferChain from app.schemas.file import FileItem @@ -26,6 +37,10 @@ def _build_chain(admissions) -> TransferChain: """ chain = object.__new__(TransferChain) chain._transfer_admissions = admissions + chain._transfer_executions = MagicMock() + chain._transfer_executions.get_snapshot.side_effect = ( + lambda *, task_id: _execution_snapshot(task_id=task_id) + ) chain._worker_owner_id = "test-owner" chain._owned_leases = {} chain._queued_lease_tokens = set() @@ -40,8 +55,34 @@ def _build_chain(admissions) -> TransferChain: return chain +def _execution_snapshot( + *, + task_id: str = "task-1", + state: TransferExecutionState = TransferExecutionState.NOT_STARTED, + steps: tuple[object, ...] = (), +) -> TransferExecutionSnapshot: + """构造回放判定所需的最小执行状态投影。""" + return TransferExecutionSnapshot( + task_id=task_id, + state=state, + checkpoint=None, + retry_generation=0, + retry_count=0, + retry_due_at=None, + settlement_revision=0, + terminal_history_id=None, + last_error=None, + steps=steps, + ) + + def _admission(path: str, task_id: str = "task-1") -> TransferAdmission: """构造一条可脱离数据库会话使用的准入快照。""" + planning_input = TransferPlanningInput( + source_fileitem=_task(path).fileitem.model_dump(mode="json"), + meta=None, + mediainfo=None, + ) return TransferAdmission( task_id=task_id, storage="local", @@ -49,6 +90,7 @@ def _admission(path: str, task_id: str = "task-1") -> TransferAdmission: state="accepted", created_at="2026-08-27 10:00:00", updated_at="2026-08-27 10:00:00", + planning_input=planning_input, lease_owner="test-owner", lease_token=f"lease-{task_id}", lease_expires_at="2026-08-27 10:02:00.000000", @@ -97,12 +139,12 @@ def test_admit_transfer_records_storage_and_path(): assert result.task_id == "task-1" -def test_discard_pending_on_terminal_state(): - """ - 整理到达终态后必须注销登记,否则每次重启都会重复回放。 - """ +def test_terminal_without_settlement_releases_claim_and_keeps_pending(): + """缺少原子终态回执时必须释放租约并保留 pending 供恢复。""" admissions = MagicMock() + admissions.release_claim.return_value = True chain = _build_chain(admissions) + chain.jobview = MagicMock() task = _task("/mnt/cd2/downloads/Movie.2024.mkv") task.bind_admission_task_id("task-1") task.bind_execution_lease(owner_id="test-owner", lease_token="lease-task-1") @@ -110,14 +152,18 @@ def test_discard_pending_on_terminal_state(): chain._owned_leases = { "task-1": ("lease-task-1", time.monotonic() + 120) } - admissions.discard_claimed.return_value = 1 + assert chain._TransferChain__finish_job_execution( + task, + terminal=True, + terminal_settlement=None, + ) is False - assert chain._TransferChain__discard_pending(task) is True - - admissions.discard_claimed.assert_called_once_with( + admissions.release_claim.assert_called_once_with( task_id="task-1", lease_token="lease-task-1", + error="整理终态未完成 durable 原子结算", ) + admissions.abandon_unstarted.assert_not_called() def test_replay_resends_pending_files_to_transfer(tmp_path, monkeypatch): @@ -156,17 +202,104 @@ def test_replay_discards_vanished_files(tmp_path): admissions = MagicMock() missing = tmp_path / "gone.mkv" admissions.claim_recoverable.return_value = [_admission(str(missing))] - admissions.discard_claimed.return_value = 1 + admissions.abandon_unstarted.return_value = 1 chain = _build_chain(admissions) chain._execute_transfer = MagicMock() chain._TransferChain__replay_pending() chain._execute_transfer.assert_not_called() - admissions.discard_claimed.assert_called_once_with( + admissions.abandon_unstarted.assert_called_once_with( task_id="task-1", lease_token="lease-task-1", ) + admissions.release_claim.assert_not_called() + assert chain._owned_leases == {} + + +def test_replay_releases_claim_when_vanished_source_abandon_is_rejected( + tmp_path, +) -> None: + """注销 CAS 被执行证据拒绝时必须释放 claim,不能留下无人续期的毒任务。""" + admissions = MagicMock() + missing = tmp_path / "state-changed.mkv" + admission = _admission(str(missing)) + admissions.claim_recoverable.return_value = [admission] + admissions.abandon_unstarted.return_value = 0 + admissions.release_claim.return_value = True + chain = _build_chain(admissions) + chain._execute_transfer = MagicMock() + + chain._TransferChain__replay_pending() + + admissions.abandon_unstarted.assert_called_once_with( + task_id="task-1", + lease_token="lease-task-1", + ) + admissions.release_claim.assert_called_once_with( + task_id="task-1", + lease_token="lease-task-1", + error="源已消失但任务状态已变化,保留登记供恢复", + ) + assert chain._owned_leases == {} + + +@pytest.mark.parametrize( + ("execution_state", "steps"), + [ + (TransferExecutionState.NOT_STARTED, ()), + (TransferExecutionState.RUNNING, ()), + (TransferExecutionState.RETRY_WAIT, ()), + (TransferExecutionState.NOT_STARTED, (SimpleNamespace(),)), + ], +) +def test_replay_with_execution_evidence_uses_frozen_source_when_source_vanished( + tmp_path, + monkeypatch, + execution_state, + steps, +) -> None: + """已有执行状态或步骤证据时不得用源消失推断任务可删除。""" + missing = tmp_path / "already-moved.mkv" + planning_input = TransferPlanningInput( + source_fileitem=_task(str(missing)).fileitem.model_dump(mode="json"), + meta=None, + mediainfo=None, + requested_transfer_type="move", + ) + admission = replace( + _admission(str(missing)), + state="planned", + planning_input=planning_input, + checkpoint=MagicMock(), + ) + admissions = MagicMock() + admissions.claim_recoverable.return_value = [admission] + chain = _build_chain(admissions) + chain._transfer_executions.get_snapshot.return_value = _execution_snapshot( + state=execution_state, + steps=steps, + ) + chain._transfer_executions.get_snapshot.side_effect = None + queue_planned = MagicMock(return_value=True) + monkeypatch.setattr( + chain, + "_TransferChain__queue_planned_replay", + queue_planned, + ) + + def reject_stat(*_args, **_kwargs): + """冻结恢复触碰源文件即判定测试失败。""" + pytest.fail("已有执行证据的恢复不得探测已经消失的源文件") + + monkeypatch.setattr(Path, "stat", reject_stat) + + chain._TransferChain__replay_pending() + + queued_fileitem = queue_planned.call_args.args[0] + assert queued_fileitem.path == str(missing) + admissions.abandon_unstarted.assert_not_called() + admissions.release_claim.assert_not_called() def test_replay_keeps_registration_when_mount_unreadable(tmp_path, monkeypatch): @@ -195,7 +328,7 @@ def test_replay_keeps_registration_when_mount_unreadable(tmp_path, monkeypatch): chain._TransferChain__replay_pending() chain._execute_transfer.assert_not_called() - admissions.discard_claimed.assert_not_called() + admissions.abandon_unstarted.assert_not_called() admissions.release_claim.assert_called_once_with( task_id="task-1", lease_token="lease-task-1", @@ -316,7 +449,7 @@ def test_replay_stop_keeps_unprocessed_registrations(tmp_path, monkeypatch): chain._TransferChain__replay_pending(stop_event) assert transferred == [first.as_posix()] - admissions.discard_claimed.assert_not_called() + admissions.abandon_unstarted.assert_not_called() assert admissions.release_claim.call_count == 2 diff --git a/tests/test_transfer_planning_checkpoint.py b/tests/test_transfer_planning_checkpoint.py index c25873d3c..ed95ec5d6 100644 --- a/tests/test_transfer_planning_checkpoint.py +++ b/tests/test_transfer_planning_checkpoint.py @@ -15,7 +15,11 @@ from sqlalchemy.orm import sessionmaker from app.application.transfer import workflow as transfer_application from app.application.transfer.execution import ( TransferExecutionCheckpoint, + TransferExecutionSnapshot, + TransferExecutionState, + TransferExecutionStep, TransferSettlementResult, + TransferStepState, ) from app.application.transfer.workflow import TransferTask from app.chain.transfer import TransferChain @@ -27,7 +31,6 @@ from app.domain.meta.metabase import MetaBase from app.modules.filemanager.module import FileManagerModule from app.modules.filemanager.transhandler import TransHandler from app.runtime.extensions.module.dispatcher import ( - FrozenModuleProviderMissingError, ModuleInvocationDispatcher, ) from app.schemas.exception import StorageQueryError @@ -204,10 +207,136 @@ def _planned_admission(task: TransferTask, checkpoint): ) +class _ExecutionRepositoryStub: + """为规划编排测试提供严格但内存化的 execution repository。""" + + def __init__(self) -> None: + """初始化空步骤集合与未启动执行态。""" + self.steps = {} + self.state = TransferExecutionState.NOT_STARTED + self.checkpoint = None + + def get_snapshot(self, *, task_id): + """返回当前任务的类型化执行投影。""" + return TransferExecutionSnapshot( + task_id=task_id, + state=self.state, + checkpoint=self.checkpoint, + retry_generation=0, + retry_count=0, + retry_due_at=None, + settlement_revision=0, + terminal_history_id=None, + last_error=None, + steps=tuple(self.steps.values()), + ) + + def prepare_step(self, *, task_id, lease_token, intent): + """幂等保存准备态步骤。""" + del lease_token + existing = self.steps.get(intent.operation_id) + if existing is not None: + return existing + step = TransferExecutionStep( + task_id=task_id, + operation_id=intent.operation_id, + checkpoint_fingerprint=intent.checkpoint_fingerprint, + ordinal=intent.ordinal, + phase=intent.phase, + kind=intent.kind, + state=TransferStepState.PREPARED, + attempt_token=None, + attempt_count=0, + intent=intent, + result=None, + last_error=None, + prepared_at="2026-08-27 10:00:00", + started_at=None, + completed_at=None, + updated_at="2026-08-27 10:00:00", + ) + self.steps[intent.operation_id] = step + return step + + def start_step( + self, + *, + task_id, + lease_token, + operation_id, + attempt_token, + ): + """把准备态步骤推进到已开始。""" + del task_id, lease_token + step = replace( + self.steps[operation_id], + state=TransferStepState.STARTED, + attempt_token=attempt_token, + attempt_count=1, + started_at="2026-08-27 10:00:01", + ) + self.steps[operation_id] = step + self.state = TransferExecutionState.RUNNING + return step + + def complete_step( + self, + *, + task_id, + lease_token, + operation_id, + attempt_token, + result, + ): + """以当前 attempt 提交成功证据。""" + del task_id, lease_token + assert self.steps[operation_id].attempt_token == attempt_token + step = replace( + self.steps[operation_id], + state=TransferStepState.SUCCEEDED, + result=result, + completed_at="2026-08-27 10:00:02", + ) + self.steps[operation_id] = step + return step + + def checkpoint_execution(self, *, task_id, lease_token, checkpoint): + """保存可重放终态的聚合检查点。""" + del lease_token + self.state = TransferExecutionState.SETTLING + self.checkpoint = checkpoint + return self.get_snapshot(task_id=task_id) + + def mark_manual_review( + self, + *, + task_id, + lease_token, + operation_id, + attempt_token, + error, + evidence, + ): + """把执行结果不确定的步骤隔离到人工复核态。""" + del lease_token + step = self.steps[operation_id] + assert step.attempt_token == attempt_token + self.steps[operation_id] = replace( + step, + state=TransferStepState.MANUAL_REVIEW, + result=evidence, + last_error=error, + ) + self.state = TransferExecutionState.MANUAL_REVIEW + return self.get_snapshot(task_id=task_id) + + def _chain(*, repository=None, checkpoint=None, result=None) -> TransferChain: """构造只保留规划编排依赖的 TransferChain 骨架。""" chain = object.__new__(TransferChain) chain._transfer_admissions = repository or Mock() + chain._transfer_executions = _ExecutionRepositoryStub() + chain.durable_event_writer = Mock() chain._worker_owner_id = "planning-owner" chain._owned_leases = {} chain._queued_lease_tokens = set() @@ -225,6 +354,7 @@ def _chain(*, repository=None, checkpoint=None, result=None) -> TransferChain: state="accepted", created_at="2026-08-27 10:00:00", updated_at="2026-08-27 10:00:00", + planning_input=_planning_input(), lease_owner=kwargs["owner_id"], lease_token=f"lease-{kwargs['task_id']}", lease_expires_at="2026-08-27 10:02:00.000000", @@ -245,6 +375,14 @@ def _chain(*, repository=None, checkpoint=None, result=None) -> TransferChain: transfer_type="copy", ) ) + + def run_module(method, *args, **kwargs): + """让规划测试沿正式模块入口调用其可观察的宿主执行替身。""" + assert method == "execute_transfer_plan" + checkpoint_arg = kwargs.pop("checkpoint") + return chain.execute_transfer_plan(checkpoint_arg, *args, **kwargs) + + chain.run_module = Mock(side_effect=run_module) return chain @@ -252,6 +390,21 @@ def _replay_chain(repository) -> TransferChain: """构造绑定固定恢复 owner 且不启动真实 heartbeat 线程的测试链。""" chain = object.__new__(TransferChain) chain._transfer_admissions = repository + chain._transfer_executions = Mock() + chain._transfer_executions.get_snapshot.side_effect = ( + lambda *, task_id: TransferExecutionSnapshot( + task_id=task_id, + state=TransferExecutionState.NOT_STARTED, + checkpoint=None, + retry_generation=0, + retry_count=0, + retry_due_at=None, + settlement_revision=0, + terminal_history_id=None, + last_error=None, + steps=(), + ) + ) chain._worker_owner_id = "replay-owner" chain._owned_leases = {} chain._queued_lease_tokens = set() @@ -275,6 +428,43 @@ def _real_dispatcher(plugins: dict) -> ModuleInvocationDispatcher: ) +def test_non_preview_missing_durable_writer_stops_before_planning_or_execution(): + """缺少原子 writer 时,持久任务取得租约后也不得开始任何外部流程。""" + task = _task() + task.bind_admission_task_id("task-missing-writer") + _bind_planning_input(task, _planning_input()) + chain = _chain() + chain.durable_event_writer = None + + with pytest.raises(RuntimeError, match="缺少 durable 原子写入端口"): + chain._plan_checkpoint_and_execute(task) + + chain._module_dispatcher.freeze_plugin_providers.assert_not_called() + chain.plan_transfer.assert_not_called() + chain.execute_transfer_plan.assert_not_called() + + +def test_non_preview_missing_execution_repository_stops_before_side_effects(): + """缺少 execution repository 时不得调用 provider 或文件执行器。""" + task = _task() + task.bind_admission_task_id("task-missing-execution-repository") + _bind_planning_input(task, _planning_input()) + _bind_checkpoint(task, _checkpoint()) + chain = _chain() + chain._transfer_executions = None + chain._TransferChain__restore_planned_task = Mock() + + with pytest.raises(RuntimeError, match="缺少 execution repository"): + chain._plan_checkpoint_and_execute( + task, + source_oper=object(), + target_oper=object(), + ) + + chain._module_dispatcher.execute_frozen_plugin_providers.assert_not_called() + chain.execute_transfer_plan.assert_not_called() + + def test_legacy_provider_runs_only_after_checkpoint_commit_and_short_circuits_host(): """旧插件 provider 必须随计划冻结,并在 CAS 提交后才能接管执行。""" calls = [] @@ -314,7 +504,7 @@ def test_legacy_provider_runs_only_after_checkpoint_commit_and_short_circuits_ho returned = chain._plan_checkpoint_and_execute(task) - assert returned is plugin_result + assert returned == plugin_result assert calls == ["checkpoint", "plugin"] chain.plan_transfer.assert_not_called() chain.execute_transfer_plan.assert_not_called() @@ -548,8 +738,8 @@ def test_missing_frozen_provider_keeps_pending_and_skips_cleanup() -> None: chain._transfer_storage_chain = Mock(return_value=storage_chain) with pytest.raises( - FrozenModuleProviderMissingError, - match=r"ProviderTwo/插件二\.transfer", + RuntimeError, + match=r"禁止自动重放.*ProviderTwo/插件二\.transfer", ): chain._plan_checkpoint_and_execute(task) @@ -824,7 +1014,7 @@ def test_provider_pending_crash_replay_executes_snapshot_without_host_planning() returned = recovered_chain._plan_checkpoint_and_execute(recovered_task) - assert returned is recovered_result + assert returned == recovered_result recovered_chain._module_dispatcher.freeze_plugin_providers.assert_not_called() recovered_chain.plan_transfer.assert_not_called() recovered_chain._transfer_admissions.checkpoint_plan.assert_not_called() @@ -905,7 +1095,7 @@ def test_legacy_transfer_command_uses_durable_pipeline_and_settles_pending(): assert returned is result assert calls == ["admit", "checkpoint", "execute", "settle"] - repository.discard_claimed.assert_not_called() + repository.abandon_unstarted.assert_not_called() writer_call = chain.durable_event_writer.transfer_result.call_args.kwargs assert writer_call["topic"] is None assert writer_call["publish"] is None @@ -1561,31 +1751,22 @@ def test_filemanager_resolves_drifted_target_from_checkpoint(monkeypatch): def test_pre_checkpoint_recognition_failure_records_retryable_error(monkeypatch): - """准入后、checkpoint 前的业务失败必须写 last_error 并保持 accepted。""" + """未识别拒绝必须先建立 plan/execution checkpoint,且不提前写终态副作用。""" task = _task() task.meta = MetaBase("Unrecognized.Movie.2026.mkv") task.bind_admission_task_id("task-before-checkpoint") - task.bind_execution_lease( - owner_id="recognition-owner", - lease_token="lease-task-before-checkpoint", - ) - chain = object.__new__(TransferChain) - chain._transfer_admissions = Mock() + chain = _chain() chain._worker_owner_id = "recognition-owner" - chain._owned_leases = { - "task-before-checkpoint": ( - "lease-task-before-checkpoint", - float("inf"), - ) - } - chain._worker_state_lock = threading.RLock() chain.jobview = Mock() chain.queue_failed_transfer_notification = Mock() chain.runtime_config = SimpleNamespace( - ai_agent_enable=False, - ai_agent_retry_transfer=False, + ai_agent_enable=True, + ai_agent_retry_transfer=True, ) chain._TransferChain__mark_torrent_completed_if_done = Mock() + chain._transfer_admissions.checkpoint_plan.side_effect = ( + lambda **kwargs: _planned_admission(task, kwargs["checkpoint"]) + ) media_chain = Mock() media_chain.recognize_by_meta.return_value = None monkeypatch.setattr("app.chain.transfer.MediaChain", lambda: media_chain) @@ -1593,17 +1774,91 @@ def test_pre_checkpoint_recognition_failure_records_retryable_error(monkeypatch) "app.chain.transfer.get_chain_transfer_history_port", lambda: SimpleNamespace(), ) - monkeypatch.setattr("app.chain.transfer.record_transfer_failure", Mock()) - monkeypatch.setattr("app.chain.transfer.add_transfer_fail", lambda **_kwargs: None) + record_transfer_failure = Mock() + add_transfer_fail = Mock() + monkeypatch.setattr( + "app.chain.transfer.record_transfer_failure", + record_transfer_failure, + ) + monkeypatch.setattr("app.chain.transfer.add_transfer_fail", add_transfer_fail) result = chain._TransferChain__handle_transfer(task) assert result == (False, "未识别到媒体信息") - chain._transfer_admissions.record_planning_failure.assert_called_once_with( - task_id="task-before-checkpoint", - lease_token="lease-task-before-checkpoint", - error="未识别到媒体信息", + 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 + assert task.execution_checkpoint.payload["outcome"] == "failed" + assert [step.kind for step in chain._transfer_executions.steps.values()] == [ + "reject" + ] + chain._transfer_admissions.record_planning_failure.assert_not_called() + record_transfer_failure.assert_not_called() + add_transfer_fail.assert_not_called() + chain.queue_failed_transfer_notification.assert_not_called() + chain._TransferChain__mark_torrent_completed_if_done.assert_not_called() + + +def test_recognition_rejection_without_writer_has_zero_terminal_side_effects( + monkeypatch, +) -> None: + """缺 writer 时未识别拒绝不得提交计划、历史、通知或 AI 重试。""" + task = _task() + task.meta = MetaBase("Unrecognized.Movie.2026.mkv") + task.bind_admission_task_id("task-rejection-missing-writer") + chain = _chain() + chain.durable_event_writer = None + chain.jobview = Mock() + chain.queue_failed_transfer_notification = Mock() + chain._TransferChain__mark_torrent_completed_if_done = Mock() + chain.runtime_config = SimpleNamespace( + ai_agent_enable=True, + ai_agent_retry_transfer=True, ) + media_chain = Mock() + media_chain.recognize_by_meta.return_value = None + monkeypatch.setattr("app.chain.transfer.MediaChain", lambda: media_chain) + monkeypatch.setattr( + "app.chain.transfer.get_chain_transfer_history_port", + lambda: SimpleNamespace(), + ) + record_transfer_failure = Mock() + add_transfer_fail = Mock() + monkeypatch.setattr( + "app.chain.transfer.record_transfer_failure", + record_transfer_failure, + ) + monkeypatch.setattr("app.chain.transfer.add_transfer_fail", add_transfer_fail) + + with pytest.raises(RuntimeError, match="缺少 durable 原子写入端口"): + chain._TransferChain__handle_transfer(task) + + assert task.plan_checkpoint is None + assert task.execution_checkpoint is None + chain._transfer_admissions.checkpoint_plan.assert_not_called() + record_transfer_failure.assert_not_called() + add_transfer_fail.assert_not_called() + chain.queue_failed_transfer_notification.assert_not_called() + chain._TransferChain__mark_torrent_completed_if_done.assert_not_called() + + +def test_planning_rejection_checkpoint_round_trips_and_rejects_file_steps(): + """拒绝原因必须稳定序列化,且不能与真实文件步骤同时存在。""" + checkpoint = replace( + _checkpoint(), + items=(), + rejection_error="未识别到媒体信息", + ) + + restored = transfer_application.TransferPlanCheckpoint.from_payload( + checkpoint.to_payload() + ) + + assert restored == checkpoint + assert restored.rejection_error == "未识别到媒体信息" + with pytest.raises(ValueError, match="不得包含文件步骤"): + replace(checkpoint, items=_checkpoint().items) def test_preview_plans_without_persistence_or_file_side_effects(): diff --git a/tests/test_transfer_planning_migration.py b/tests/test_transfer_planning_migration.py index 3997db772..88255c0da 100644 --- a/tests/test_transfer_planning_migration.py +++ b/tests/test_transfer_planning_migration.py @@ -132,10 +132,11 @@ def _assert_upgrade_downgrade_reupgrade(connection, monkeypatch) -> None: if isinstance(planning_payload, str): planning_payload = json.loads(planning_payload) planning_input = TransferPlanningInput.from_payload(planning_payload) - assert planning_input == TransferPlanningInput.legacy( - storage="local", - src_path="/downloads/Movie.mkv", - ) + assert planning_input.source_fileitem == { + "storage": "local", + "path": "/downloads/Movie.mkv", + } + assert planning_input.options == {"legacy_replan": True} assert upgraded["input_version"] == 1 assert upgraded["input_fingerprint"] == planning_input.fingerprint assert upgraded["checkpoint_payload"] is None @@ -282,6 +283,58 @@ def test_partial_upgrade_preserves_existing_planning_json(monkeypatch) -> None: )).scalar_one() == "future-state" +def test_partial_upgrade_recomputes_inconsistent_planning_identity(monkeypatch) -> None: + """部分升级留下的版本和指纹必须按最终 JSON 重算,不能保留伪身份。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + _create_admission_table(connection) + connection.execute(sa.text( + "ALTER TABLE transferpending ADD COLUMN input_version INTEGER" + )) + connection.execute(sa.text( + "ALTER TABLE transferpending ADD COLUMN input_fingerprint VARCHAR(64)" + )) + connection.execute(sa.text( + "UPDATE transferpending SET input_version = 99, " + "input_fingerprint = 'bogus' WHERE id = 1" + )) + + migration = _bind_migration(monkeypatch, connection) + migration.upgrade() + upgraded = _planning_row(connection) + payload = upgraded["planning_input"] + if isinstance(payload, str): + payload = json.loads(payload) + planning_input = TransferPlanningInput.from_payload(payload) + + assert upgraded["input_version"] == planning_input.schema_version == 1 + assert upgraded["input_fingerprint"] == planning_input.fingerprint + assert upgraded["input_fingerprint"] != "bogus" + + +def test_replayed_upgrade_repairs_complete_but_mismatched_identity(monkeypatch) -> None: + """重复执行升级也必须修复完整三元组中与 payload 不一致的旧值。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + _create_admission_table(connection) + migration = _bind_migration(monkeypatch, connection) + migration.upgrade() + connection.execute(sa.text( + "UPDATE transferpending SET input_version = 7, " + "input_fingerprint = 'stale' WHERE id = 1" + )) + + migration.upgrade() + upgraded = _planning_row(connection) + payload = upgraded["planning_input"] + if isinstance(payload, str): + payload = json.loads(payload) + planning_input = TransferPlanningInput.from_payload(payload) + + assert upgraded["input_version"] == 1 + assert upgraded["input_fingerprint"] == planning_input.fingerprint + + def test_transfer_planning_migration_runs_on_postgresql(monkeypatch) -> None: """配置隔离 PostgreSQL 时真实验证规划字段的完整可逆迁移。""" prefix = "MOVIEPILOT_TEST_POSTGRESQL_" diff --git a/tests/test_transfer_planning_persistence.py b/tests/test_transfer_planning_persistence.py index 8bdecb4da..5c09f9d8d 100644 --- a/tests/test_transfer_planning_persistence.py +++ b/tests/test_transfer_planning_persistence.py @@ -4,6 +4,7 @@ from dataclasses import replace import pytest from sqlalchemy import create_engine, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import sessionmaker from app.application.transfer.workflow import ( @@ -545,8 +546,10 @@ def test_checkpoint_rejects_missing_task(repository) -> None: ) -def test_direct_orm_defaults_create_valid_legacy_projection(tmp_path) -> None: - """兼容直接构造 ORM 行时也必须生成匹配路径的版本化输入与指纹。""" +def test_canonical_admission_requires_explicit_versioned_planning_input( + tmp_path, +) -> None: + """直接 ORM 写入不得伪造默认输入,canonical 仓储必须显式保存完整快照。""" engine = create_engine(f"sqlite:///{tmp_path / 'orm-defaults.db'}") factory = sessionmaker(bind=engine) TransferPending.__table__.create(engine) @@ -559,21 +562,27 @@ def test_direct_orm_defaults_create_valid_legacy_projection(tmp_path) -> None: updated_at="2026-08-27 10:00:00", ) session.add(pending) - session.commit() - task_id = pending.task_id + with pytest.raises(IntegrityError): + session.commit() + session.rollback() repository = TransactionalTransferAdmissionRepository(factory) - admitted = repository.claim_task( - task_id=task_id, - owner_id="legacy-projection-worker", - lease_seconds=3600, + planning_input = replace( + _planning_input(), + source_fileitem={ + "storage": "local", + "path": "/downloads/legacy.mkv", + "type": "file", + }, + ) + admitted = repository.admit( + storage="local", + src_path="/downloads/legacy.mkv", + planning_input=planning_input, ) assert admitted is not None - assert admitted.planning_input == TransferPlanningInput.legacy( - storage="local", - src_path="/downloads/legacy.mkv", - ) + assert admitted.planning_input == planning_input assert admitted.input_fingerprint == admitted.planning_input.fingerprint engine.dispose() diff --git a/tests/test_transfer_queue_service.py b/tests/test_transfer_queue_service.py index 0c9fdbcfe..057625b85 100644 --- a/tests/test_transfer_queue_service.py +++ b/tests/test_transfer_queue_service.py @@ -5,7 +5,11 @@ import pytest from sqlalchemy import create_engine, select from sqlalchemy.orm import sessionmaker -from app.application.transfer.workflow import TransferAdmission, TransferQueueService +from app.application.transfer.workflow import ( + TransferAdmission, + TransferPlanningInput, + TransferQueueService, +) from app.db.adapters.transfer.admission import TransactionalTransferAdmissionRepository from app.db.models.transferhistory import TransferHistory from app.db.models.transferpending import TransferPending @@ -13,6 +17,20 @@ from app.schemas.file import FileItem from tests.test_transfer_job_manager import make_task, make_transfer_chain +def _planning_input(path: str = "/tmp/demo.mkv") -> TransferPlanningInput: + """构造队列准入测试要求的显式版本化输入。""" + return TransferPlanningInput( + source_fileitem={ + "storage": "local", + "path": path, + "type": "file", + "name": path.rsplit("/", 1)[-1], + }, + meta=None, + mediainfo=None, + ) + + def _service(**overrides): """构造可观测整理队列服务及其默认依赖。""" dependencies = { @@ -24,6 +42,7 @@ def _service(**overrides): state="accepted", created_at="2026-08-27 10:00:00", updated_at="2026-08-27 10:00:00", + planning_input=_planning_input(), )), "enqueue": Mock(), "before_enqueue": Mock(), @@ -48,6 +67,7 @@ def test_transfer_queue_service_put_preserves_registration_order(): state="accepted", created_at="2026-08-27 10:00:00", updated_at="2026-08-27 10:00:00", + planning_input=_planning_input(), ), before_enqueue=lambda _task: calls.append("batch"), enqueue=lambda _item: calls.append("queue"), @@ -123,10 +143,12 @@ def test_transfer_queue_service_commits_admission_before_failed_enqueue(tmp_path factory = sessionmaker(bind=engine) repository = TransactionalTransferAdmissionRepository(factory) task = make_task(1) + task.bind_planning_input(_planning_input(task.fileitem.path)) service, _ = _service( admit_task=lambda item: repository.admit( storage=item.fileitem.storage, src_path=item.fileitem.path, + planning_input=item.planning_input, ), enqueue=Mock(side_effect=RuntimeError("queue closed")), enqueue_failed=lambda item, error: repository.record_enqueue_failure( diff --git a/tests/test_transfer_reconciliation_migration.py b/tests/test_transfer_reconciliation_migration.py new file mode 100644 index 000000000..813402403 --- /dev/null +++ b/tests/test_transfer_reconciliation_migration.py @@ -0,0 +1,796 @@ +"""整理状态 3.0.17 数据收口与完整迁移链测试。""" + +import hashlib +import importlib +import json +import os +import uuid +from datetime import datetime, timezone + +import pytest +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy.orm import sessionmaker + +from app.application.transfer.execution import ( + TransferExecutionCommand, + TransferExecutionState, + TransferManualReviewDecision, + TransferManualReviewQuery, + TransferStepResult, +) +from app.application.transfer.workflow import ( + TransferPlanCheckpoint, + TransferPlanItem, + TransferPlanningInput, + TransferProviderInvocationSnapshot, + TransferProviderReference, +) +from app.db.adapters.transfer.admission import ( + TransactionalTransferAdmissionRepository, +) +from app.db.adapters.transfer.execution import ( + TransactionalTransferExecutionRepository, +) + +try: + import psycopg2 as postgres_driver + from psycopg2 import sql + + POSTGRESQL_DIALECT = "postgresql+psycopg2" +except ModuleNotFoundError: + import psycopg as postgres_driver + from psycopg import sql + + POSTGRESQL_DIALECT = "postgresql+psycopg" + +INITIAL_MIGRATION = "database.versions.e3d9f4b7c806_3_0_4" +ADMISSION_MIGRATION = "database.versions.b1e7d3f5a9c2_3_0_13" +PLANNING_MIGRATION = "database.versions.c2f8a4d6e1b3_3_0_14" +LEASE_MIGRATION = "database.versions.d3a9e5f7b2c4_3_0_15" +EXECUTION_MIGRATION = "database.versions.e5c7a9b1d3f6_3_0_16" +RECONCILIATION_MIGRATION = "database.versions.f6d8b0c2e4a7_3_0_17" + + +def _bind_migration(monkeypatch, connection, module_name: str): + """把指定整理迁移绑定到隔离数据库连接。""" + migration = importlib.import_module(module_name) + monkeypatch.setattr( + migration, + "op", + Operations(MigrationContext.configure(connection)), + ) + return migration + + +def _canonical_fingerprint(payload: dict[str, object]) -> str: + """按运行时规范计算测试检查点指纹。""" + canonical = json.dumps( + payload, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _create_3_0_15_tables(connection) -> sa.Table: + """创建执行迁移前的 pending 与最小 history 表。""" + metadata = sa.MetaData() + pending = sa.Table( + "transferpending", + metadata, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("task_id", sa.String(64), nullable=False), + sa.Column("storage", sa.String(), nullable=False), + sa.Column("src_path", sa.String(), nullable=False), + sa.Column("created_at", sa.String()), + sa.Column("state", sa.String(32), nullable=False), + sa.Column("updated_at", sa.String(40), nullable=False), + sa.Column("last_error", sa.Text()), + sa.Column("input_version", sa.Integer(), nullable=False), + sa.Column("planning_input", sa.JSON(), nullable=False), + sa.Column("input_fingerprint", sa.String(64), nullable=False), + sa.Column("checkpoint_version", sa.Integer()), + sa.Column("checkpoint_payload", sa.JSON()), + sa.Column("planned_at", sa.String(40)), + sa.Column("lease_owner", sa.String(128)), + sa.Column("lease_token", sa.String(64)), + sa.Column("lease_expires_at", sa.String(40)), + sa.Column("heartbeat_at", sa.String(40)), + sa.Column("attempt_count", sa.Integer(), nullable=False), + sa.UniqueConstraint("task_id", name="uq_transferpending_task_id"), + ) + sa.Table( + "transferhistory", + metadata, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("src", sa.String()), + sa.Column("src_storage", sa.String(), nullable=False), + sa.Column("status", sa.Boolean()), + ) + metadata.create_all(connection) + return pending + + +def _seed_execution_rows(connection, pending: sa.Table) -> None: + """写入待收口的非法状态以及应保持不变的合法状态。""" + base = { + "storage": "local", + "created_at": "2026-08-27 10:00:00", + "updated_at": "2026-08-27 10:00:00", + "last_error": None, + "state": "accepted", + "input_version": 1, + "planning_input": { + "schema_version": 1, + "source_fileitem": {"storage": "local", "path": "/source"}, + }, + "input_fingerprint": "input", + "checkpoint_version": None, + "checkpoint_payload": None, + "planned_at": None, + "lease_owner": None, + "lease_token": None, + "lease_expires_at": None, + "heartbeat_at": None, + "attempt_count": 0, + } + task_ids = ( + "unknown", + "settling-missing", + "failed-missing", + "retry-missing-due", + "partial-checkpoint", + "completed", + "manual-lease", + "settling-valid", + "failed-valid", + "accepted-checkpoint", + "rejection-checkpoint", + "invalid-outcome", + "invalid-overwrite", + ) + connection.execute(pending.insert(), [ + { + **base, + "id": index, + "task_id": task_id, + "src_path": f"/{task_id}", + } + for index, task_id in enumerate(task_ids, start=1) + ]) + + +def _execution_checkpoint() -> tuple[dict[str, object], str]: + """构造合法且可由运行时恢复的零副作用执行检查点。""" + payload = { + "schema_version": 1, + "payload": {"outcome": "succeeded", "preview": True}, + "operation_ids": [], + "skip_reason": "preview", + } + return payload, _canonical_fingerprint(payload) + + +def test_upgrade_reconciles_invalid_execution_combinations(monkeypatch) -> None: + """非法执行组合必须留证转人工态,合法结算与失败终态不得被破坏。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + pending = _create_3_0_15_tables(connection) + _seed_execution_rows(connection, pending) + execution = _bind_migration(monkeypatch, connection, EXECUTION_MIGRATION) + execution.upgrade() + payload, fingerprint = _execution_checkpoint() + connection.execute(sa.text( + "UPDATE transferpending SET execution_state = 'future' " + "WHERE task_id = 'unknown'" + )) + connection.execute(sa.text( + "UPDATE transferpending SET execution_state = 'settling' " + "WHERE task_id = 'settling-missing'" + )) + connection.execute(sa.text( + "UPDATE transferpending SET execution_state = 'failed' " + "WHERE task_id = 'failed-missing'" + )) + connection.execute(sa.text( + "UPDATE transferpending SET execution_state = 'retry_wait', " + "retry_due_at = NULL WHERE task_id = 'retry-missing-due'" + )) + connection.execute(sa.text( + "UPDATE transferpending SET execution_state = 'running', " + "execution_version = 1 WHERE task_id = 'partial-checkpoint'" + )) + connection.execute(sa.text( + "UPDATE transferpending SET execution_state = 'completed' " + "WHERE task_id = 'completed'" + )) + connection.execute(sa.text( + "UPDATE transferpending SET execution_state = 'manual_review', " + "lease_owner = 'old-worker', lease_token = 'old-lease', " + "lease_expires_at = '2099-01-01 00:00:00.000000', " + "heartbeat_at = '2026-08-27 10:00:00.000000' " + "WHERE task_id = 'manual-lease'" + )) + current = sa.table( + "transferpending", + sa.column("task_id", sa.String(64)), + sa.column("execution_state", sa.String(32)), + sa.column("state", sa.String(32)), + sa.column("input_version", sa.Integer()), + sa.column("planning_input", sa.JSON()), + sa.column("input_fingerprint", sa.String(64)), + sa.column("checkpoint_version", sa.Integer()), + sa.column("checkpoint_payload", sa.JSON()), + sa.column("planned_at", sa.String(40)), + sa.column("execution_version", sa.Integer()), + sa.column("execution_payload", sa.JSON()), + sa.column("execution_fingerprint", sa.String(64)), + sa.column("settlement_revision", sa.Integer()), + sa.column("terminal_history_id", sa.Integer()), + sa.column("lease_owner", sa.String(128)), + sa.column("lease_token", sa.String(64)), + sa.column("lease_expires_at", sa.String(40)), + ) + host_input = _planning_input("/settling-valid") + host_checkpoint = _host_checkpoint(host_input) + failed_input = _planning_input("/failed-valid") + failed_checkpoint = _host_checkpoint(failed_input) + accepted_input = _planning_input("/accepted-checkpoint") + accepted_checkpoint = _host_checkpoint(accepted_input) + rejection_input = _planning_input("/rejection-checkpoint") + rejection_checkpoint = TransferPlanCheckpoint( + planning_input=rejection_input, + target_storage="local", + root_target_path="/library", + final_target_path="/library/Movies", + resolved_transfer_type="copy", + items=(), + rejection_error="未识别到媒体信息", + ) + invalid_outcome_input = _planning_input("/invalid-outcome") + invalid_outcome_checkpoint = _host_checkpoint(invalid_outcome_input) + invalid_overwrite_input = _planning_input("/invalid-overwrite") + invalid_overwrite_checkpoint = _host_checkpoint(invalid_overwrite_input) + for task_id, planning_input, checkpoint, state in ( + ( + "settling-valid", + host_input, + host_checkpoint, + "planned", + ), + ( + "failed-valid", + failed_input, + failed_checkpoint, + "planned", + ), + ( + "accepted-checkpoint", + accepted_input, + accepted_checkpoint, + "accepted", + ), + ( + "rejection-checkpoint", + rejection_input, + rejection_checkpoint, + "accepted", + ), + ( + "invalid-outcome", + invalid_outcome_input, + invalid_outcome_checkpoint, + "planned", + ), + ( + "invalid-overwrite", + invalid_overwrite_input, + invalid_overwrite_checkpoint, + "planned", + ), + ): + connection.execute( + current.update() + .where(current.c.task_id == task_id) + .values( + state=state, + input_version=1, + planning_input=planning_input.to_payload(), + input_fingerprint=planning_input.fingerprint, + checkpoint_version=1, + checkpoint_payload=checkpoint.to_payload(), + planned_at="2026-08-27 10:30:00", + ) + ) + connection.execute( + current.update() + .where(current.c.task_id == "settling-valid") + .values( + execution_state="settling", + execution_version=1, + execution_payload=payload, + execution_fingerprint=fingerprint, + lease_owner="active-worker", + lease_token="active-lease", + lease_expires_at="2099-01-01 00:00:00.000000", + ) + ) + invalid_outcome_payload = { + **payload, + "payload": {"outcome": "future", "preview": True}, + } + invalid_overwrite_payload = { + **payload, + "payload": {"outcome": "overwrite_skipped", "preview": True}, + } + for task_id, invalid_payload in ( + ("invalid-outcome", invalid_outcome_payload), + ("invalid-overwrite", invalid_overwrite_payload), + ): + connection.execute( + current.update() + .where(current.c.task_id == task_id) + .values( + execution_state="settling", + execution_version=1, + execution_payload=invalid_payload, + execution_fingerprint=_canonical_fingerprint(invalid_payload), + ) + ) + connection.execute(sa.text( + "INSERT INTO transferhistory (id, src, src_storage, status) " + "VALUES (42, '/failed-valid', 'local', 0)" + )) + connection.execute( + current.update() + .where(current.c.task_id == "failed-valid") + .values( + execution_state="failed", + execution_version=1, + execution_payload=payload, + execution_fingerprint=fingerprint, + settlement_revision=1, + terminal_history_id=42, + ) + ) + connection.execute(sa.text( + "INSERT INTO transfersettlementreceipt (" + "task_id, history_id, settlement_revision, outcome, " + "execution_fingerprint, lease_token, history_status, src, src_storage, " + "pending_deleted, error, created_at, updated_at" + ") VALUES (" + "'failed-valid', 42, 1, 'failed', :fingerprint, 'failed-lease', 0, " + "'/failed-valid', 'local', 0, 'failed', " + "'2026-08-27 11:00:00', '2026-08-27 11:00:00'" + ")" + ), {"fingerprint": fingerprint}) + + reconciliation = _bind_migration( + monkeypatch, + connection, + RECONCILIATION_MIGRATION, + ) + reconciliation.upgrade() + reconciliation.upgrade() + + rows = { + row["task_id"]: dict(row) + for row in connection.execute(sa.text( + "SELECT task_id, execution_state, execution_version, " + "execution_payload, execution_fingerprint, lease_owner, lease_token " + "FROM transferpending" + )).mappings() + } + invalid = { + "unknown", + "settling-missing", + "failed-missing", + "retry-missing-due", + "partial-checkpoint", + "completed", + "invalid-outcome", + "invalid-overwrite", + } + assert {rows[task_id]["execution_state"] for task_id in invalid} == { + "manual_review" + } + assert all( + rows[task_id]["execution_version"] is None + and rows[task_id]["execution_payload"] is None + and rows[task_id]["execution_fingerprint"] is None + for task_id in invalid + ) + assert rows["manual-lease"]["execution_state"] == "manual_review" + assert rows["manual-lease"]["lease_owner"] is None + assert rows["manual-lease"]["lease_token"] is None + assert rows["settling-valid"]["execution_state"] == "settling" + assert rows["settling-valid"]["lease_token"] == "active-lease" + assert rows["failed-valid"]["execution_state"] == "failed" + planning_states = dict(connection.execute(sa.text( + "SELECT task_id, state FROM transferpending " + "WHERE task_id IN ('accepted-checkpoint', 'rejection-checkpoint')" + )).all()) + assert planning_states == { + "accepted-checkpoint": "planned", + "rejection-checkpoint": "planned", + } + assert connection.execute(sa.text( + "SELECT COUNT(*) FROM transferexecutionstep " + "WHERE kind = 'legacy_execution_review' " + "AND task_id IN ('unknown', 'settling-missing', 'failed-missing', " + "'retry-missing-due', 'partial-checkpoint', 'completed', " + "'invalid-outcome', 'invalid-overwrite')" + )).scalar_one() == len(invalid) + factory = sessionmaker(bind=engine, expire_on_commit=False) + reviews = TransferManualReviewQuery( + TransactionalTransferExecutionRepository(factory) + ).list(page=1, page_size=20) + assert reviews.total == len(invalid) + 1 + assert {item.task_id for item in reviews.items} == invalid | {"manual-lease"} + engine.dispose() + + +def test_reconciliation_migration_runs_on_postgresql(monkeypatch) -> None: + """配置隔离 PostgreSQL 时真实验证人工态归一、租约清理与可逆 DDL。""" + prefix = "MOVIEPILOT_TEST_POSTGRESQL_" + host = os.getenv(f"{prefix}HOST") + database = os.getenv(f"{prefix}DATABASE") + username = os.getenv(f"{prefix}USERNAME") + if not host or not database or not username: + pytest.skip("未配置隔离 PostgreSQL migration 测试库") + + port = os.getenv(f"{prefix}PORT", "5432") + password = os.getenv(f"{prefix}PASSWORD", "") + schema = f"transfer_reconciliation_{uuid.uuid4().hex}" + with postgres_driver.connect( + host=host, + port=port, + dbname=database, + user=username, + password=password, + ) as connection: + connection.autocommit = True + with connection.cursor() as cursor: + cursor.execute(sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema))) + + engine = None + try: + engine = sa.create_engine( + sa.URL.create( + POSTGRESQL_DIALECT, + username=username, + password=password, + host=host, + port=int(port), + database=database, + ), + connect_args={"options": f"-csearch_path={schema}"}, + ) + with engine.begin() as connection: + pending = _create_3_0_15_tables(connection) + _seed_execution_rows(connection, pending) + execution = _bind_migration( + monkeypatch, + connection, + EXECUTION_MIGRATION, + ) + reconciliation = _bind_migration( + monkeypatch, + connection, + RECONCILIATION_MIGRATION, + ) + execution.upgrade() + connection.execute(sa.text( + "UPDATE transferpending SET state = 'manual_review', " + "execution_state = 'manual_review', lease_owner = 'old-worker', " + "lease_token = 'old-token', " + "lease_expires_at = '2099-01-01 00:00:00.000000' " + "WHERE task_id = 'manual-lease'" + )) + + reconciliation.upgrade() + + row = connection.execute(sa.text( + "SELECT state, execution_state, lease_owner, lease_token " + "FROM transferpending WHERE task_id = 'manual-lease'" + )).one() + assert row == ("accepted", "manual_review", None, None) + reconciliation.downgrade() + execution.downgrade() + assert "execution_state" not in { + column["name"] + for column in sa.inspect(connection).get_columns("transferpending") + } + finally: + if engine is not None: + engine.dispose() + with postgres_driver.connect( + host=host, + port=port, + dbname=database, + user=username, + password=password, + ) as connection: + connection.autocommit = True + with connection.cursor() as cursor: + cursor.execute( + sql.SQL("DROP SCHEMA IF EXISTS {} CASCADE").format( + sql.Identifier(schema) + ) + ) + + +def _planning_input(path: str) -> TransferPlanningInput: + """构造完整且可持久恢复的规划输入。""" + return TransferPlanningInput( + source_fileitem={"storage": "local", "path": path, "type": "file"}, + meta={"name": "Movie", "year": 2026}, + mediainfo={"title": "Movie", "tmdb_id": 42}, + target_directory={"storage": "local", "path": "/library"}, + target_storage="local", + target_path="/library/Movies", + requested_transfer_type="copy", + ) + + +def _host_checkpoint(planning_input: TransferPlanningInput) -> TransferPlanCheckpoint: + """构造完整宿主计划检查点。""" + return TransferPlanCheckpoint( + planning_input=planning_input, + target_storage="local", + root_target_path="/library", + final_target_path="/library/Movies/Movie.mkv", + resolved_transfer_type="copy", + items=(TransferPlanItem( + sequence=0, + source_fileitem=planning_input.source_fileitem, + target_storage="local", + target_path="/library/Movies/Movie.mkv", + ),), + ) + + +def _provider_checkpoint( + planning_input: TransferPlanningInput, +) -> TransferPlanCheckpoint: + """构造完整 provider 待执行检查点。""" + invocation = TransferProviderInvocationSnapshot( + fileitem=planning_input.source_fileitem, + meta=planning_input.meta, + meta_kind="MetaVideo", + mediainfo=planning_input.mediainfo, + mediainfo_kind="MediaInfo", + ) + return TransferPlanCheckpoint( + planning_input=planning_input, + target_storage="", + root_target_path="", + final_target_path="", + resolved_transfer_type="", + items=(), + resolved_meta=invocation.meta, + resolved_meta_kind=invocation.meta_kind, + resolved_mediainfo=invocation.mediainfo, + resolved_mediainfo_kind=invocation.mediainfo_kind, + legacy_transfer_providers=(TransferProviderReference( + plugin_id="provider-a", + plugin_name="Provider A", + ),), + provider_invocation=invocation, + ) + + +def _create_history_table(connection) -> None: + """创建迁移链所需的最小整理历史表。""" + connection.execute(sa.text( + "CREATE TABLE transferhistory (" + "id INTEGER PRIMARY KEY, src VARCHAR, " + "src_storage VARCHAR NOT NULL, status BOOLEAN)" + )) + + +def _run_upgrade_chain(monkeypatch, connection) -> list[object]: + """从 3.0.13 顺序升级到 3.0.17 并返回迁移模块。""" + migrations = [ + _bind_migration(monkeypatch, connection, module_name) + for module_name in ( + ADMISSION_MIGRATION, + PLANNING_MIGRATION, + LEASE_MIGRATION, + EXECUTION_MIGRATION, + RECONCILIATION_MIGRATION, + ) + ] + for migration in migrations: + migration.upgrade() + return migrations + + +def test_full_legacy_chain_projects_after_downgrade_and_reupgrade( + monkeypatch, +) -> None: + """旧登记经完整升级、人工判定、降级再升级后仍可被真实仓储投影。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + initial = _bind_migration(monkeypatch, connection, INITIAL_MIGRATION) + initial.upgrade() + _create_history_table(connection) + connection.execute(sa.text( + "INSERT INTO transferpending (id, storage, src_path, created_at) VALUES " + "(1, 'local', '/accepted.mkv', '2026-08-27 09:00:00'), " + "(2, 'local', '/planned.mkv', '2026-08-27 09:00:01'), " + "(3, 'local', '/provider.mkv', '2026-08-27 09:00:02')" + )) + admission = _bind_migration( + monkeypatch, + connection, + ADMISSION_MIGRATION, + ) + planning = _bind_migration( + monkeypatch, + connection, + PLANNING_MIGRATION, + ) + lease = _bind_migration(monkeypatch, connection, LEASE_MIGRATION) + execution = _bind_migration( + monkeypatch, + connection, + EXECUTION_MIGRATION, + ) + reconciliation = _bind_migration( + monkeypatch, + connection, + RECONCILIATION_MIGRATION, + ) + admission.upgrade() + planning.upgrade() + lease.upgrade() + rows = connection.execute(sa.text( + "SELECT id, task_id, src_path, planning_input FROM transferpending" + )).mappings().all() + by_path = {row["src_path"]: row for row in rows} + for path, checkpoint in ( + ( + "/planned.mkv", + _host_checkpoint(_planning_input("/planned.mkv")), + ), + ( + "/provider.mkv", + _provider_checkpoint(_planning_input("/provider.mkv")), + ), + ): + planning_input = checkpoint.planning_input + connection.execute(sa.text( + "UPDATE transferpending SET state = 'manual_review', " + "input_version = 1, planning_input = :planning_input, " + "input_fingerprint = :input_fingerprint, checkpoint_version = 1, " + "checkpoint_payload = :checkpoint_payload, " + "planned_at = '2026-08-27 09:30:00', " + "lease_owner = 'old-worker', lease_token = 'old-token', " + "lease_expires_at = '2099-01-01 00:00:00.000000' " + "WHERE id = :id" + ), { + "id": by_path[path]["id"], + "planning_input": json.dumps(planning_input.to_payload()), + "input_fingerprint": planning_input.fingerprint, + "checkpoint_payload": json.dumps(checkpoint.to_payload()), + }) + connection.execute(sa.text( + "UPDATE transferpending SET state = 'manual_review', " + "lease_owner = 'old-worker', lease_token = 'old-token', " + "lease_expires_at = '2099-01-01 00:00:00.000000' WHERE id = 1" + )) + execution.upgrade() + reconciliation.upgrade() + normalized = dict(connection.execute(sa.text( + "SELECT src_path, state FROM transferpending ORDER BY id" + )).all()) + assert normalized == { + "/accepted.mkv": "accepted", + "/planned.mkv": "planned", + "/provider.mkv": "provider_pending", + } + assert connection.execute(sa.text( + "SELECT COUNT(*) FROM transferpending WHERE lease_token IS NOT NULL" + )).scalar_one() == 0 + + factory = sessionmaker(bind=engine, expire_on_commit=False) + execution_repository = TransactionalTransferExecutionRepository( + factory, + local_clock=lambda: datetime(2026, 8, 27, 10, 0, 0), + lease_clock=lambda: datetime(2026, 8, 27, 2, 0, 0, tzinfo=timezone.utc), + ) + reviews = TransferManualReviewQuery(execution_repository).list( + page=1, + page_size=10, + ) + assert reviews.total == 3 + command = TransferExecutionCommand(execution_repository) + for review in reviews.items: + resolved = command.resolve_manual_review( + task_id=review.task_id, + operation_id=review.step.operation_id, + decision=TransferManualReviewDecision.NOT_APPLIED, + actor="migration-test", + reason="确认旧执行未发生", + result=TransferStepResult(payload={"confirmed": False}), + ) + assert resolved.state is TransferExecutionState.RETRY_WAIT + + admission_repository = TransactionalTransferAdmissionRepository(factory) + monkeypatch.setattr( + admission_repository, + "_now", + lambda: "2026-08-27 10:00:01", + ) + monkeypatch.setattr( + admission_repository, + "_lease_now", + lambda: datetime(2026, 8, 27, 2, 0, 1, tzinfo=timezone.utc), + ) + claimed_states = set() + for task_id in (row["task_id"] for row in rows): + claimed = admission_repository.claim_task( + task_id=task_id, + owner_id="migration-worker", + lease_seconds=60, + ) + assert claimed is not None + claimed_states.add(claimed.state) + assert claimed_states == {"accepted", "planned", "provider_pending"} + + with engine.begin() as connection: + for migration in ( + reconciliation, + execution, + lease, + planning, + admission, + ): + monkeypatch.setattr( + migration, + "op", + Operations(MigrationContext.configure(connection)), + ) + migration.downgrade() + assert { + column["name"] + for column in sa.inspect(connection).get_columns("transferpending") + } == {"id", "storage", "src_path", "created_at"} + migrations = _run_upgrade_chain(monkeypatch, connection) + assert connection.execute(sa.text( + "SELECT COUNT(*) FROM transferpending" + )).scalar_one() == 3 + assert connection.execute(sa.text( + "SELECT COUNT(*) FROM transferpending " + "WHERE state = 'accepted' AND execution_state = 'not_started' " + "AND lease_token IS NULL" + )).scalar_one() == 3 + assert all(migration is not None for migration in migrations) + + reupgraded_repository = TransactionalTransferAdmissionRepository(factory) + monkeypatch.setattr( + reupgraded_repository, + "_now", + lambda: "2026-08-27 10:01:01", + ) + monkeypatch.setattr( + reupgraded_repository, + "_lease_now", + lambda: datetime(2026, 8, 27, 2, 1, 1, tzinfo=timezone.utc), + ) + reupgraded = [ + reupgraded_repository.claim_task( + task_id=row["task_id"], + owner_id="reupgraded-worker", + lease_seconds=60, + ) + for row in rows + ] + assert all(item is not None for item in reupgraded) + assert {item.state for item in reupgraded if item is not None} == {"accepted"} + engine.dispose() diff --git a/tests/test_transfer_settling_recovery.py b/tests/test_transfer_settling_recovery.py index 33f7d219d..f4b79442c 100644 --- a/tests/test_transfer_settling_recovery.py +++ b/tests/test_transfer_settling_recovery.py @@ -9,21 +9,33 @@ import pytest from sqlalchemy import create_engine, select from sqlalchemy.orm import sessionmaker +from app.application.chain.events import TransferResultSettlement from app.application.transfer.execution import ( TransferExecutionCheckpoint, + TransferExecutionCommand, TransferExecutionSnapshot, TransferExecutionState, + TransferOperationObservation, + TransferOperationObservationState, + TransferStepIntent, + TransferStepResult, ) from app.application.transfer.workflow import ( TransferAdmission, TransferPlanCheckpoint, + TransferPlanItem, TransferPlanningInput, TransferTask, ) +from app.chain import transfer as transfer_chain_module from app.chain.transfer import TransferChain +from app.db.adapters.chain import TransactionalChainDurableEventWriter from app.db.adapters.transfer.admission import TransactionalTransferAdmissionRepository +from app.db.adapters.transfer.execution import TransactionalTransferExecutionRepository +from app.db.models.transferexecutionstep import TransferExecutionStep from app.db.models.transferhistory import TransferHistory from app.db.models.transferpending import TransferPending +from app.db.models.transfersettlementreceipt import TransferSettlementReceipt from app.schemas.file import FileItem from app.schemas.transfer import TransferInfo @@ -173,6 +185,8 @@ def admission_store(tmp_path): engine = create_engine(f"sqlite:///{tmp_path / 'settling-recovery.db'}") TransferHistory.__table__.create(engine) TransferPending.__table__.create(engine) + TransferExecutionStep.__table__.create(engine) + TransferSettlementReceipt.__table__.create(engine) factory = sessionmaker(bind=engine, expire_on_commit=False) try: yield TransactionalTransferAdmissionRepository(factory), factory @@ -202,6 +216,7 @@ def _build_chain(admissions) -> TransferChain: """构造只允许执行 settling 终态恢复的 TransferChain 骨架。""" chain = object.__new__(TransferChain) chain._transfer_admissions = admissions + chain._transfer_executions = MagicMock() chain._worker_owner_id = "recovery-owner" chain._owned_leases = {} chain._queued_lease_tokens = set() @@ -251,6 +266,18 @@ def _recovered_task( admission.lease_token, time.monotonic() + 120, ) + chain._transfer_executions.get_snapshot.return_value = TransferExecutionSnapshot( + task_id=admission.task_id, + state=TransferExecutionState.SETTLING, + checkpoint=execution_checkpoint, + retry_generation=0, + retry_count=0, + retry_due_at=None, + settlement_revision=0, + terminal_history_id=None, + last_error=None, + steps=(), + ) return task @@ -381,7 +408,7 @@ def test_replay_settling_uses_frozen_source_without_filesystem_probe( queued_task = chain.put_to_queue.call_args.args[0] assert queued_task.fileitem.path == path assert queued_task.execution_checkpoint == execution_checkpoint - admissions.discard_claimed.assert_not_called() + admissions.abandon_unstarted.assert_not_called() admissions.release_claim.assert_not_called() chain._plan_checkpoint_and_execute.assert_not_called() @@ -454,3 +481,204 @@ def test_writer_failure_releases_and_reclaims_same_settling_checkpoint( assert second_admission.lease_token != first_admission.lease_token chain._TransferChain__select_storage_oper.assert_not_called() chain._plan_checkpoint_and_execute.assert_not_called() + + +def test_bound_checkpoint_is_not_restored_outside_settling() -> None: + """旧终态检查点留在 retry_wait 时不得跳过步骤恢复直接再次结算。""" + path = "/downloads/retry-state.mkv" + planning_input = _planning_input(path) + plan_checkpoint = _plan_checkpoint(planning_input) + execution_checkpoint = _execution_checkpoint(path, success=False) + admission = TransferAdmission( + task_id="retry-task", + storage="local", + src_path=path, + state="planned", + created_at="2026-08-27 09:00:00", + updated_at="2026-08-27 09:00:00", + planning_input=planning_input, + checkpoint=plan_checkpoint, + lease_owner="recovery-owner", + lease_token="retry-token", + ) + chain = _build_chain(MagicMock()) + task = _recovered_task(chain, admission, execution_checkpoint) + chain._transfer_executions.get_snapshot.return_value = TransferExecutionSnapshot( + task_id=admission.task_id, + state=TransferExecutionState.RETRY_WAIT, + checkpoint=execution_checkpoint, + retry_generation=1, + retry_count=1, + retry_due_at="2026-08-27 09:00:00.000000", + settlement_revision=1, + terminal_history_id=41, + last_error="copy failed", + steps=(), + ) + + restored = chain._TransferChain__restore_settling_transfer_result(task) + + assert restored is None + + +def test_failed_settlement_retry_replays_step_and_commits_new_receipt( + admission_store, +) -> None: + """失败结算请求重试后应恢复 FAILED 步骤,并以新检查点完成下一版结算。""" + admissions, factory = admission_store + path = "/downloads/retry-success.mkv" + planning_input = _planning_input(path) + plan_checkpoint = TransferPlanCheckpoint( + planning_input=planning_input, + target_storage="local", + root_target_path="/library", + final_target_path="/library/retry-success.mkv", + resolved_transfer_type="copy", + items=(TransferPlanItem( + sequence=0, + source_fileitem=planning_input.source_fileitem, + target_storage="local", + target_path="/library/retry-success.mkv", + ),), + need_notify=False, + ) + admitted = admissions.admit( + storage="local", + src_path=path, + planning_input=planning_input, + ) + first_claim = admissions.claim_task( + task_id=admitted.task_id, + owner_id="first-owner", + lease_seconds=120, + ) + assert first_claim is not None + assert first_claim.lease_token is not None + admissions.checkpoint_plan( + task_id=admitted.task_id, + lease_token=first_claim.lease_token, + input_fingerprint=planning_input.fingerprint, + checkpoint=plan_checkpoint, + ) + executions = TransactionalTransferExecutionRepository(factory) + command = TransferExecutionCommand( + executions, + attempt_token_factory=iter(("attempt-1", "attempt-2")).__next__, + ) + plan_fingerprint = ( + TransferChain._TransferChain__transfer_plan_fingerprint(plan_checkpoint) + ) + intent = TransferStepIntent.create( + task_id=admitted.task_id, + checkpoint_fingerprint=plan_fingerprint, + ordinal=0, + phase="transfer", + kind="copy", + payload={"source": path, "target": "/library/retry-success.mkv"}, + ) + prepared = command.prepare( + task_id=admitted.task_id, + lease_token=first_claim.lease_token, + intent=intent, + ) + started = command.begin( + task_id=admitted.task_id, + lease_token=first_claim.lease_token, + operation_id=prepared.operation_id, + ) + exhausted = command.exhaust( + task_id=admitted.task_id, + lease_token=first_claim.lease_token, + step=started, + error="copy failed", + evidence=TransferStepResult(payload={"target_exists": False}), + ) + assert exhausted.checkpoint is not None + writer = TransactionalChainDurableEventWriter(factory) + first_settlement = writer.transfer_result( + topic=None, + stage_history=lambda repository: repository.add_force( + src=path, + src_storage="local", + status=False, + errmsg="copy failed", + ), + event_payload={}, + publish=None, + settlement=TransferResultSettlement( + task_id=admitted.task_id, + lease_token=first_claim.lease_token, + execution_fingerprint=exhausted.checkpoint.fingerprint, + outcome="failed", + error="copy failed", + ), + ) + assert first_settlement is not None + retry = command.request_retry( + task_id=admitted.task_id, + reason="manual retry", + requested_by="test", + ) + assert retry.accepted is True + + chain = _build_chain(admissions) + chain._transfer_executions = executions + chain.put_to_queue = MagicMock(return_value=True) + chain._TransferChain__replay_pending() + + replayed_task = chain.put_to_queue.call_args.args[0] + assert replayed_task.execution_checkpoint is None + assert replayed_task.lease_token is not None + runner = transfer_chain_module._DurableTransferStepRunner( + task_id=admitted.task_id, + lease_token=replayed_task.lease_token, + checkpoint_fingerprint=plan_fingerprint, + repository=executions, + ) + resumed = [] + step_result = runner.run( + phase="transfer", + kind="copy", + payload={"source": path, "target": "/library/retry-success.mkv"}, + execute=lambda: resumed.append("executed") or TransferStepResult( + payload={"target_exists": True} + ), + observe=lambda: TransferOperationObservation( + state=TransferOperationObservationState.NOT_APPLIED, + evidence=TransferStepResult(payload={"target_exists": False}), + ), + ) + assert step_result.payload == {"target_exists": True} + assert resumed == ["executed"] + new_checkpoint = runner.checkpoint(_transfer_result(path, success=True)) + assert new_checkpoint.fingerprint != exhausted.checkpoint.fingerprint + second_settlement = writer.transfer_result( + topic=None, + stage_history=lambda repository: repository.add_force( + src=path, + src_storage="local", + status=True, + errmsg=None, + ), + event_payload={}, + publish=None, + settlement=TransferResultSettlement( + task_id=admitted.task_id, + lease_token=replayed_task.lease_token, + execution_fingerprint=new_checkpoint.fingerprint, + outcome="succeeded", + ), + ) + assert second_settlement is not None + + with factory() as session: + receipts = session.scalars( + select(TransferSettlementReceipt).order_by( + TransferSettlementReceipt.settlement_revision + ) + ).all() + pending = session.scalar(select(TransferPending)) + steps = session.scalars(select(TransferExecutionStep)).all() + assert [receipt.outcome for receipt in receipts] == ["failed", "succeeded"] + assert pending is None + assert steps == [] diff --git a/tests/test_transfer_tmdb_category.py b/tests/test_transfer_tmdb_category.py index 045caa7d4..0e811ffdc 100644 --- a/tests/test_transfer_tmdb_category.py +++ b/tests/test_transfer_tmdb_category.py @@ -1,5 +1,8 @@ +import threading from types import SimpleNamespace +from unittest.mock import Mock +from app.application.transfer.execution import TransferExecutionCheckpoint from app.application.transfer.workflow import TransferTask from app.chain.transfer import TransferChain from app.domain.context import MediaInfo @@ -76,6 +79,43 @@ def test_transfer_stops_when_automatic_category_has_no_tmdb_result(monkeypatch) """启用自动类别目录时,缺少 TMDB 分类必须在文件操作前明确失败。""" chain = object.__new__(TransferChain) chain.jobview = SimpleNamespace(try_remove_job=lambda _task: None) + chain._transfer_admissions = Mock() + chain._worker_owner_id = "category-owner" + chain._owned_leases = { + "task-before-category": ("lease-before-category", float("inf")) + } + chain._worker_state_lock = threading.RLock() + chain.durable_event_writer = Mock() + chain.runtime_config = SimpleNamespace( + scrape_follow_tmdb=True, + ai_agent_enable=True, + ai_agent_retry_transfer=True, + ) + chain.queue_failed_transfer_notification = Mock() + chain._TransferChain__mark_torrent_completed_if_done = Mock() + record_transfer_failure = Mock() + add_transfer_fail = Mock() + monkeypatch.setattr( + "app.chain.transfer.record_transfer_failure", + record_transfer_failure, + ) + monkeypatch.setattr("app.chain.transfer.add_transfer_fail", add_transfer_fail) + chain._transfer_admissions.checkpoint_plan.side_effect = ( + lambda **kwargs: SimpleNamespace(checkpoint=kwargs["checkpoint"]) + ) + step_runner = Mock() + step_runner.checkpoint.side_effect = lambda transferinfo: ( + TransferExecutionCheckpoint.create( + payload={ + "outcome": "failed", + "transferinfo": transferinfo.model_dump(mode="json"), + }, + operation_ids=("planning-reject",), + ) + ) + chain._TransferChain__build_durable_step_runner = Mock( + return_value=step_runner + ) monkeypatch.setattr( "app.chain.transfer.get_chain_transfer_history_port", lambda: SimpleNamespace(), @@ -114,7 +154,12 @@ def test_transfer_stops_when_automatic_category_has_no_tmdb_result(monkeypatch) library_category_folder=True, ), library_category_folder=True, - preview=True, + preview=False, + ) + task.bind_admission_task_id("task-before-category") + task.bind_execution_lease( + owner_id="category-owner", + lease_token="lease-before-category", ) state, message = chain._TransferChain__handle_transfer(task) @@ -123,3 +168,11 @@ def test_transfer_stops_when_automatic_category_has_no_tmdb_result(monkeypatch) assert message == "未识别到 TMDB 辅助信息,无法按媒体类别整理" assert task.mediainfo.media_source == MediaSource.AniList assert task.mediainfo.media_id == "1234" + assert task.plan_checkpoint is not None + assert task.plan_checkpoint.rejection_error == message + assert task.execution_checkpoint is not None + chain._transfer_admissions.record_planning_failure.assert_not_called() + record_transfer_failure.assert_not_called() + add_transfer_fail.assert_not_called() + chain.queue_failed_transfer_notification.assert_not_called() + chain._TransferChain__mark_torrent_completed_if_done.assert_not_called() diff --git a/tests/test_transfer_worker_lifecycle.py b/tests/test_transfer_worker_lifecycle.py index c89cb52a0..4df3169b7 100644 --- a/tests/test_transfer_worker_lifecycle.py +++ b/tests/test_transfer_worker_lifecycle.py @@ -4,13 +4,17 @@ import asyncio import queue import threading import time -from concurrent.futures import Future from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import pytest -from app.application.transfer.workflow import TransferAdmission, TransferQueue, TransferTask +from app.application.transfer.workflow import ( + TransferAdmission, + TransferPlanningInput, + TransferQueue, + TransferTask, +) from app.chain.transfer import TransferChain from app.foundation.singleton import Singleton from app.runtime.config import global_vars @@ -19,6 +23,15 @@ from app.schemas.transfer import TransferInfo from app.startup.initializers import transfer as transfer_initializer +def _planning_input(fileitem: FileItem) -> TransferPlanningInput: + """构造 worker 准入与 claim 投影使用的真实规划输入。""" + return TransferPlanningInput( + source_fileitem=fileitem.model_dump(mode="json"), + meta=None, + mediainfo=None, + ) + + def _build_chain(*, transfer_threads: int = 0) -> TransferChain: """构造只包含后台线程生命周期字段的 TransferChain 测试骨架。""" chain = object.__new__(TransferChain) @@ -51,6 +64,7 @@ def _build_chain(*, transfer_threads: int = 0) -> TransferChain: state="accepted", created_at="2026-08-27 10:00:00", updated_at="2026-08-27 10:00:00", + planning_input=kwargs["planning_input"], ) admissions.claim_task.side_effect = lambda **kwargs: TransferAdmission( task_id=kwargs["task_id"], @@ -59,13 +73,18 @@ def _build_chain(*, transfer_threads: int = 0) -> TransferChain: state="accepted", created_at="2026-08-27 10:00:00", updated_at="2026-08-27 10:00:00", + planning_input=_planning_input(FileItem( + storage="local", + path="/downloads/test.mkv", + type="file", + )), lease_owner=kwargs["owner_id"], lease_token=f"lease-{kwargs['task_id']}", lease_expires_at="2026-08-27 10:02:00.000000", heartbeat_at="2026-08-27 10:00:00.000000", attempt_count=1, ) - admissions.discard_claimed.return_value = 1 + admissions.abandon_unstarted.return_value = 1 admissions.release_claim.return_value = True chain._transfer_admissions = admissions chain._TransferChain__ensure_lease_heartbeat_owner = MagicMock() @@ -82,6 +101,7 @@ def _claimed_admission(task: TransferTask, task_id: str) -> TransferAdmission: state="accepted", created_at="2026-08-27 10:00:00", updated_at="2026-08-27 10:00:00", + planning_input=_planning_input(task.fileitem), lease_owner="worker-owner", lease_token=f"lease-{task_id}", lease_expires_at="2026-08-27 10:02:00.000000", @@ -224,33 +244,29 @@ def test_close_workers_lock_wait_uses_the_same_timeout_budget() -> None: assert chain.close_workers(timeout_seconds=1) is True -def test_close_keeps_timer_dependencies_when_workers_do_not_converge() -> None: - """活跃整理线程超时后,通知和重试 owner 必须继续供线程使用。""" +def test_close_keeps_failure_notification_when_workers_do_not_converge() -> None: + """活跃整理线程超时后,失败通知 owner 必须继续供线程使用。""" chain = _build_chain() chain.close_workers = MagicMock(return_value=False) chain.failure_notification_aggregator = MagicMock() - chain.retry_scheduler = MagicMock(close=AsyncMock()) completed = asyncio.run(chain.close(timeout_seconds=0.01)) assert completed is False chain.close_workers.assert_called_once_with(0.01) chain.failure_notification_aggregator.close.assert_not_called() - chain.retry_scheduler.close.assert_not_awaited() -def test_close_releases_timer_dependencies_after_workers_converge() -> None: - """worker 和回放退出后,整理链应继续刷新通知并关闭 AI 重试。""" +def test_close_releases_failure_notification_after_workers_converge() -> None: + """worker 和回放退出后,整理链应刷新并关闭失败通知 owner。""" chain = _build_chain() chain.close_workers = MagicMock(return_value=True) chain.failure_notification_aggregator = MagicMock() - chain.retry_scheduler = MagicMock(close=AsyncMock()) completed = asyncio.run(chain.close(timeout_seconds=0.01)) assert completed is True chain.failure_notification_aggregator.close.assert_called_once_with() - chain.retry_scheduler.close.assert_awaited_once_with() def test_stop_transfer_runtime_does_not_construct_chain(monkeypatch) -> None: @@ -329,49 +345,6 @@ def test_constructor_failure_publishes_started_worker_to_cleanup(monkeypatch) -> assert workers[0].is_alive() is False -def test_failed_retry_schedule_future_error_is_observed() -> None: - """跨线程调度协程的延迟异常必须被取回并写入日志。""" - future: Future[None] = Future() - future.set_exception(RuntimeError("scheduler closed")) - - with patch("app.chain.transfer.logger.error") as log_error: - TransferChain._observe_failed_retry_schedule(future) - - log_error.assert_called_once() - assert "scheduler closed" in log_error.call_args.args[0] - - -def test_failed_retry_schedule_registers_future_observer(monkeypatch) -> None: - """整理线程提交 AI 重试后应让 Future 持续连接到异常观察回调。""" - chain = _build_chain() - - async def schedule_retry(_history_id: int, *, group_key: str) -> None: - """提供不会实际执行的调度协程,供跨线程提交边界检查。""" - - chain.retry_scheduler = MagicMock(schedule_retry=schedule_retry) - future = MagicMock(spec=Future) - event_loop = MagicMock() - event_loop.is_running.return_value = True - event_loop.is_closed.return_value = False - monkeypatch.setattr(global_vars, "CURRENT_EVENT_LOOP", event_loop) - - def submit(coroutine, loop): - """关闭测试协程并返回可检查的并发 Future。""" - assert loop is event_loop - coroutine.close() - return future - - with patch( - "app.chain.transfer.asyncio.run_coroutine_threadsafe", - side_effect=submit, - ): - chain._schedule_failed_transfer_retry(42, "media:test") - - future.add_done_callback.assert_called_once() - callback = future.add_done_callback.call_args.args[0] - assert callback is TransferChain._observe_failed_retry_schedule - - def test_worker_requeues_item_taken_during_shutdown(monkeypatch) -> None: """停止信号与 queue.get 竞态时,未开始处理的任务必须原样放回队列。""" chain = _build_chain() @@ -465,8 +438,8 @@ def test_worker_settles_progress_when_only_stop_sentinel_remains(monkeypatch) -> assert list(chain._queue.queue) == [chain._QUEUE_STOP_SENTINEL] -def test_durable_task_identity_flows_from_queue_to_terminal_discard(monkeypatch) -> None: - """准入生成的稳定身份必须随队列任务到 worker 终态并准确注销。""" +def test_durable_task_identity_flows_to_unsettled_terminal_claim_release(monkeypatch) -> None: + """终态无原子回执时稳定身份必须用于释放 claim,pending 保持可恢复。""" chain = _build_chain() chain.runtime_config.transfer_task_timeout = 0 task = TransferTask(fileitem=FileItem( @@ -486,12 +459,13 @@ def test_durable_task_identity_flows_from_queue_to_terminal_discard(monkeypatch) state="accepted", created_at="2026-08-27 10:00:00", updated_at="2026-08-27 10:00:00", + planning_input=_planning_input(task.fileitem), ) admissions.claim_task.return_value = _claimed_admission( task, "durable-task-id", ) - admissions.discard_claimed.side_effect = ( + admissions.release_claim.side_effect = ( lambda **_kwargs: discarded.set() or 1 ) chain._transfer_admissions = admissions @@ -534,10 +508,12 @@ def test_durable_task_identity_flows_from_queue_to_terminal_discard(monkeypatch) owner_id="worker-owner", lease_seconds=120, ) - admissions.discard_claimed.assert_called_once_with( + admissions.release_claim.assert_called_once_with( task_id="durable-task-id", lease_token="lease-durable-task-id", + error="整理终态未完成 durable 原子结算", ) + admissions.abandon_unstarted.assert_not_called() def test_claimed_task_prevents_progress_settlement_before_active_registration() -> None: @@ -646,7 +622,7 @@ def test_recovered_worker_reuses_claimed_token_without_second_claim( chain._processed_num = 0 chain._fail_num = 0 chain._total_num = 0 - chain._transfer_admissions.discard_claimed.return_value = 1 + chain._transfer_admissions.release_claim.return_value = True stop_event = threading.Event() def complete_recovery(*, task, callback): @@ -670,10 +646,12 @@ def test_recovered_worker_reuses_claimed_token_without_second_claim( assert worker.is_alive() is False chain._transfer_admissions.claim_task.assert_not_called() - chain._transfer_admissions.discard_claimed.assert_called_once_with( + chain._transfer_admissions.release_claim.assert_called_once_with( task_id="recovered-task", lease_token="lease-recovered-task", + error="整理终态未完成 durable 原子结算", ) + chain._transfer_admissions.abandon_unstarted.assert_not_called() def test_heartbeat_refreshes_current_token_and_forgets_lost_lease() -> None: @@ -761,7 +739,7 @@ def test_worker_reports_failed_settlement_without_skipping_queue_bookkeeping( chain._processed_num = 0 chain._fail_num = 0 chain._total_num = 0 - chain._transfer_admissions.discard_claimed.return_value = 0 + chain._transfer_admissions.release_claim.return_value = False chain._TransferChain__settle_transfer_progress_if_idle = MagicMock() stop_event = threading.Event() @@ -877,8 +855,10 @@ def test_worker_fenced_releases_lost_lease_and_completes_queue_bookkeeping( assert chain._recovery_wakeup_event.is_set() is False -def test_success_callback_runs_only_after_terminal_cas_succeeds(monkeypatch) -> None: - """终态 CAS 被拒绝时不得写成功历史、事件或通知。""" +def test_callback_without_terminal_settlement_releases_claim_and_counts_failure( + monkeypatch, +) -> None: + """回调未给出原子结算回执时必须保留 pending、释放 claim 并计失败。""" chain = _build_chain() task = TransferTask(fileitem=FileItem( storage="local", @@ -896,7 +876,7 @@ def test_success_callback_runs_only_after_terminal_cas_succeeds(monkeypatch) -> chain._processed_num = 0 chain._fail_num = 0 chain._total_num = 0 - chain._transfer_admissions.discard_claimed.return_value = 0 + chain._transfer_admissions.release_claim.return_value = False chain._TransferChain__settle_transfer_progress_if_idle = MagicMock() success_callback = MagicMock(return_value=(True, "")) chain._TransferChain__default_callback = success_callback @@ -924,8 +904,13 @@ def test_success_callback_runs_only_after_terminal_cas_succeeds(monkeypatch) -> worker.join(timeout=1) assert worker.is_alive() is False - success_callback.assert_not_called() - chain.jobview.fail_unfinished_task.assert_called_once_with(task) + success_callback.assert_called_once() + chain._transfer_admissions.release_claim.assert_called_once_with( + task_id="admitted-task", + lease_token="lease-admitted-task", + error="整理终态未完成 durable 原子结算", + ) + chain._transfer_admissions.abandon_unstarted.assert_not_called() assert chain._fail_num == 1 assert chain._queue.unfinished_tasks == 0