Merge upstream/v3 into codex/feat/plugin-data-query-sdk-v3

This commit is contained in:
InfinityPacer
2026-08-28 00:43:57 +08:00
49 changed files with 5423 additions and 1143 deletions
+74 -7
View File
@@ -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)
+22 -173
View File
@@ -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}"
)
+363 -373
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -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
+11 -15
View File
@@ -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()),
+412 -12
View File
@@ -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,
)
+76 -69
View File
@@ -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},
)
+12 -6
View File
@@ -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,
)
-1
View File
@@ -301,7 +301,6 @@ SCHEMA_EXPORTS = {
'PluginTriggeredEventData': ('app.schemas.event', 'PluginTriggeredEventData'),
'PluginUpdateCandidate': ('app.schemas.plugin', 'PluginUpdateCandidate'),
'PluginWorkflowActionGroup': ('app.schemas.workflow', 'PluginWorkflowActionGroup'),
'PrivateAttr': ('app.schemas.plugin', 'PrivateAttr'),
'ProcessInfo': ('app.schemas.dashboard', 'ProcessInfo'),
'ProgressKeyData': ('app.schemas.common', 'ProgressKeyData'),
'RadarrMovie': ('app.schemas.servarr', 'RadarrMovie'),
+3 -2
View File
@@ -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
# 插件名称
+42
View File
@@ -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),
)
)
+55 -29
View File
@@ -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:
+11 -12
View File
@@ -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),
)
)
+26 -16
View File
@@ -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 (
+98 -17
View File
@@ -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)
+89 -23
View File
@@ -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):
+703
View File
@@ -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 负责降级。"""
+2 -2
View File
@@ -77,9 +77,9 @@ 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 个文件,本批迁移路径的类型债务已清零 |
| 全量 mypy 历史债务 | 11,820 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 |
| Ruff 历史诊断 | 885 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率低水位 | Application 78.74%Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
| 覆盖率低水位 | Application 78.81%Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
### 3.3 热点文件
+3
View File
@@ -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
+3 -3
View File
@@ -1,8 +1,8 @@
{
"application": {
"covered_lines": 10098,
"percent": 78.74,
"statements": 12825
"covered_lines": 10068,
"percent": 78.81,
"statements": 12775
},
"domain": {
"covered_lines": 3392,
+2 -2
View File
@@ -1442,7 +1442,7 @@
}
},
"edge_count": 6940,
"edge_sha256": "8ff91be099f1230655ceeb006bd1bf604063054ddb0721b3e887c956ea1251fb",
"edge_sha256": "9e3e8485c94c46a75eb577ccfc24708c80d4296b6422ec66ab9f23ef9964568f",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -4454,7 +4454,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",
@@ -5201,6 +5200,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",
+3 -3
View File
@@ -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": {
+95
View File
@@ -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 = {
+244 -19
View File
@@ -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",
@@ -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(
+180 -1
View File
@@ -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:
+52
View File
@@ -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"
+31 -2
View File
@@ -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
@@ -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_"
+105 -13
View File
@@ -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()
+449 -12
View File
@@ -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",
)
+202 -16
View File
@@ -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。"""
@@ -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())
+94 -40
View File
@@ -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()
+27
View File
@@ -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:
+5 -3
View File
@@ -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
+31 -3
View File
@@ -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(
+43 -8
View File
@@ -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,
+39 -8
View File
@@ -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(
@@ -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()
+146 -13
View File
@@ -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
+283 -28
View File
@@ -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():
+57 -4
View File
@@ -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_"
+21 -12
View File
@@ -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()
+23 -1
View File
@@ -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(
@@ -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()
+229 -1
View File
@@ -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 == []
+54 -1
View File
@@ -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()
+52 -67
View File
@@ -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