refactor: complete durable transfer execution settlement

This commit is contained in:
jxxghp
2026-08-27 20:27:42 +08:00
parent 8e7a553c1e
commit e82ce8447c
69 changed files with 11948 additions and 275 deletions
+12 -9
View File
@@ -39,10 +39,11 @@ task_types:
- "Use `query_transfer_history` with status='failed' to find the record with id={history_id} and understand the failure details such as source path, error message, and media info."
- "Analyze the error message to determine the best retry strategy."
- "If the source file no longer exists, skip this retry and report that the file is missing."
- "Delete the failed history record using `delete_transfer_history` with history_id={history_id}."
- "Re-identify the media using `recognize_media` with the source file path. For audio files, set media_type='music' and preserve artist/title/album context."
- "If recognition fails, try `search_media` with keywords from the filename. For music, distinguish recording, album, and browse-only artist results."
- "Re-transfer using `transfer_file` with the source path and exact identity fields. Reuse media_source + media_id for every media type, plus media_type + music_type for music."
- "Call `delete_transfer_history` with history_id={history_id}. Durable records are not deleted: the tool submits them to the persistent retry scheduler."
- "If `delete_transfer_history` reports that a durable retry was accepted or rejected, stop and report the exact scheduler result. Do not call `transfer_file`; do not delete the target, history, or retry evidence."
- "Only for a legacy history that was actually deleted, re-identify the media using `recognize_media` with the source file path. For audio files, set media_type='music' and preserve artist/title/album context."
- "If legacy recognition fails, try `search_media` with keywords from the filename. For music, distinguish recording, album, and browse-only artist results."
- "Re-transfer only the deleted legacy history using `transfer_file` with the source path and exact identity fields. Reuse media_source + media_id for every media type, plus media_type + music_type for music."
- "Report the final result."
batch_transfer_failed_retry:
header: "[System Task - Batch Transfer Failed Retry]"
@@ -56,7 +57,7 @@ task_types:
- "Use `query_transfer_history` with status='failed' to find all records with these IDs and understand the failure details."
- "Group records by exact media identity and source directory before retrying. Do not assume all selected files belong to one media."
- "If the error is about media recognition, identify each group once using `recognize_media` or `search_media`, then reuse that result inside the group. Album tracks should normally be retried from the shared album directory with the album identity."
- "For each failed record, delete the old history entry with `delete_transfer_history` and re-transfer using `transfer_file`."
- "For each failed record, call `delete_transfer_history`. Durable records are submitted to the persistent retry scheduler, and that result is final for this task: report its exact accepted or rejected state and do not call `transfer_file` or delete its evidence. Only an actually deleted legacy history may be re-transferred with `transfer_file`."
- "Report how many retries succeeded and how many still failed."
task_rules:
- "Within one verified group, do NOT call `recognize_media` or `search_media` repeatedly for each file. A music recording is one track; a music album is one multi-track directory; an artist is never a transfer target."
@@ -90,8 +91,9 @@ task_types:
- "If the current recognition is wrong or the record should be reorganized, determine the correct media identity first."
- "Prefer `recognize_media` with the source path. If recognition is not reliable, use `search_media` with keywords from filename, title, or year."
- "Only continue when you have high confidence in the target media."
- "Before re-organizing, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file."
- "Then use `transfer_file` to organize the source path directly."
- "Before re-organizing, call `delete_transfer_history`. Durable records are not deleted: the tool submits them to the persistent retry scheduler."
- "If `delete_transfer_history` reports that a durable retry was accepted or rejected, stop for that record and report the exact scheduler result. Do not call `transfer_file`; do not delete the target, history, or retry evidence. Only a legacy history deletion may be followed by `transfer_file`."
- "For a legacy history that was actually deleted, use `transfer_file` to organize the source path directly."
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, media_source, media_id, media_type, and music_type. For an album, retry the album directory once with the album identity when the records share that directory."
- "If this record is already correct and no re-organize is needed, do not perform destructive actions; simply report that no change is necessary."
task_rules:
@@ -116,8 +118,9 @@ task_types:
- "For each group, decide whether the current recognition is trustworthy."
- "If multiple records clearly belong to the same movie, series, or music album, identify the media once with `recognize_media` or `search_media`, then reuse that result for the related records. A recording remains a single-track target, and an artist is browse-only."
- "If a source file no longer exists or cannot be safely processed, skip that record and note the reason."
- "Before re-organizing a record, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file."
- "Then use `transfer_file` to organize the source path directly."
- "Before re-organizing each record, call `delete_transfer_history`. Durable records are not deleted: the tool submits each task to the persistent retry scheduler."
- "For each durable result, report the exact accepted or rejected scheduler state and stop processing that record. Do not call `transfer_file`; do not delete its target, history, or retry evidence."
- "Only after a legacy history was actually deleted, use `transfer_file` to organize that source path directly."
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, media_source, media_id, media_type, and music_type. Prefer one directory transfer for a verified complete album instead of treating each track as an unrelated media item."
- "If a record is already correct and no re-organize is needed, do not perform destructive actions; simply mark it as skipped."
- "Report only the aggregate outcome, including how many records succeeded, skipped, and failed."
@@ -7,8 +7,13 @@ from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.chain.storage import StorageChain
from app.application.agentdata import get_agent_transfer_history_port
from app.application.chain.data import get_chain_transfer_execution_port
from app.application.transfer_execution import (
TransferExecutionCommand,
TransferRetryRequestResult,
)
from app.chain.storage import StorageChain
from app.runtime.log import logger
from app.schemas.workflow import FileItem
@@ -29,6 +34,22 @@ def _delete_history_destination_file(fileitem: FileItem) -> tuple[bool, bool]:
return True, bool(storage_chain.delete_media_file(fileitem))
def _request_transfer_retry(
*,
history_id: int,
task_id: str,
user_id: str,
) -> TransferRetryRequestResult:
"""在线程池中登记 durable 重试,避免 Agent 事件循环执行同步数据库 I/O。"""
return TransferExecutionCommand(
get_chain_transfer_execution_port()
).request_retry(
task_id=task_id,
reason=f"Agent 请求重试整理历史 #{history_id}",
requested_by=f"agent:{user_id or 'unknown'}",
)
class DeleteTransferHistoryTool(MoviePilotTool):
name: str = "delete_transfer_history"
tags: list[str] = [
@@ -37,9 +58,10 @@ class DeleteTransferHistoryTool(MoviePilotTool):
ToolTag.Admin,
]
description: str = (
"Delete a specific transfer history record by its ID. For non-successful-move records with an old "
"destination file, the tool removes that media-library file before deleting the history record. This is "
"useful before retrying or re-organizing because the system skips files that already have transfer history."
"Request a safe retry for durable transfer history, or delete a legacy transfer history record by its ID. "
"Durable records keep their files and history and are retried only by the persistent scheduler. For legacy "
"non-successful-move records, the tool removes the old destination before deleting the history. If a durable "
"retry is accepted or rejected, stop and report that result; do not call transfer_file for the same record."
)
args_schema: Type[BaseModel] = DeleteTransferHistoryInput
require_admin: bool = True
@@ -58,6 +80,22 @@ class DeleteTransferHistoryTool(MoviePilotTool):
if not history:
return f"错误:整理历史记录不存在,ID={history_id}"
task_id = getattr(history, "transfer_task_id", None)
if task_id:
retry = await self.run_blocking(
"db",
_request_transfer_retry,
history_id=history_id,
task_id=task_id,
user_id=self._user_id,
)
outcome = "已登记" if retry.accepted else "未登记"
return (
f"durable 整理任务{outcome}重试:ID={history_id}"
f"task_id={task_id}state={retry.state.value}{retry.message}"
"已保留目标文件、历史记录和失败计数;不要调用 transfer_file。"
)
title = history.title or "未知"
src = history.src or "未知"
status = "成功" if history.status else "失败"
+189 -10
View File
@@ -1,3 +1,4 @@
import asyncio
import time
from collections.abc import Coroutine
from typing import Any, Callable, List, Optional
@@ -27,12 +28,17 @@ from app.api.dependencies.history import (
)
from app.api.response import ResponseAPIRouter
from app.application.agent import get_running_agent_manager
from app.application.chain.data import get_chain_transfer_execution_port
from app.application.configuration import ApiRuntimeConfig
from app.application.history import (
DownloadHistoryMutationCommand,
HistoryQueryService,
TransferHistoryMutationCommand,
)
from app.application.transfer_execution import (
TransferExecutionCommand,
TransferRetryRequestResult,
)
from app.runtime.config import global_vars
from app.runtime.log import logger
from app.runtime.progress import AsyncProgressHelper
@@ -58,6 +64,120 @@ def normalize_history_ids(history_ids: list[int]) -> list[int]:
return normalized_ids
def _request_durable_transfer_retry(
*,
history_id: int,
task_id: str,
requested_by: str,
) -> TransferRetryRequestResult:
"""把 durable 历史重试交给唯一持久调度器,不在请求线程执行整理。"""
return TransferExecutionCommand(
get_chain_transfer_execution_port()
).request_retry(
task_id=task_id,
reason=f"AI REST 请求重试整理历史 #{history_id}",
requested_by=requested_by,
)
def _format_retry_rejections(
rejections: list[tuple[int, TransferRetryRequestResult]],
) -> str:
"""把批量 durable 重试拒绝原因格式化为可审计的接口提示。"""
return "".join(
f"#{history_id} [{result.state.value}]: {result.message}"
for history_id, result in rejections
)
def _partition_durable_histories(
histories: list[_SchemaTransferHistory],
) -> tuple[list[_SchemaTransferHistory], list[_SchemaTransferHistory]]:
"""按是否绑定持久任务回执分离 durable 与旧整理历史。"""
return (
[history for history in histories if history.transfer_task_id],
[history for history in histories if not history.transfer_task_id],
)
async def _request_batch_durable_retries(
histories: list[_SchemaTransferHistory],
) -> tuple[int, list[tuple[int, TransferRetryRequestResult]]]:
"""逐任务登记 durable 重试并保留每条拒绝的稳定状态。"""
accepted_count = 0
rejections: list[tuple[int, TransferRetryRequestResult]] = []
for history in histories:
retry = await asyncio.to_thread(
_request_durable_transfer_retry,
history_id=history.id,
task_id=history.transfer_task_id or "",
requested_by="history_ai_redo_batch",
)
if retry.accepted:
accepted_count += 1
else:
rejections.append((history.id, retry))
return accepted_count, rejections
def _durable_retry_messages(
*,
accepted_count: int,
rejections: list[tuple[int, TransferRetryRequestResult]],
) -> list[str]:
"""构造 durable 批量登记结果消息,供纯 durable 和混合请求复用。"""
messages: list[str] = []
if accepted_count:
messages.append(f"已登记 {accepted_count} 个持久整理任务重试")
if rejections:
messages.append("以下任务未登记重试:" + _format_retry_rejections(rejections))
return messages
async def _complete_durable_retry_batch(
*,
histories: list[_SchemaTransferHistory],
messages: list[str],
rejections: list[tuple[int, TransferRetryRequestResult]],
) -> Any:
"""完成纯 durable 批量响应;存在拒绝时不伪造成功进度。"""
message = "".join(messages)
if rejections:
return _SchemaResponse(success=False, message=message)
progress_key = f"transfer_retry_batch_{int(time.time() * 1000)}"
history_ids = [history.id for history in histories]
await _complete_durable_retry_progress(
progress_key=progress_key,
text=message,
history_ids=history_ids,
)
return _SchemaResponse(
success=True,
message=message,
data={"progress_key": progress_key, "history_ids": history_ids},
)
async def _complete_durable_retry_progress(
*,
progress_key: str,
text: str,
history_ids: list[int],
) -> None:
"""写入可被现有 SSE 客户端立即消费的 durable 重试完成进度。"""
progress = AsyncProgressHelper(progress_key)
await progress.start()
await progress.end(
text=text,
data={
"history_ids": history_ids,
"success": True,
"completed": True,
"message": text,
},
)
def _build_progress_output_callback(
progress: AsyncProgressHelper,
data: dict[str, Any],
@@ -285,13 +405,34 @@ async def ai_redo_transfer_history(
手动触发单条历史记录的 AI 重新整理,并返回进度键。
"""
runtime_config = resolve_api_runtime_config(runtime_config)
if not runtime_config.ai_agent_enable:
return _SchemaResponse(success=False, message="MoviePilot智能助手未启用")
history = await query.get_transfer(history_id)
if not history:
return _SchemaResponse(success=False, message="整理记录不存在")
if history.transfer_task_id:
retry = await asyncio.to_thread(
_request_durable_transfer_retry,
history_id=history.id,
task_id=history.transfer_task_id,
requested_by="history_ai_redo",
)
if not retry.accepted:
return _SchemaResponse(success=False, message=retry.message)
progress_key = f"transfer_retry_{history_id}_{int(time.time() * 1000)}"
await _complete_durable_retry_progress(
progress_key=progress_key,
text=retry.message,
history_ids=[history.id],
)
return _SchemaResponse(
success=True,
message=retry.message,
data={"progress_key": progress_key},
)
if not runtime_config.ai_agent_enable:
return _SchemaResponse(success=False, message="MoviePilot智能助手未启用")
prompt = build_manual_redo_prompt(history)
progress_key = f"ai_redo_transfer_{history_id}_{int(time.time() * 1000)}"
_start_ai_redo_task(
@@ -320,9 +461,6 @@ async def batch_ai_redo_transfer_history(
手动触发多条历史记录的 AI 批量重新整理,并返回进度键。
"""
runtime_config = resolve_api_runtime_config(runtime_config)
if not runtime_config.ai_agent_enable:
return _SchemaResponse(success=False, message="MoviePilot智能助手未启用")
history_ids = normalize_history_ids(payload.history_ids)
if not history_ids:
return _SchemaResponse(success=False, message="未提供有效的整理记录")
@@ -336,18 +474,59 @@ async def batch_ai_redo_transfer_history(
+ ", ".join(str(history_id) for history_id in missing_ids),
)
prompt = build_batch_manual_redo_prompt(histories)
durable_histories, legacy_histories = _partition_durable_histories(histories)
accepted_count, rejections = await _request_batch_durable_retries(
durable_histories
)
response_message_parts = _durable_retry_messages(
accepted_count=accepted_count,
rejections=rejections,
)
if not legacy_histories:
return await _complete_durable_retry_batch(
histories=durable_histories,
messages=response_message_parts,
rejections=rejections,
)
if rejections:
response_message_parts.append(
f"{len(legacy_histories)} 条旧历史未提交:批量请求包含被拒绝的持久任务"
)
return _SchemaResponse(
success=False,
message="".join(response_message_parts),
)
if not runtime_config.ai_agent_enable:
response_message_parts.append(
f"{len(legacy_histories)} 条旧历史未处理:MoviePilot智能助手未启用"
)
return _SchemaResponse(
success=False,
message="".join(response_message_parts),
)
prompt = build_batch_manual_redo_prompt(legacy_histories)
progress_key = f"ai_redo_transfer_batch_{int(time.time() * 1000)}"
_start_batch_ai_redo_task(
history_ids=history_ids,
history_ids=[history.id for history in legacy_histories],
prompt=prompt,
progress_key=progress_key,
task_registry=task_registry,
)
response_message_parts.append(
f"已提交 {len(legacy_histories)} 条旧历史给智能助手处理"
)
return _SchemaResponse(
success=True,
data={"progress_key": progress_key, "history_ids": history_ids},
success=not rejections,
message="".join(response_message_parts),
data={
"progress_key": progress_key,
"history_ids": [history.id for history in histories],
},
)
+153 -2
View File
@@ -1,15 +1,25 @@
from pathlib import Path
from typing import Annotated, Any, List, Optional
from typing import Annotated, Any, List, Literal, Optional, cast
from fastapi import Depends
from fastapi import Depends, HTTPException, Query, status
from app.adapters.web.security.access import verify_apitoken, verify_token
from app.api.dependencies.auth import get_current_active_manage_user
from app.api.dependencies.history import get_transfer_history_lookup_service
from app.api.response import ResponseAPIRouter
from app.application.chain.data import get_chain_transfer_execution_port
from app.application.configuration import get_api_runtime_config_snapshot
from app.application.directory import DirectoryHelper
from app.application.history import TransferHistoryLookupService
from app.application.transfer_execution import (
TransferExecutionCommand,
TransferExecutionConflictError,
TransferExecutionState,
TransferManualReviewDecision,
TransferManualReviewQuery,
TransferManualReviewTaskView,
TransferStepResult,
)
from app.chain.media import MediaChain
from app.chain.transfer import TransferChain
from app.runtime.log import logger
@@ -25,6 +35,10 @@ from app.schemas.transfer import ManualTransferHistoryInfo as _SchemaManualTrans
from app.schemas.transfer import ManualTransferResultData as _SchemaManualTransferResultData
from app.schemas.transfer import ManualTransferTargetPath as _SchemaManualTransferTargetPath
from app.schemas.transfer import TransferJob as _SchemaTransferJob
from app.schemas.transfer import TransferManualReviewData as _SchemaTransferManualReviewData
from app.schemas.transfer import TransferManualReviewPageData as _SchemaTransferManualReviewPageData
from app.schemas.transfer import TransferManualReviewRequest as _SchemaTransferManualReviewRequest
from app.schemas.transfer import TransferManualReviewTaskData as _SchemaTransferManualReviewTaskData
from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaType
from app.schemas.workflow import FileItem
from app.schemas.workflow import FileItem as _SchemaFileItem
@@ -32,6 +46,143 @@ from app.schemas.workflow import FileItem as _SchemaFileItem
router = ResponseAPIRouter()
def _manual_review_actor(current_user: object) -> str:
"""按名称、用户名和用户 ID 的稳定顺序提取人工复核操作者。"""
for attribute in ("name", "username", "id"):
value = getattr(current_user, attribute, None)
if value is not None and str(value).strip():
return str(value).strip()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="当前管理用户缺少可审计身份",
)
def _manual_review_task_data(
task: TransferManualReviewTaskView,
) -> _SchemaTransferManualReviewTaskData:
"""把 Application 人工复核投影映射为严格公开响应。"""
return cast(
_SchemaTransferManualReviewTaskData,
_SchemaTransferManualReviewTaskData.model_validate({
"task_id": task.task_id,
"source": {
"storage": task.source.storage,
"path": task.source.path,
},
"state": task.state.value,
"step": {
"operation_id": task.step.operation_id,
"kind": task.step.kind,
"intent": task.step.intent,
"evidence": task.step.evidence,
"error": task.step.error,
},
"review_revision": task.review_revision,
}),
)
@router.get( # type: ignore[misc]
"/tasks/manual-reviews",
summary="分页查询 durable 整理人工复核任务",
response_model=_SchemaResponse[_SchemaTransferManualReviewPageData],
)
def list_transfer_manual_reviews(
state_filter: Literal["manual_review", "retry_wait"] = Query(
default="manual_review",
alias="state",
),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=30, ge=1, le=100),
current_user: object = Depends(get_current_active_manage_user),
) -> Any:
"""分页返回待复核或已判定等待 durable 恢复的任务。"""
del current_user
result = TransferManualReviewQuery(
get_chain_transfer_execution_port()
).list(
state=TransferExecutionState(state_filter),
page=page,
page_size=page_size,
)
return _SchemaResponse(
success=True,
data=_SchemaTransferManualReviewPageData(
items=[_manual_review_task_data(item) for item in result.items],
total=result.total,
page=result.page,
page_size=result.page_size,
),
)
@router.get( # type: ignore[misc]
"/tasks/{task_id}/manual-review",
summary="查询 durable 整理人工复核详情",
response_model=_SchemaResponse[_SchemaTransferManualReviewTaskData],
)
def get_transfer_manual_review(
task_id: str,
current_user: object = Depends(get_current_active_manage_user),
) -> Any:
"""按任务标识返回严格裁剪的人工复核详情。"""
del current_user
task = TransferManualReviewQuery(
get_chain_transfer_execution_port()
).get(task_id=task_id)
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="人工复核任务不存在",
)
return _SchemaResponse(success=True, data=_manual_review_task_data(task))
@router.post( # type: ignore[misc]
"/tasks/{task_id}/manual-review",
summary="人工判定整理步骤的外部执行结果",
response_model=_SchemaResponse[_SchemaTransferManualReviewData],
)
def resolve_transfer_manual_review(
task_id: str,
review: _SchemaTransferManualReviewRequest,
current_user: object = Depends(get_current_active_manage_user),
) -> Any:
"""提交无租约人工判定,并返回不含 attempt 与 lease 的公开状态。"""
result = (
TransferStepResult(payload=dict(review.result_payload))
if review.result_payload is not None
else None
)
try:
resolved = TransferExecutionCommand(
get_chain_transfer_execution_port()
).resolve_manual_review(
task_id=task_id,
operation_id=review.operation_id,
decision=TransferManualReviewDecision(review.decision),
actor=_manual_review_actor(current_user),
reason=review.reason,
result=result,
)
except TransferExecutionConflictError as error:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(error),
) from error
return _SchemaResponse(
success=True,
data=_SchemaTransferManualReviewData(
task_id=resolved.task_id,
operation_id=resolved.operation_id,
decision=resolved.decision.value,
state=resolved.state.value,
review_revision=resolved.review_revision,
),
)
@router.get(
"/name",
summary="查询整理后的名称",
+9
View File
@@ -11,10 +11,12 @@ from dataclasses import dataclass
from typing import Any, Optional
from app.application.transfer import TransferAdmissionRepository
from app.application.transfer_execution import TransferExecutionRepository
OperFactory = Callable[[], Any]
TransferAdmissionRepositoryFactory = Callable[[], TransferAdmissionRepository]
TransferExecutionRepositoryFactory = Callable[[], TransferExecutionRepository]
@dataclass(frozen=True, slots=True)
@@ -27,6 +29,7 @@ class ChainDataPorts:
download_history: OperFactory
transfer_history: OperFactory
transfer_pending: TransferAdmissionRepositoryFactory
transfer_execution: TransferExecutionRepositoryFactory
media_server: OperFactory
download_failure: OperFactory
user: OperFactory
@@ -110,6 +113,7 @@ def configure_chain_data_ports(**factories: OperFactory) -> None:
"download_history",
"transfer_history",
"transfer_pending",
"transfer_execution",
"media_server",
"download_failure",
"user",
@@ -158,6 +162,11 @@ def get_chain_transfer_pending_port() -> TransferAdmissionRepository:
return get_chain_data_ports().transfer_pending()
def get_chain_transfer_execution_port() -> TransferExecutionRepository:
"""创建类型化的整理步骤执行与终态结算仓储。"""
return get_chain_data_ports().transfer_execution()
def get_chain_media_server_port() -> Any:
"""创建媒体服务器数据端口实例。"""
return get_chain_data_ports().media_server()
+43 -6
View File
@@ -11,6 +11,7 @@ from typing import Any, Protocol, cast
from uuid import uuid4
from app.application.history import TransferHistoryRecord, TransferHistoryWriter
from app.application.transfer_execution import TransferSettlementResult
from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic
@@ -37,12 +38,33 @@ class ChainDurableEventWriter(Protocol):
def transfer_result(
self,
*,
topic: str,
topic: str | None,
stage_history: Callable[[TransferHistoryWriter], TransferHistoryRecord | None],
event_payload: dict[str, Any],
publish: Callable[[dict[str, Any]], None],
) -> TransferHistoryRecord | None:
"""提交整理历史与结果 intent,并在提交后广播兼容事件。"""
publish: Callable[[dict[str, Any]], None] | None,
settlement: "TransferResultSettlement | None" = None,
) -> TransferHistoryRecord | TransferSettlementResult | None:
"""提交历史、可选任务终态和结果 intent,再按 topic 广播事件。"""
@dataclass(frozen=True, slots=True)
class TransferResultSettlement:
"""描述一次受 lease fencing 保护的整理任务终态结算。"""
task_id: str
lease_token: str
execution_fingerprint: str
outcome: str
error: str | None = None
def __post_init__(self) -> None:
"""拒绝缺少稳定身份、非法结果或不可诊断的失败终态。"""
if not self.task_id or not self.lease_token or not self.execution_fingerprint:
raise ValueError("整理终态结算缺少任务、租约或执行检查点身份")
if self.outcome not in {"succeeded", "failed"}:
raise ValueError(f"不支持的整理终态:{self.outcome}")
if self.outcome == "failed" and not self.error:
raise ValueError("整理失败终态必须包含可诊断原因")
@dataclass(frozen=True, slots=True)
@@ -61,8 +83,23 @@ def download_added_event_key(history_id: int) -> str:
return f"download.added:{history_id}:{uuid4().hex}:v1"
def transfer_result_event_key(topic: str, history_id: int) -> str:
"""由结果 topic、整理历史 ID 与本次事实标识构造幂等键。"""
def transfer_result_event_key(
topic: str,
history_id: int,
*,
settlement: TransferResultSettlement | None = None,
settlement_revision: int | None = None,
) -> str:
"""为旧结果事实生成 occurrence key,为任务结算生成确定性幂等键。"""
if settlement is not None:
if settlement_revision is None or settlement_revision <= 0:
raise ValueError("整理终态事件键缺少有效结算修订号")
return (
f"transfer.result:{settlement.task_id}:{settlement_revision}:"
f"{settlement.outcome}:v1"
)
if settlement_revision is not None:
raise ValueError("旧整理结果事件键不能单独指定结算修订号")
return f"{topic}:{history_id}:{uuid4().hex}:v1"
+8 -2
View File
@@ -40,6 +40,7 @@ class TransferHistoryRecord(Protocol):
src: Optional[str]
src_storage: Optional[str]
src_fileitem: Optional[dict]
transfer_task_id: Optional[str]
class TransferHistoryWriter(Protocol):
@@ -414,6 +415,11 @@ class TransferHistoryMutationCommand:
history = self._repository.get(history_id)
if not history:
return HistoryMutationResult(False, "记录不存在")
if getattr(history, "transfer_task_id", None):
return HistoryMutationResult(
False,
"持久整理失败记录不可删除,请使用重试或人工复核入口",
)
if delete_destination and history.dest_fileitem:
destination = self._file_item_factory(history.dest_fileitem)
@@ -440,10 +446,10 @@ class TransferHistoryMutationCommand:
return HistoryMutationResult(True)
def truncate(self) -> HistoryMutationResult:
"""在单一事务中清空全部整理历史"""
"""在单一事务中清空旧历史,并保留当前失败任务记录"""
self._repository.stage_truncate()
self._commit()
return HistoryMutationResult(True)
return HistoryMutationResult(True, "已清空旧整理记录,失败任务记录已保留")
def _commit(self) -> None:
"""提交历史事务,失败时回滚且不发布事件或清缓存。"""
+14 -10
View File
@@ -167,16 +167,18 @@ class DurableEventCommand:
def execute(
self,
*,
intent: OutboxIntent | Callable[[T], OutboxIntent],
intent: OutboxIntent | Callable[[T], OutboxIntent] | None,
stage_business: Callable[[], T],
publish: Callable[[], None],
publish: Callable[[], None] | None,
after_commit: Callable[[], None] | None = None,
) -> T:
"""原子提交业务与 intent,再保持原顺序执行提交后动作和即时广播。"""
"""原子提交业务与可选 intent,再执行可选提交后动作和广播。"""
resolved_intent: OutboxIntent | None = None
try:
result = stage_business()
resolved_intent = intent(result) if callable(intent) else intent
self._outbox.stage(resolved_intent, datetime.now(timezone.utc))
if intent is not None:
resolved_intent = intent(result) if callable(intent) else intent
self._outbox.stage(resolved_intent, datetime.now(timezone.utc))
self._unit_of_work.commit()
except Exception:
self._unit_of_work.rollback()
@@ -184,11 +186,13 @@ class DurableEventCommand:
if after_commit:
after_commit()
publish()
self._outbox.complete_by_event_key(
resolved_intent.event_key,
datetime.now(timezone.utc),
)
if publish:
publish()
if resolved_intent is not None and publish is not None:
self._outbox.complete_by_event_key(
resolved_intent.event_key,
datetime.now(timezone.utc),
)
return result
+23
View File
@@ -37,6 +37,7 @@ 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
from app.domain.meta.metabase import MetaBase
@@ -668,6 +669,8 @@ class TransferTask(OptionalMediaIdentityMixin, BaseModel):
_admission_task_id: Optional[str] = PrivateAttr(default=None)
_planning_input: Optional[TransferPlanningInput] = PrivateAttr(default=None)
_plan_checkpoint: Optional[TransferPlanCheckpoint] = PrivateAttr(default=None)
_execution_checkpoint: Optional[TransferExecutionCheckpoint] = PrivateAttr(default=None)
_terminal_settled: bool = PrivateAttr(default=False)
_planning_context_restored: bool = PrivateAttr(default=False)
_lease_owner: Optional[str] = PrivateAttr(default=None)
_lease_token: Optional[str] = PrivateAttr(default=None)
@@ -699,6 +702,26 @@ class TransferTask(OptionalMediaIdentityMixin, BaseModel):
"""绑定持久执行检查点,不改变插件可见序列化字段。"""
self._plan_checkpoint = checkpoint
@property
def execution_checkpoint(self) -> Optional[TransferExecutionCheckpoint]:
"""返回仅供宿主终态结算使用的内部执行检查点。"""
return self._execution_checkpoint
def bind_execution_checkpoint(
self, checkpoint: TransferExecutionCheckpoint
) -> None:
"""绑定执行结果检查点,不改变插件可见的旧任务序列化字段。"""
self._execution_checkpoint = checkpoint
@property
def terminal_settled(self) -> bool:
"""返回历史、事件与 pending 是否已由同一 UoW 提交。"""
return self._terminal_settled
def mark_terminal_settled(self) -> None:
"""仅在 task-aware writer 成功返回后标记终态已经提交。"""
self._terminal_settled = True
@property
def planning_context_restored(self) -> bool:
"""返回当前领域上下文是否来自持久快照。"""
+884
View File
@@ -0,0 +1,884 @@
"""整理步骤执行检查点、人工判定与终态结算的应用契约。"""
from __future__ import annotations
import hashlib
import json
from copy import deepcopy
from dataclasses import dataclass
from enum import StrEnum
from typing import Any, Callable, Mapping, Optional, Protocol
from uuid import uuid4
TRANSFER_EXECUTION_VERSION = 1
TRANSFER_STEP_INTENT_VERSION = 1
TRANSFER_STEP_RESULT_VERSION = 1
class TransferExecutionState(StrEnum):
"""描述 planning phase 正交的持久执行状态。"""
NOT_STARTED = "not_started"
RUNNING = "running"
RETRY_WAIT = "retry_wait"
SETTLING = "settling"
FAILED = "failed"
MANUAL_REVIEW = "manual_review"
class TransferStepState(StrEnum):
"""描述单个稳定外部操作的持久执行状态。"""
PREPARED = "prepared"
STARTED = "started"
SUCCEEDED = "succeeded"
FAILED = "failed"
MANUAL_REVIEW = "manual_review"
class TransferTerminalState(StrEnum):
"""描述允许写入整理历史的确定终态。"""
SUCCEEDED = "succeeded"
FAILED = "failed"
class TransferOperationObservationState(StrEnum):
"""描述重启后对遗留 STARTED 外部操作的严格探测结论。"""
APPLIED = "applied"
NOT_APPLIED = "not_applied"
UNKNOWN = "unknown"
CONFLICT = "conflict"
class TransferManualReviewDecision(StrEnum):
"""描述人工对外部结果不确定步骤作出的显式判定。"""
NOT_APPLIED = "not_applied"
APPLIED = "applied"
FAILED = "failed"
class TransferExecutionError(RuntimeError):
"""表示整理执行持久化状态无法按请求推进。"""
class TransferExecutionConflictError(TransferExecutionError):
"""表示稳定操作身份、尝试身份或检查点证据发生冲突。"""
class TransferExecutionLeaseLostError(TransferExecutionError):
"""表示持久化写入时任务租约已失效或已被其他 worker 接管。"""
def _canonical_json(payload: Mapping[str, Any]) -> str:
"""生成稳定操作身份使用的规范 JSON。"""
return json.dumps(
payload,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
)
def build_transfer_operation_id(
*,
task_id: str,
checkpoint_fingerprint: str,
ordinal: int,
phase: str,
kind: str,
intent_payload: Mapping[str, Any],
) -> str:
"""
由冻结计划和操作语义生成跨 lease、attempt 与重启稳定的身份。
:param task_id: 稳定任务标识
:param checkpoint_fingerprint: 冻结计划指纹
:param ordinal: 全局执行序号
:param phase: 执行阶段
:param kind: 操作类型
:param intent_payload: 冻结操作参数
:return: SHA-256 操作标识
"""
if not task_id or not checkpoint_fingerprint or ordinal < 0 or not phase or not kind:
raise ValueError("整理操作身份缺少稳定任务、计划或步骤信息")
canonical = _canonical_json({
"schema_version": TRANSFER_STEP_INTENT_VERSION,
"task_id": task_id,
"checkpoint_fingerprint": checkpoint_fingerprint,
"ordinal": ordinal,
"phase": phase,
"kind": kind,
"intent_payload": dict(intent_payload),
})
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def build_transfer_checkpoint_fingerprint(
checkpoint_payload: Mapping[str, Any],
) -> str:
"""由版本化执行检查点 payload 生成稳定 SHA-256 指纹。"""
return hashlib.sha256(
_canonical_json(checkpoint_payload).encode("utf-8")
).hexdigest()
@dataclass(frozen=True, slots=True)
class TransferStepIntent:
"""保存一次可持久化外部操作的稳定意图。"""
operation_id: str
checkpoint_fingerprint: str
ordinal: int
phase: str
kind: str
payload: dict[str, Any]
version: int = TRANSFER_STEP_INTENT_VERSION
def __post_init__(self) -> None:
"""拒绝版本未知、身份不完整或不可 JSON 序列化的操作意图。"""
object.__setattr__(self, "payload", deepcopy(self.payload))
if self.version != TRANSFER_STEP_INTENT_VERSION:
raise ValueError(f"不支持的整理步骤意图版本:{self.version}")
if (
not self.operation_id
or not self.checkpoint_fingerprint
or self.ordinal < 0
or not self.phase
or not self.kind
):
raise ValueError("整理步骤意图缺少稳定身份或顺序")
_canonical_json(self.payload)
@classmethod
def create(
cls,
*,
task_id: str,
checkpoint_fingerprint: str,
ordinal: int,
phase: str,
kind: str,
payload: Mapping[str, Any],
) -> "TransferStepIntent":
"""构造并绑定稳定 operation ID 的步骤意图。"""
frozen_payload = dict(payload)
return cls(
operation_id=build_transfer_operation_id(
task_id=task_id,
checkpoint_fingerprint=checkpoint_fingerprint,
ordinal=ordinal,
phase=phase,
kind=kind,
intent_payload=frozen_payload,
),
checkpoint_fingerprint=checkpoint_fingerprint,
ordinal=ordinal,
phase=phase,
kind=kind,
payload=frozen_payload,
)
@dataclass(frozen=True, slots=True)
class TransferStepResult:
"""保存严格执行或探测后得到的版本化步骤证据。"""
payload: dict[str, Any]
version: int = TRANSFER_STEP_RESULT_VERSION
def __post_init__(self) -> None:
"""拒绝未知版本或不可 JSON 序列化的步骤结果。"""
object.__setattr__(self, "payload", deepcopy(self.payload))
if self.version != TRANSFER_STEP_RESULT_VERSION:
raise ValueError(f"不支持的整理步骤结果版本:{self.version}")
_canonical_json(self.payload)
@dataclass(frozen=True, slots=True)
class TransferOperationObservation:
"""保存外部操作探测结论及其版本化事实证据。"""
state: TransferOperationObservationState
evidence: TransferStepResult
@dataclass(frozen=True, slots=True)
class TransferExecutionStep:
"""提供脱离 ORM Session 的单步骤持久状态投影。"""
task_id: str
operation_id: str
checkpoint_fingerprint: str
ordinal: int
phase: str
kind: str
state: TransferStepState
attempt_token: Optional[str]
attempt_count: int
intent: TransferStepIntent
result: Optional[TransferStepResult]
last_error: Optional[str]
prepared_at: str
started_at: Optional[str]
completed_at: Optional[str]
updated_at: str
@dataclass(frozen=True, slots=True)
class TransferExecutionCheckpoint:
"""保存所有必要步骤完成后供终态结算重放的聚合结果。"""
fingerprint: str
payload: dict[str, Any]
operation_ids: tuple[str, ...]
skip_reason: Optional[str] = None
version: int = TRANSFER_EXECUTION_VERSION
def __post_init__(self) -> None:
"""拒绝重复步骤、空指纹和不可序列化的执行检查点。"""
object.__setattr__(self, "payload", deepcopy(self.payload))
if self.version != TRANSFER_EXECUTION_VERSION or not self.fingerprint:
raise ValueError("整理执行检查点版本或指纹无效")
if len(set(self.operation_ids)) != len(self.operation_ids):
raise ValueError("整理执行检查点不能引用重复操作")
if not all(isinstance(item, str) and item for item in self.operation_ids):
raise ValueError("整理执行检查点包含无效操作标识")
if not self.operation_ids and not self.skip_reason:
raise ValueError("零副作用整理执行检查点必须记录 skip_reason")
_canonical_json(self.payload)
if build_transfer_checkpoint_fingerprint(self.to_payload()) != self.fingerprint:
raise ValueError("整理执行检查点内容与指纹不一致")
@classmethod
def create(
cls,
*,
payload: Mapping[str, Any],
operation_ids: tuple[str, ...],
skip_reason: Optional[str] = None,
) -> "TransferExecutionCheckpoint":
"""构造并绑定完整序列化内容指纹的执行检查点。"""
frozen_payload = dict(payload)
serialized = {
"schema_version": TRANSFER_EXECUTION_VERSION,
"payload": frozen_payload,
"operation_ids": list(operation_ids),
"skip_reason": skip_reason,
}
return cls(
fingerprint=build_transfer_checkpoint_fingerprint(serialized),
payload=frozen_payload,
operation_ids=operation_ids,
skip_reason=skip_reason,
)
def to_payload(self) -> dict[str, Any]:
"""编码可跨重启恢复的完整版本化执行检查点。"""
return {
"schema_version": self.version,
"payload": dict(self.payload),
"operation_ids": list(self.operation_ids),
"skip_reason": self.skip_reason,
}
@classmethod
def from_payload(
cls,
payload: Mapping[str, Any],
*,
fingerprint: str,
) -> "TransferExecutionCheckpoint":
"""解析并校验数据库中的执行检查点版本与稳定指纹。"""
serialized = dict(payload)
if build_transfer_checkpoint_fingerprint(serialized) != fingerprint:
raise TransferExecutionConflictError("整理执行检查点 JSON 与指纹不一致")
version = serialized.get("schema_version")
if version != TRANSFER_EXECUTION_VERSION:
raise TransferExecutionConflictError("整理执行检查点版本不受支持")
result_payload = serialized.get("payload")
operation_ids = serialized.get("operation_ids")
if not isinstance(result_payload, dict) or not isinstance(operation_ids, list):
raise TransferExecutionConflictError("整理执行检查点结构无效")
if not all(isinstance(item, str) and item for item in operation_ids):
raise TransferExecutionConflictError("整理执行检查点包含无效操作标识")
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,
)
@dataclass(frozen=True, slots=True)
class TransferExecutionSnapshot:
"""提供任务执行 checkpoint、重试和人工状态的稳定投影。"""
task_id: str
state: TransferExecutionState
checkpoint: Optional[TransferExecutionCheckpoint]
retry_generation: int
retry_count: int
retry_due_at: Optional[str]
settlement_revision: int
terminal_history_id: Optional[int]
last_error: Optional[str]
steps: tuple[TransferExecutionStep, ...]
@dataclass(frozen=True, slots=True)
class TransferSettlementIntent:
"""描述已确定结果对应的历史写入与 pending 终态。"""
terminal_state: TransferTerminalState
history_payload: dict[str, Any]
def __post_init__(self) -> None:
"""确保历史 payload 可持久化且包含稳定源身份。"""
object.__setattr__(self, "history_payload", deepcopy(self.history_payload))
if not self.history_payload.get("src"):
raise ValueError("整理终态历史缺少源路径")
_canonical_json(self.history_payload)
@dataclass(frozen=True, slots=True)
class TransferSettlementResult:
"""描述终态历史是否新提交以及 pending 是否已删除。"""
history_id: int
settlement_revision: int
pending_deleted: bool
already_settled: bool = False
@dataclass(frozen=True, slots=True)
class TransferRetryRequestResult:
"""描述用户重试请求是否被持久调度器接受。"""
accepted: bool
state: TransferExecutionState
retry_generation: int
message: str
@dataclass(frozen=True, slots=True)
class TransferManualReviewResult:
"""描述一次已持久审计的人工判定及后续调度状态。"""
task_id: str
operation_id: str
decision: TransferManualReviewDecision
state: TransferExecutionState
review_revision: int
step: TransferExecutionStep
@dataclass(frozen=True, slots=True)
class TransferManualReviewSource:
"""提供人工复核任务的最小源文件身份。"""
storage: str
path: str
@dataclass(frozen=True, slots=True)
class TransferManualReviewStepView:
"""提供人工复核所需且不含租约或尝试令牌的步骤证据。"""
operation_id: str
kind: str
intent: dict[str, Any]
evidence: Optional[dict[str, Any]]
error: Optional[str]
@dataclass(frozen=True, slots=True)
class TransferManualReviewTaskView:
"""提供管理员发现与复核 durable 整理任务的公开投影。"""
task_id: str
source: TransferManualReviewSource
state: TransferExecutionState
step: TransferManualReviewStepView
review_revision: int
@dataclass(frozen=True, slots=True)
class TransferManualReviewPage:
"""提供稳定分页的人工复核任务结果。"""
items: tuple[TransferManualReviewTaskView, ...]
total: int
page: int
page_size: int
class TransferExecutionRepository(Protocol):
"""定义步骤状态、执行 checkpoint 与终态历史的持久化端口。"""
def get_snapshot(self, *, task_id: str) -> Optional[TransferExecutionSnapshot]:
"""读取任务执行快照。"""
def list_manual_reviews(
self,
*,
state: TransferExecutionState,
page: int,
page_size: int,
) -> TransferManualReviewPage:
"""按严格公开状态分页读取人工复核任务。"""
def get_manual_review(
self,
*,
task_id: str,
) -> Optional[TransferManualReviewTaskView]:
"""按任务标识读取人工复核公开详情。"""
def prepare_step(
self,
*,
task_id: str,
lease_token: str,
intent: TransferStepIntent,
) -> TransferExecutionStep:
"""在外部副作用前持久化稳定步骤意图。"""
def start_step(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
) -> TransferExecutionStep:
"""以当前 lease 和新 attempt token 标记步骤开始。"""
def restart_after_not_applied(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
previous_attempt_token: str,
attempt_token: str,
evidence: TransferStepResult,
) -> TransferExecutionStep:
"""严格探测为未发生后,以新 attempt token 安全重启遗留步骤。"""
def resume_failed_step(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
) -> TransferExecutionStep:
"""任务到期并重新 claim 后,以新 attempt token 重试 FAILED 步骤。"""
def complete_step(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
result: TransferStepResult,
) -> TransferExecutionStep:
"""以 lease 和 attempt 双 CAS 提交成功证据。"""
def defer_step(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
error: str,
retry_due_at: str,
evidence: Optional[TransferStepResult] = None,
) -> TransferExecutionSnapshot:
"""持久化已知失败、到期时间并原子释放当前 lease。"""
def exhaust_step(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
error: str,
evidence: Optional[TransferStepResult] = None,
) -> TransferExecutionSnapshot:
"""重试预算耗尽时保留 lease,并建立可 durable 失败结算的检查点。"""
def request_retry(
self,
*,
task_id: str,
reason: str,
requested_by: str,
) -> TransferRetryRequestResult:
"""仅把 FAILED 终态转入到期可 claim 的 retry_wait。"""
def resolve_manual_review(
self,
*,
task_id: str,
operation_id: str,
decision: TransferManualReviewDecision,
actor: str,
reason: str,
result: Optional[TransferStepResult] = None,
) -> TransferManualReviewResult:
"""无 lease 地原子提交人工判定审计并交回唯一调度器。"""
def mark_manual_review(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
error: str,
evidence: Optional[TransferStepResult] = None,
attempt_token: Optional[str] = None,
) -> TransferExecutionSnapshot:
"""持久化不可判定结果并从自动调度中隔离任务。"""
def checkpoint_execution(
self,
*,
task_id: str,
lease_token: str,
checkpoint: TransferExecutionCheckpoint,
) -> TransferExecutionSnapshot:
"""确认所有引用步骤成功后提交聚合执行检查点。"""
class TransferStepRunner(Protocol):
"""定义文件执行方可注入的单步骤持久执行边界。"""
def run(
self,
*,
phase: str,
kind: str,
payload: Mapping[str, Any],
execute: Callable[[], TransferStepResult],
observe: Callable[[], TransferOperationObservation],
) -> TransferStepResult:
"""持久编排一次外部副作用,并在遗留尝试时先严格探测。"""
class TransferExecutionCommand:
"""以类型化命令收口步骤状态机,外部 I/O 由调用方在事务外执行。"""
def __init__(
self,
repository: TransferExecutionRepository,
*,
attempt_token_factory: Callable[[], str] = lambda: uuid4().hex,
) -> None:
"""保存持久化端口和可替换的 attempt token 工厂。"""
self._repository = repository
self._attempt_token_factory = attempt_token_factory
def prepare(
self,
*,
task_id: str,
lease_token: str,
intent: TransferStepIntent,
) -> TransferExecutionStep:
"""在外部调用前提交步骤意图。"""
return self._repository.prepare_step(
task_id=task_id,
lease_token=lease_token,
intent=intent,
)
def begin(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
) -> TransferExecutionStep:
"""生成单次 attempt token 并持久化开始状态。"""
attempt_token = self._attempt_token_factory()
if not attempt_token:
raise ValueError("整理步骤 attempt token 不能为空")
return self._repository.start_step(
task_id=task_id,
lease_token=lease_token,
operation_id=operation_id,
attempt_token=attempt_token,
)
def restart_after_not_applied(
self,
*,
task_id: str,
lease_token: str,
step: TransferExecutionStep,
evidence: TransferStepResult,
) -> TransferExecutionStep:
"""在严格 NOT_APPLIED 证据成立时轮换 attempt token 并继续执行。"""
if step.state is not TransferStepState.STARTED or not step.attempt_token:
raise ValueError("只有遗留 STARTED 步骤可按未发生证据安全重启")
attempt_token = self._attempt_token_factory()
if not attempt_token or attempt_token == step.attempt_token:
raise ValueError("安全重启必须生成不同的 attempt token")
return self._repository.restart_after_not_applied(
task_id=task_id,
lease_token=lease_token,
operation_id=step.operation_id,
previous_attempt_token=step.attempt_token,
attempt_token=attempt_token,
evidence=evidence,
)
def resume_failed(
self,
*,
task_id: str,
lease_token: str,
step: TransferExecutionStep,
) -> TransferExecutionStep:
"""使用重试调度取得的新 lease 为 FAILED 步骤创建下一次尝试。"""
if step.state is not TransferStepState.FAILED or step.attempt_token is not None:
raise ValueError("只有已释放 attempt 的 FAILED 步骤可以恢复重试")
attempt_token = self._attempt_token_factory()
if not attempt_token:
raise ValueError("恢复重试的 attempt token 不能为空")
return self._repository.resume_failed_step(
task_id=task_id,
lease_token=lease_token,
operation_id=step.operation_id,
attempt_token=attempt_token,
)
def complete(
self,
*,
task_id: str,
lease_token: str,
step: TransferExecutionStep,
result: TransferStepResult,
) -> TransferExecutionStep:
"""使用已开始步骤携带的 attempt token 提交成功证据。"""
if not step.attempt_token:
raise ValueError("尚未开始的整理步骤不能提交成功")
return self._repository.complete_step(
task_id=task_id,
lease_token=lease_token,
operation_id=step.operation_id,
attempt_token=step.attempt_token,
result=result,
)
def defer(
self,
*,
task_id: str,
lease_token: str,
step: TransferExecutionStep,
error: str,
retry_due_at: str,
evidence: Optional[TransferStepResult] = None,
) -> TransferExecutionSnapshot:
"""记录已知失败并把唯一重试权交回持久调度器。"""
if not step.attempt_token:
raise ValueError("尚未开始的整理步骤不能进入重试等待")
return self._repository.defer_step(
task_id=task_id,
lease_token=lease_token,
operation_id=step.operation_id,
attempt_token=step.attempt_token,
error=error,
retry_due_at=retry_due_at,
evidence=evidence,
)
def exhaust(
self,
*,
task_id: str,
lease_token: str,
step: TransferExecutionStep,
error: str,
evidence: Optional[TransferStepResult] = None,
) -> TransferExecutionSnapshot:
"""提交达到预算的确定失败,并把任务交给唯一 durable 终态 writer。"""
if step.state is not TransferStepState.STARTED or not step.attempt_token:
raise ValueError("只有 STARTED 步骤可提交预算耗尽失败")
return self._repository.exhaust_step(
task_id=task_id,
lease_token=lease_token,
operation_id=step.operation_id,
attempt_token=step.attempt_token,
error=error,
evidence=evidence,
)
def request_retry(
self,
*,
task_id: str,
reason: str,
requested_by: str,
) -> TransferRetryRequestResult:
"""登记用户重试意图,不直接 claim 或执行任务。"""
if not task_id or not reason or not requested_by:
raise ValueError("用户重试请求缺少任务、原因或请求身份")
return self._repository.request_retry(
task_id=task_id,
reason=reason,
requested_by=requested_by,
)
def resolve_manual_review(
self,
*,
task_id: str,
operation_id: str,
decision: TransferManualReviewDecision,
actor: str,
reason: str,
result: Optional[TransferStepResult] = None,
) -> TransferManualReviewResult:
"""提交人工判定;FAILED 在无 lease durable 结算落地前明确拒绝。"""
if not all((task_id, operation_id, actor, reason)):
raise ValueError("人工判定缺少任务、步骤、操作者或原因")
if decision is TransferManualReviewDecision.APPLIED and result is None:
raise ValueError("人工判定已发生时必须提供结果证据")
if decision is TransferManualReviewDecision.FAILED:
raise TransferExecutionConflictError(
"人工失败终态尚不能绕过 lease durable 结算"
)
return self._repository.resolve_manual_review(
task_id=task_id,
operation_id=operation_id,
decision=decision,
actor=actor,
reason=reason,
result=result,
)
def manual_review(
self,
*,
task_id: str,
lease_token: str,
step: TransferExecutionStep,
error: str,
evidence: Optional[TransferStepResult] = None,
) -> TransferExecutionSnapshot:
"""隔离无法严格判断外部结果的步骤,禁止自动再次执行。"""
return self._repository.mark_manual_review(
task_id=task_id,
lease_token=lease_token,
operation_id=step.operation_id,
attempt_token=step.attempt_token,
error=error,
evidence=evidence,
)
def checkpoint(
self,
*,
task_id: str,
lease_token: str,
checkpoint: TransferExecutionCheckpoint,
) -> TransferExecutionSnapshot:
"""提交可独立重放终态结算的聚合执行结果。"""
return self._repository.checkpoint_execution(
task_id=task_id,
lease_token=lease_token,
checkpoint=checkpoint,
)
class TransferManualReviewQuery:
"""收口管理员可发现的人工复核只读用例。"""
_visible_states = frozenset({
TransferExecutionState.MANUAL_REVIEW,
TransferExecutionState.RETRY_WAIT,
})
def __init__(self, repository: TransferExecutionRepository) -> None:
"""保存整理执行查询端口。"""
self._repository = repository
@classmethod
def _validate_state(cls, state: TransferExecutionState) -> None:
"""拒绝把内部执行状态扩展到人工复核查询面。"""
if state not in cls._visible_states:
raise ValueError(f"人工复核查询不支持状态:{state.value}")
def list(
self,
*,
state: TransferExecutionState = TransferExecutionState.MANUAL_REVIEW,
page: int = 1,
page_size: int = 30,
) -> TransferManualReviewPage:
"""分页返回待复核或刚完成复核并等待恢复的任务。"""
self._validate_state(state)
if page < 1 or not 1 <= page_size <= 100:
raise ValueError("人工复核分页参数超出允许范围")
return self._repository.list_manual_reviews(
state=state,
page=page,
page_size=page_size,
)
def get(self, *, task_id: str) -> Optional[TransferManualReviewTaskView]:
"""读取一条人工复核详情,内部状态任务按不存在处理。"""
if not task_id:
raise ValueError("人工复核详情缺少任务标识")
result = self._repository.get_manual_review(task_id=task_id)
if result is not None:
self._validate_state(result.state)
return result
__all__ = [
"TRANSFER_EXECUTION_VERSION",
"TRANSFER_STEP_INTENT_VERSION",
"TRANSFER_STEP_RESULT_VERSION",
"TransferExecutionCheckpoint",
"TransferExecutionCommand",
"TransferExecutionConflictError",
"TransferExecutionError",
"TransferExecutionLeaseLostError",
"TransferOperationObservation",
"TransferOperationObservationState",
"TransferRetryRequestResult",
"TransferExecutionRepository",
"TransferExecutionSnapshot",
"TransferExecutionState",
"TransferExecutionStep",
"TransferManualReviewDecision",
"TransferManualReviewPage",
"TransferManualReviewQuery",
"TransferManualReviewResult",
"TransferManualReviewSource",
"TransferManualReviewStepView",
"TransferManualReviewTaskView",
"TransferSettlementIntent",
"TransferSettlementResult",
"TransferStepIntent",
"TransferStepResult",
"TransferStepRunner",
"TransferStepState",
"TransferTerminalState",
"build_transfer_checkpoint_fingerprint",
"build_transfer_operation_id",
]
+108 -19
View File
@@ -9,12 +9,13 @@ TransferChain 中。mixin 方法运行时经 MRO 解析,共享 TransferChain
"""
from copy import deepcopy
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
from typing import Any, Callable, Dict, List, Optional, Tuple, Union, cast
from app.adapters.system.host import SystemUtils
from app.application.agent import build_manual_redo_prompt, get_running_agent_manager
from app.application.chain.data import (
get_chain_download_history_port,
get_chain_transfer_execution_port,
get_chain_transfer_history_port,
)
from app.application.configuration import (
@@ -24,6 +25,7 @@ from app.application.configuration import (
from app.application.formatting import EpisodeFormatRuleHelper
from app.application.history import clear_transfer_failures, resolve_history
from app.application.transfer import TransferTask, job_lock
from app.application.transfer_execution import TransferExecutionCommand
from app.chain._contracts import TransferMixinHost
from app.chain.media import MediaChain
from app.chain.storage import StorageChain
@@ -61,6 +63,34 @@ DownloadFiles = Any
DownloadHistory = Any
TransferHistory = Any
def _request_durable_transfer_retry(
history: TransferHistory,
*,
requested_by: str,
) -> Optional[Tuple[bool, str]]:
"""将 durable 历史重试交还持久调度器,旧历史返回 ``None``。"""
task_id = getattr(history, "transfer_task_id", None)
if not task_id:
return None
try:
result = TransferExecutionCommand(
get_chain_transfer_execution_port()
).request_retry(
task_id=task_id,
reason=f"用户请求重试整理历史 #{history.id}",
requested_by=requested_by,
)
except (RuntimeError, ValueError) as error:
logger.warning(
"登记 durable 整理重试失败:history_id=%s task_id=%s error=%s",
history.id,
task_id,
error,
)
return False, str(error)
return result.accepted, result.message
# 字幕文件常见的语言/默认/强制标记,整理同名字幕时只允许剥离这些字幕专属尾缀。
SUBTITLE_STEM_TAGS = {
"cc",
@@ -1233,12 +1263,40 @@ class ManualHistoryMixin:
histories[history.id] = history
return list(histories.values())
@staticmethod
def _request_durable_transfer_retry(
self,
history: TransferHistory,
*,
requested_by: str,
) -> Optional[Tuple[bool, str]]:
"""将 durable 历史重试交还持久调度器,旧历史返回 ``None``。
durable 任务的历史、目标文件和步骤证据共同描述一次可恢复执行。任何历史
入口都只能登记重试意图,不能删除这些证据后重新准入一条并行任务。
:param history: 整理历史
:param requested_by: 发起重试的稳定入口身份
:return: durable 请求结果;旧历史返回 ``None`` 继续兼容流程
"""
return _request_durable_transfer_retry(
history,
requested_by=requested_by,
)
def _delete_manual_transfer_history(
self,
history: TransferHistory,
transfer_history_oper: Any,
) -> Tuple[bool, str]:
"""删除手动重整历史;非成功移动记录同时清理可能存在的旧目标。"""
durable_retry = self._request_durable_transfer_retry(
history,
requested_by="manual_reorganize",
)
if durable_retry is not None:
_, message = durable_retry
# 返回 False 阻止旧 caller 把“已登记”误当成“历史已删除”并继续执行。
return False, message
if (
history.dest_fileitem
and not ManualHistoryMixin._is_successful_move_history(history)
@@ -1365,7 +1423,7 @@ class FailedRetryMixin:
source=source,
userid=userid,
username=username,
title=f"整理记录 #{history_id} 已重新整理",
title=errmsg or f"整理记录 #{history_id} 已重新整理",
link=self.runtime_config.history_url,
save_history=False,
)
@@ -1397,6 +1455,47 @@ class FailedRetryMixin:
由智能助手接管一条失败的整理记录。
"""
history = get_chain_transfer_history_port().get(history_id)
if not history:
host = cast(TransferMixinHost, self)
host.post_message(
Message(
channel=channel,
source=source,
userid=userid,
username=username,
title="重新整理失败",
text=f"整理记录 #{history_id} 不存在",
link=host.runtime_config.history_url,
save_history=False,
)
)
return
durable_retry = _request_durable_transfer_retry(
history,
requested_by="ai_retry_button",
)
if durable_retry is not None:
accepted, message = durable_retry
self.post_message(
Message(
channel=channel,
source=source,
userid=userid,
username=username,
title=(
message
if accepted
else "重新整理失败"
),
text=None if accepted else message,
link=self.runtime_config.history_url,
save_history=False,
)
)
return
if not self.runtime_config.ai_agent_enable:
self.post_message(
Message(
@@ -1410,22 +1509,6 @@ class FailedRetryMixin:
)
return
history = get_chain_transfer_history_port().get(history_id)
if not history:
self.post_message(
Message(
channel=channel,
source=source,
userid=userid,
username=username,
title="重新整理失败",
text=f"整理记录 #{history_id} 不存在",
link=self.runtime_config.history_url,
save_history=False,
)
)
return
redo_prompt = build_manual_redo_prompt(history)
async def _run_ai_takeover():
@@ -1528,6 +1611,12 @@ class FailedRetryMixin:
if not history:
logger.error(f"整理记录不存在,ID{logid}")
return False, "整理记录不存在"
durable_retry = _request_durable_transfer_retry(
history,
requested_by="history_redo",
)
if durable_retry is not None:
return durable_retry
# 按源目录路径重新整理
src_path = Path(history.src)
if not src_path.exists():
+744 -69
View File
File diff suppressed because it is too large Load Diff
+336 -24
View File
@@ -3,13 +3,17 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.application.chain.durable_events import (
ChainDurableEventWriter,
TransferHistoryRef,
TransferResultSettlement,
download_added_event_key,
snapshot_download_added,
snapshot_transfer_result,
@@ -17,22 +21,40 @@ from app.application.chain.durable_events import (
)
from app.application.history import TransferHistoryRecord, TransferHistoryWriter
from app.application.outbox import (
DurableEventCommand,
DOWNLOAD_ADDED_TOPIC,
DurableEventCommand,
OutboxIntent,
)
from app.application.transfer_execution import (
TransferExecutionConflictError,
TransferExecutionLeaseLostError,
TransferExecutionState,
TransferSettlementResult,
)
from app.db.adapters.outbox import SqlAlchemyOutboxRepository
from app.db.models.transfersettlementreceipt import TransferSettlementReceipt
from app.db.oper.downloadhistory import DownloadHistoryOper
from app.db.oper.transferexecutionstep import TransferExecutionStepOper
from app.db.oper.transferhistory import TransferHistoryOper
from app.db.oper.transferpending import TransferPendingOper
from app.db.oper.transfersettlementreceipt import TransferSettlementReceiptOper
from app.db.uow import SqlAlchemyUnitOfWork
class _StagingTransferHistoryWriter:
"""让既有历史字段映射复用无提交的 replace 适配器。"""
def __init__(self, repository: TransferHistoryOper) -> None:
"""保存绑定调用方 Session 的整理历史仓储。"""
def __init__(
self,
repository: TransferHistoryOper,
*,
settlement: TransferResultSettlement | None = None,
settlement_revision: int | None = None,
) -> None:
"""保存仓储,并让历史继续表达同源最新业务投影。"""
self._repository = repository
self._settlement = settlement
self._settlement_revision = settlement_revision
def get_by_src(
self,
@@ -47,14 +69,40 @@ class _StagingTransferHistoryWriter:
src: str,
storage: str | None = None,
) -> TransferHistoryRecord | None:
"""转发按源路径读取成功记录。"""
"""读取成功记录;任务结算时绑定当前任务投影"""
if self._settlement is not None:
if self._settlement_revision is None:
raise RuntimeError("整理任务结算缺少事务内修订号")
return self._repository.stage_bind_settlement(
task_id=self._settlement.task_id,
settlement_revision=self._settlement_revision,
src=src,
storage=storage,
)
return self._repository.get_success_by_src(src, storage)
def add_force(self, **payload: Any) -> TransferHistoryRecord:
"""保持应用层旧端口名,但只暂存替换而不自行提交"""
"""保持旧端口名,并按是否存在任务身份选择暂存策略"""
if self._settlement is not None:
if self._settlement_revision is None:
raise RuntimeError("整理任务结算缺少事务内修订号")
return self._repository.stage_upsert_by_transfer_task_id(
task_id=self._settlement.task_id,
settlement_revision=self._settlement_revision,
retain_task_mapping=self._settlement.outcome == "failed",
payload=payload,
)
return self._repository.stage_replace_by_src(**payload)
@dataclass(frozen=True, slots=True)
class _StagedTransferResult:
"""保存构造 outbox 所需历史投影及可选任务结算结果。"""
history: TransferHistoryRef
settlement: TransferSettlementResult | None = None
class TransactionalChainDurableEventWriter(ChainDurableEventWriter):
"""为每次 Chain 结果事件创建独占同步 Session 和 UoW。"""
@@ -109,41 +157,111 @@ class TransactionalChainDurableEventWriter(ChainDurableEventWriter):
def transfer_result(
self,
*,
topic: str,
topic: str | None,
stage_history: Callable[[TransferHistoryWriter], TransferHistoryRecord | None],
event_payload: dict[str, Any],
publish: Callable[[dict[str, Any]], None],
) -> TransferHistoryRecord | None:
"""原子写整理历史与结果 intent,并返回脱离 Session 的最小投影。"""
publish: Callable[[dict[str, Any]], None] | None,
settlement: TransferResultSettlement | None = None,
) -> TransferHistoryRecord | TransferSettlementResult | None:
"""原子写历史、可选任务终态与 intent,再返回稳定投影。"""
if topic is None and settlement is None:
raise ValueError("无事件 topic 的整理写入必须绑定 durable 任务结算")
session = self._session_factory()
try:
staging = _StagingTransferHistoryWriter(TransferHistoryOper(session))
history_repository = TransferHistoryOper(session)
pending_repository = TransferPendingOper(session)
receipt_repository = TransferSettlementReceiptOper(session)
already_settled = self._read_settlement_result(
pending_repository=pending_repository,
receipt_repository=receipt_repository,
settlement=settlement,
)
if already_settled is not None:
return already_settled
command = DurableEventCommand(
unit_of_work=SqlAlchemyUnitOfWork(session),
outbox=SqlAlchemyOutboxRepository(session),
)
def stage_business() -> TransferHistoryRef | None:
"""复用历史字段映射,并在 flush 后冻结安全投影"""
def stage_business() -> _StagedTransferResult:
"""同一事务暂存历史及受 fencing 保护的 pending 终态"""
expected_revision = self._settlement_revision(
pending_repository=pending_repository,
settlement=settlement,
)
next_revision = (
expected_revision + 1
if expected_revision is not None
else None
)
staging = _StagingTransferHistoryWriter(
history_repository,
settlement=settlement,
settlement_revision=next_revision,
)
history = stage_history(staging)
if history is None:
return None
return TransferHistoryRef(
raise RuntimeError("整理历史暂存失败,无法登记 durable 结果事件")
projected = TransferHistoryRef(
id=history.id,
status=bool(history.status),
src=history.src,
src_storage=history.src_storage,
src_fileitem=history.src_fileitem,
)
if settlement is None:
return _StagedTransferResult(history=projected)
assert expected_revision is not None
assert next_revision is not None
self._validate_history_outcome(projected, settlement)
pending_deleted = self._stage_pending_terminal(
session=session,
repository=pending_repository,
settlement=settlement,
expected_revision=expected_revision,
history_id=projected.id,
)
settled_at = datetime.now(timezone.utc).isoformat()
receipt_repository.stage_append(
task_id=settlement.task_id,
history_id=projected.id,
settlement_revision=next_revision,
outcome=settlement.outcome,
execution_fingerprint=settlement.execution_fingerprint,
lease_token=settlement.lease_token,
history_status=projected.status,
src=projected.src,
src_storage=projected.src_storage,
pending_deleted=pending_deleted,
error=settlement.error,
settled_at=settled_at,
)
return _StagedTransferResult(
history=projected,
settlement=TransferSettlementResult(
history_id=projected.id,
settlement_revision=next_revision,
pending_deleted=pending_deleted,
),
)
def build_intent(
history: TransferHistoryRef | None,
result: _StagedTransferResult,
) -> OutboxIntent:
"""历史 ID 确定后构造事件键与可恢复快照。"""
if history is None:
raise RuntimeError("整理历史暂存失败,无法登记 durable 结果事件")
event_key = transfer_result_event_key(topic, history.id)
event_payload["transfer_history_id"] = history.id
assert topic is not None
event_key = transfer_result_event_key(
topic,
result.history.id,
settlement=settlement,
settlement_revision=(
result.settlement.settlement_revision
if result.settlement is not None
else None
),
)
event_payload["transfer_history_id"] = result.history.id
event_payload["idempotency_key"] = event_key
return OutboxIntent(
event_key=event_key,
@@ -151,10 +269,204 @@ class TransactionalChainDurableEventWriter(ChainDurableEventWriter):
payload=snapshot_transfer_result(event_payload),
)
return command.execute(
intent=build_intent,
stage_business=stage_business,
publish=lambda: publish(event_payload),
)
try:
result = command.execute(
intent=build_intent if topic is not None else None,
stage_business=stage_business,
publish=(
(lambda: publish(event_payload))
if (
settlement is None
and topic is not None
and publish is not None
)
else None
),
)
except (
IntegrityError,
TransferExecutionConflictError,
TransferExecutionLeaseLostError,
ValueError,
):
if settlement is None:
raise
session.rollback()
replay = self._read_settlement_result(
pending_repository=pending_repository,
receipt_repository=receipt_repository,
settlement=settlement,
)
if replay is None:
raise
return replay
return result.settlement or result.history
finally:
session.close()
@staticmethod
def _read_settlement_result(
*,
pending_repository: TransferPendingOper,
receipt_repository: TransferSettlementReceiptOper,
settlement: TransferResultSettlement | None,
) -> TransferSettlementResult | None:
"""识别已提交终态并返回幂等结果,未结算时返回空。"""
if settlement is None:
return None
pending = pending_repository.get_by_task_id(task_id=settlement.task_id)
latest = receipt_repository.get_latest_by_task_id(
task_id=settlement.task_id
)
receipt = receipt_repository.get_by_identity(
task_id=settlement.task_id,
execution_fingerprint=settlement.execution_fingerprint,
lease_token=settlement.lease_token,
outcome=settlement.outcome,
)
if (
pending is not None
and pending.execution_state == TransferExecutionState.FAILED.value
):
if (
latest is None
or pending.terminal_history_id != latest.history_id
or pending.settlement_revision != latest.settlement_revision
or pending.execution_fingerprint != latest.execution_fingerprint
or latest.outcome != "failed"
or latest.pending_deleted
):
raise TransferExecutionConflictError("失败终态与最新结算回执不一致")
if receipt is not None:
TransactionalChainDurableEventWriter._validate_receipt(
receipt=receipt,
settlement=settlement,
)
return TransferSettlementResult(
history_id=receipt.history_id,
settlement_revision=receipt.settlement_revision,
pending_deleted=receipt.pending_deleted,
already_settled=True,
)
if pending is None:
if latest is None:
raise TransferExecutionConflictError(
"pending 已不存在且没有可验证的终态回执"
)
raise TransferExecutionConflictError("整理终态与 durable 回执不一致")
if pending.execution_state != TransferExecutionState.FAILED.value:
return None
raise TransferExecutionConflictError("失败终态缺少匹配的结算回执")
@staticmethod
def _validate_receipt(
*,
receipt: TransferSettlementReceipt,
settlement: TransferResultSettlement,
) -> None:
"""校验重放请求与独立回执中的终态身份完全一致。"""
expected_status = settlement.outcome == "succeeded"
if (
receipt.task_id != settlement.task_id
or receipt.outcome != settlement.outcome
or receipt.execution_fingerprint != settlement.execution_fingerprint
or receipt.lease_token != settlement.lease_token
or receipt.history_status is not expected_status
or receipt.error != settlement.error
):
raise TransferExecutionConflictError("整理终态与 durable 回执不一致")
@staticmethod
def _settlement_revision(
*,
pending_repository: TransferPendingOper,
settlement: TransferResultSettlement | None,
) -> int | None:
"""从当前 pending 读取 CAS 基准修订号并校验执行身份。"""
if settlement is None:
return None
pending = pending_repository.get_by_task_id(task_id=settlement.task_id)
if pending is None:
raise TransferExecutionLeaseLostError("整理任务 pending 已不存在")
now_utc = TransactionalChainDurableEventWriter._format_utc(
datetime.now(timezone.utc)
)
if (
pending.lease_token != settlement.lease_token
or pending.lease_expires_at is None
or pending.lease_expires_at <= now_utc
):
raise TransferExecutionLeaseLostError("整理任务租约已失效或被接管")
if (
pending.execution_state != TransferExecutionState.SETTLING.value
or pending.execution_fingerprint
!= settlement.execution_fingerprint
):
raise TransferExecutionConflictError("整理终态与执行检查点不匹配")
return int(pending.settlement_revision)
@staticmethod
def _stage_pending_terminal(
*,
session: Session,
repository: TransferPendingOper,
settlement: TransferResultSettlement,
expected_revision: int,
history_id: int,
) -> bool:
"""以同一修订和 lease CAS 收口 pending,成功时同时清理步骤。"""
now = datetime.now(timezone.utc)
now_utc = TransactionalChainDurableEventWriter._format_utc(now)
if settlement.outcome == "succeeded":
TransferExecutionStepOper(session).stage_delete_task(
task_id=settlement.task_id
)
updated = repository.stage_delete_terminal_success(
task_id=settlement.task_id,
lease_token=settlement.lease_token,
execution_fingerprint=settlement.execution_fingerprint,
expected_revision=expected_revision,
now_utc=now_utc,
)
pending_deleted = True
else:
updated = repository.stage_terminal_failure(
task_id=settlement.task_id,
lease_token=settlement.lease_token,
execution_fingerprint=settlement.execution_fingerprint,
expected_revision=expected_revision,
history_id=history_id,
error=settlement.error,
now_utc=now_utc,
updated_at=now.astimezone().strftime("%Y-%m-%d %H:%M:%S"),
)
pending_deleted = False
if updated != 1:
session.expire_all()
current = repository.get_by_task_id(task_id=settlement.task_id)
if (
current is None
or current.lease_token != settlement.lease_token
or current.lease_expires_at is None
or current.lease_expires_at <= now_utc
):
raise TransferExecutionLeaseLostError(
"整理任务租约已失效或被其他 worker 接管"
)
raise TransferExecutionConflictError("整理终态结算版本发生冲突")
return pending_deleted
@staticmethod
def _validate_history_outcome(
history: TransferHistoryRef,
settlement: TransferResultSettlement,
) -> None:
"""拒绝结算终态与历史状态不一致的调用。"""
expected_status = settlement.outcome == "succeeded"
if history.status is not expected_status:
raise TransferExecutionConflictError("整理终态与历史状态不一致")
@staticmethod
def _format_utc(value: datetime) -> str:
"""编码与 pending lease 列一致的固定宽度 UTC 时间。"""
return value.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
+5 -1
View File
@@ -145,6 +145,8 @@ class TransactionalTransferAdmissionRepository:
planning_input: Optional[TransferPlanningInput] = None,
) -> TransferAdmission:
"""按输入指纹幂等持久化准入事实,并返回跨重启稳定身份。"""
if not storage or not src_path:
raise ValueError("整理任务的存储与源路径不能为空")
effective_input = planning_input or TransferPlanningInput.legacy(
storage=storage,
src_path=src_path,
@@ -170,7 +172,9 @@ class TransactionalTransferAdmissionRepository:
input_fingerprint=effective_input.fingerprint,
)
if pending is None:
raise ValueError("整理任务的存储与源路径不能为空")
raise TransferAdmissionConflictError(
f"整理源文件已有持久终态回执: {storage}:{src_path}"
)
session.flush()
self._assert_input_match(pending, effective_input)
admission = self._project(pending)
File diff suppressed because it is too large Load Diff
+8
View File
@@ -37,6 +37,14 @@ _MODEL_EXPORTS = {
),
"SystemConfig": ("app.db.models.systemconfig", "SystemConfig"),
"TransferHistory": ("app.db.models.transferhistory", "TransferHistory"),
"TransferExecutionStep": (
"app.db.models.transferexecutionstep",
"TransferExecutionStep",
),
"TransferSettlementReceipt": (
"app.db.models.transfersettlementreceipt",
"TransferSettlementReceipt",
),
"TransferPending": ("app.db.models.transferpending", "TransferPending"),
"User": ("app.db.models.user", "User"),
"UserConfig": ("app.db.models.userconfig", "UserConfig"),
+470
View File
@@ -0,0 +1,470 @@
"""整理任务外部操作步骤的持久化模型。"""
from __future__ import annotations
from typing import Any, Optional, cast
from sqlalchemy import (
JSON,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
and_,
delete,
exists,
or_,
select,
update,
)
from sqlalchemy.orm import Mapped, Session, mapped_column
from sqlalchemy.sql.selectable import Exists
from app.db.base import Base, execute_dml, get_id_column
from app.db.models.transferpending import TransferPending
class TransferExecutionStep(Base):
"""保存一次稳定外部操作的意图、尝试身份与结果证据。"""
id = get_id_column()
task_id: Mapped[str] = mapped_column(
String(64),
ForeignKey("transferpending.task_id", ondelete="CASCADE"),
nullable=False,
)
operation_id: Mapped[str] = mapped_column(String(64), nullable=False)
checkpoint_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
ordinal: Mapped[int] = mapped_column(Integer, nullable=False)
phase: Mapped[str] = mapped_column(String(32), nullable=False)
kind: Mapped[str] = mapped_column(String(32), nullable=False)
state: Mapped[str] = mapped_column(String(32), nullable=False, default="prepared")
attempt_token: Mapped[Optional[str]] = mapped_column(String(64))
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
intent_version: Mapped[int] = mapped_column(Integer, nullable=False)
intent_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
result_version: Mapped[Optional[int]] = mapped_column(Integer)
result_payload: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON)
last_error: Mapped[Optional[str]] = mapped_column(Text)
prepared_at: Mapped[str] = mapped_column(String(40), nullable=False)
started_at: Mapped[Optional[str]] = mapped_column(String(40))
completed_at: Mapped[Optional[str]] = mapped_column(String(40))
updated_at: Mapped[str] = mapped_column(String(40), nullable=False)
__table_args__ = (
UniqueConstraint("operation_id", name="uq_transferexecutionstep_operation_id"),
UniqueConstraint(
"task_id",
"ordinal",
name="uq_transferexecutionstep_task_ordinal",
),
Index(
"ix_transferexecutionstep_task_state_ordinal",
"task_id",
"state",
"ordinal",
),
)
@classmethod
def get_by_operation_id(
cls,
db: Session,
*,
operation_id: str,
) -> Optional["TransferExecutionStep"]:
"""按稳定操作标识读取步骤。"""
if not operation_id:
return None
return cast(
Optional["TransferExecutionStep"],
db.execute(
select(cls).where(cls.operation_id == operation_id)
).scalars().first(),
)
@classmethod
def list_by_task_id(
cls,
db: Session,
*,
task_id: str,
) -> list["TransferExecutionStep"]:
"""按全局序号读取任务的全部外部操作步骤。"""
if not task_id:
return []
return list(
db.execute(
select(cls)
.where(cls.task_id == task_id)
.order_by(cls.ordinal.asc())
).scalars().all()
)
@classmethod
def stage_prepare(
cls,
db: Session,
*,
task_id: str,
operation_id: str,
checkpoint_fingerprint: str,
ordinal: int,
phase: str,
kind: str,
intent_version: int,
intent_payload: dict[str, Any],
now_time: str,
) -> "TransferExecutionStep":
"""在调用方事务中暂存尚未执行的稳定步骤意图。"""
step = cls(
task_id=task_id,
operation_id=operation_id,
checkpoint_fingerprint=checkpoint_fingerprint,
ordinal=ordinal,
phase=phase,
kind=kind,
state="prepared",
attempt_count=0,
intent_version=intent_version,
intent_payload=intent_payload,
prepared_at=now_time,
updated_at=now_time,
)
db.add(step)
return step
@classmethod
def start_attempt(
cls,
db: Session,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
now_utc: str,
updated_at: str,
) -> int:
"""以有效任务租约 CAS 开始一次新的步骤尝试。"""
return execute_dml(
db,
update(cls)
.where(
cls.task_id == task_id,
cls.operation_id == operation_id,
cls.state == "prepared",
cls.attempt_token.is_(None),
cls._active_lease_exists(
task_id=task_id,
lease_token=lease_token,
now_utc=now_utc,
),
)
.values(
state="started",
attempt_token=attempt_token,
attempt_count=cls.attempt_count + 1,
started_at=updated_at,
completed_at=None,
last_error=None,
updated_at=updated_at,
),
execution_options={"synchronize_session": False},
)
@classmethod
def restart_after_not_applied(
cls,
db: Session,
*,
task_id: str,
lease_token: str,
operation_id: str,
previous_attempt_token: str,
attempt_token: str,
result_version: int,
result_payload: dict[str, Any],
now_utc: str,
updated_at: str,
) -> int:
"""以 NOT_APPLIED 证据和旧 attempt token CAS 重启遗留步骤。"""
return execute_dml(
db,
update(cls)
.where(
cls.task_id == task_id,
cls.operation_id == operation_id,
cls.state == "started",
cls.attempt_token == previous_attempt_token,
cls._active_lease_exists(
task_id=task_id,
lease_token=lease_token,
now_utc=now_utc,
),
)
.values(
attempt_token=attempt_token,
attempt_count=cls.attempt_count + 1,
result_version=result_version,
result_payload=result_payload,
started_at=updated_at,
completed_at=None,
last_error=None,
updated_at=updated_at,
),
execution_options={"synchronize_session": False},
)
@classmethod
def resume_failed_attempt(
cls,
db: Session,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
now_utc: str,
updated_at: str,
) -> int:
"""以重试调度的新 lease CAS 恢复 FAILED 步骤并保留失败证据。"""
return execute_dml(
db,
update(cls)
.where(
cls.task_id == task_id,
cls.operation_id == operation_id,
cls.state == "failed",
cls.attempt_token.is_(None),
cls._active_lease_exists(
task_id=task_id,
lease_token=lease_token,
now_utc=now_utc,
),
)
.values(
state="started",
attempt_token=attempt_token,
attempt_count=cls.attempt_count + 1,
started_at=updated_at,
completed_at=None,
updated_at=updated_at,
),
execution_options={"synchronize_session": False},
)
@classmethod
def complete_attempt(
cls,
db: Session,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
result_version: int,
result_payload: dict[str, Any],
now_utc: str,
updated_at: str,
) -> int:
"""以租约与 attempt 双 CAS 提交步骤成功证据。"""
return execute_dml(
db,
update(cls)
.where(
cls.task_id == task_id,
cls.operation_id == operation_id,
cls.state == "started",
cls.attempt_token == attempt_token,
cls._active_lease_exists(
task_id=task_id,
lease_token=lease_token,
now_utc=now_utc,
),
)
.values(
state="succeeded",
attempt_token=None,
result_version=result_version,
result_payload=result_payload,
last_error=None,
completed_at=updated_at,
updated_at=updated_at,
),
execution_options={"synchronize_session": False},
)
@classmethod
def fail_attempt(
cls,
db: Session,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
error: str,
result_version: Optional[int],
result_payload: Optional[dict[str, Any]],
now_utc: str,
updated_at: str,
) -> int:
"""以租约与 attempt 双 CAS 提交已知失败证据。"""
return execute_dml(
db,
update(cls)
.where(
cls.task_id == task_id,
cls.operation_id == operation_id,
cls.state == "started",
cls.attempt_token == attempt_token,
cls._active_lease_exists(
task_id=task_id,
lease_token=lease_token,
now_utc=now_utc,
),
)
.values(
state="failed",
attempt_token=None,
result_version=result_version,
result_payload=result_payload,
last_error=error,
completed_at=updated_at,
updated_at=updated_at,
),
execution_options={"synchronize_session": False},
)
@classmethod
def mark_manual_review(
cls,
db: Session,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: Optional[str],
error: str,
result_version: Optional[int],
result_payload: Optional[dict[str, Any]],
now_utc: str,
updated_at: str,
) -> int:
"""以当前尝试身份隔离外部结果不可判定的步骤。"""
attempt_match = (
cls.attempt_token == attempt_token
if attempt_token is not None
else cls.attempt_token.is_(None)
)
return execute_dml(
db,
update(cls)
.where(
cls.task_id == task_id,
cls.operation_id == operation_id,
cls.state.in_(("prepared", "started", "failed")),
attempt_match,
cls._active_lease_exists(
task_id=task_id,
lease_token=lease_token,
now_utc=now_utc,
),
)
.values(
state="manual_review",
attempt_token=None,
result_version=result_version,
result_payload=result_payload,
last_error=error,
completed_at=updated_at,
updated_at=updated_at,
),
execution_options={"synchronize_session": False},
)
@classmethod
def resolve_manual_review(
cls,
db: Session,
*,
task_id: str,
operation_id: str,
target_state: str,
reason: str,
result_version: Optional[int],
result_payload: Optional[dict[str, Any]],
updated_at: str,
) -> int:
"""仅在 pending 同为无租约人工态时 CAS 提交步骤判定。"""
if target_state not in {"failed", "succeeded"}:
return 0
return execute_dml(
db,
update(cls)
.where(
cls.task_id == task_id,
cls.operation_id == operation_id,
cls.state == "manual_review",
cls.attempt_token.is_(None),
cls._manual_review_pending_exists(task_id=task_id),
)
.values(
state=target_state,
result_version=result_version,
result_payload=result_payload,
last_error=(reason if target_state == "failed" else None),
completed_at=updated_at,
updated_at=updated_at,
),
execution_options={"synchronize_session": False},
)
@classmethod
def delete_by_task_id(cls, db: Session, *, task_id: str) -> int:
"""在终态成功结算事务中删除任务的步骤证据。"""
if not task_id:
return 0
return execute_dml(
db,
delete(cls).where(cls.task_id == task_id),
execution_options={"synchronize_session": False},
)
@staticmethod
def _active_lease_exists(
*,
task_id: str,
lease_token: str,
now_utc: str,
) -> Exists:
"""构造关联 pending 行仍持有当前有效租约的 SQL 谓词。"""
return exists(
select(TransferPending.id).where(
and_(
TransferPending.task_id == task_id,
TransferPending.lease_token == lease_token,
TransferPending.lease_expires_at.is_not(None),
TransferPending.lease_expires_at > now_utc,
or_(
TransferPending.execution_state == "running",
TransferPending.execution_state == "not_started",
),
)
)
)
@staticmethod
def _manual_review_pending_exists(*, task_id: str) -> Exists:
"""构造关联 pending 行处于无租约人工复核态的 SQL 谓词。"""
return exists(
select(TransferPending.id).where(
TransferPending.task_id == task_id,
TransferPending.execution_state == "manual_review",
TransferPending.lease_token.is_(None),
TransferPending.lease_owner.is_(None),
)
)
+101 -3
View File
@@ -1,9 +1,9 @@
import re
import time
from pathlib import Path
from typing import Any, List, Optional
from typing import Any, List, Optional, cast
from sqlalchemy import Boolean, Index, Integer, JSON, String, delete, func, or_, select, update
from sqlalchemy import JSON, Boolean, Index, Integer, String, delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column
@@ -24,6 +24,10 @@ class TransferHistory(Base):
整理记录
"""
id = get_id_column()
# 失败 pending 当前映射使用的稳定整理任务标识
transfer_task_id: Mapped[Optional[str]] = mapped_column(String(64))
# 失败 pending 当前映射对应的结算版本
transfer_settlement_revision: Mapped[Optional[int]] = mapped_column(Integer)
# 源路径
src: Mapped[Optional[str]] = mapped_column(String, index=True)
# 源存储
@@ -90,6 +94,11 @@ class TransferHistory(Base):
Index('ix_transferhistory_date_id', 'date', 'id'),
Index('ix_transferhistory_media_identity', 'media_source', 'media_id'),
Index('ux_transferhistory_src_storage', 'src', 'src_storage', unique=True),
Index(
'ux_transferhistory_transfer_task_id',
'transfer_task_id',
unique=True,
),
)
@classmethod
@@ -208,6 +217,82 @@ class TransferHistory(Base):
statement = statement.where(cls.src_storage == storage)
return db.execute(statement.order_by(cls.id.desc())).scalars().first()
@classmethod
def get_by_transfer_task_id(
cls,
db: Session,
*,
task_id: str,
) -> Optional["TransferHistory"]:
"""按稳定整理任务标识读取终态结算历史。"""
if not task_id:
return None
return cast(
Optional["TransferHistory"],
db.execute(
select(cls).where(cls.transfer_task_id == task_id)
).scalars().first(),
)
@classmethod
def upsert_by_transfer_task_id(
cls,
db: Session,
*,
task_id: str,
settlement_revision: int,
retain_task_mapping: bool,
payload: dict[str, Any],
) -> "TransferHistory":
"""按任务幂等写历史,并维持同源存储仅保留一条的既有约束。"""
if not task_id or settlement_revision <= 0:
raise ValueError("整理历史结算缺少稳定任务或正向版本")
column_names = {column.name for column in cls.__table__.columns}
values = {
key: value
for key, value in payload.items()
if key in column_names and key not in {
"id",
"transfer_task_id",
"transfer_settlement_revision",
}
}
src = values.get("src")
if not src:
raise ValueError("整理历史结算缺少源路径")
src_storage = values.get("src_storage") or "local"
values["src_storage"] = src_storage
history = cls.get_by_transfer_task_id(db, task_id=task_id)
if history is None:
history = db.execute(
select(cls).where(
cls.src == src,
cls.src_storage == src_storage,
)
).scalars().first()
if history is None:
history = cls(
transfer_task_id=(task_id if retain_task_mapping else None),
transfer_settlement_revision=(
settlement_revision if retain_task_mapping else None
),
**values,
)
db.add(history)
else:
if (history.transfer_task_id == task_id
and history.transfer_settlement_revision is not None
and settlement_revision <= history.transfer_settlement_revision):
raise ValueError("整理历史结算版本必须单调递增")
history.transfer_task_id = task_id if retain_task_mapping else None
history.transfer_settlement_revision = (
settlement_revision if retain_task_mapping else None
)
for key, value in values.items():
setattr(history, key, value)
db.flush()
return history
@classmethod
def get_success_by_src(
cls, db: Session, src: str,
@@ -557,10 +642,20 @@ class TransferHistory(Base):
src_storage = kwargs.get("src_storage") or "local"
kwargs["src_storage"] = src_storage
if src:
durable = db.execute(
select(cls.id).where(
cls.src == src,
cls.src_storage == src_storage,
cls.transfer_task_id.is_not(None),
)
).scalar_one_or_none()
if durable is not None:
raise ValueError("持久整理回执不能由旧历史写入口覆盖")
db.execute(
delete(cls).where(
cls.src == src,
cls.src_storage == src_storage,
cls.transfer_task_id.is_(None),
),
execution_options={"synchronize_session": False},
)
@@ -590,7 +685,10 @@ class TransferHistory(Base):
"""
ids = db.execute(
select(cls.id)
.where(cls.date < before_time)
.where(
cls.date < before_time,
cls.transfer_task_id.is_(None),
)
.order_by(cls.id.asc())
.limit(limit)
).scalars().all()
+409
View File
@@ -134,6 +134,42 @@ class TransferPending(Base):
heartbeat_at: Mapped[Optional[str]] = mapped_column(String(40))
# 真正取得新 token 的累计次数
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 与规划状态正交的执行状态
execution_state: Mapped[str] = mapped_column(
String(32), nullable=False, default="not_started"
)
# 聚合执行检查点格式版本
execution_version: Mapped[Optional[int]] = mapped_column(Integer)
# 可独立重放终态结算的聚合执行结果
execution_payload: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON)
# 聚合执行结果规范 JSON 的 SHA-256 指纹
execution_fingerprint: Mapped[Optional[str]] = mapped_column(String(64))
# 每次进入 retry_wait 都递增的调度世代
retry_generation: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 已持久提交的步骤重试次数
retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 下一次允许 claim 的 UTC 时间
retry_due_at: Mapped[Optional[str]] = mapped_column(String(40))
# 最近一次终态失败重试请求身份
retry_requested_by: Mapped[Optional[str]] = mapped_column(String(128))
# 最近一次终态失败重试请求原因
retry_reason: Mapped[Optional[str]] = mapped_column(Text)
# 已完成终态结算的单调版本
settlement_revision: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 失败终态保留的整理历史标识
terminal_history_id: Mapped[Optional[int]] = mapped_column(Integer)
# 人工判定的单调审计版本
manual_review_revision: Mapped[int] = mapped_column(
Integer, nullable=False, default=0
)
# 最近一次人工判定时间
reviewed_at: Mapped[Optional[str]] = mapped_column(String(40))
# 最近一次人工判定操作者
reviewed_by: Mapped[Optional[str]] = mapped_column(String(128))
# 最近一次人工判定原因
review_reason: Mapped[Optional[str]] = mapped_column(Text)
# 最近一次人工判定结论
review_decision: Mapped[Optional[str]] = mapped_column(String(32))
__table_args__ = (
# 同一个文件重复入队只保留一条,回放时不会重复送入整理链
@@ -153,6 +189,14 @@ class TransferPending(Base):
"created_at",
"id",
),
Index(
"ix_transferpending_execution_due",
"execution_state",
"retry_due_at",
"state",
"created_at",
"id",
),
UniqueConstraint("task_id", name="uq_transferpending_task_id"),
)
@@ -264,6 +308,19 @@ class TransferPending(Base):
cursor_created_at = func.coalesce(cls.created_at, "")
statement = select(cls.task_id, cursor_created_at, cls.id).where(
cls.state.in_(states),
cls.execution_state.in_((
"not_started",
"running",
"retry_wait",
"settling",
)),
or_(
cls.execution_state != "retry_wait",
and_(
cls.retry_due_at.is_not(None),
cls.retry_due_at <= now_time,
),
),
or_(
cls.lease_token.is_(None),
cls.lease_expires_at.is_(None),
@@ -331,6 +388,19 @@ class TransferPending(Base):
.where(
cls.task_id == task_id,
cls.state.in_(states),
cls.execution_state.in_((
"not_started",
"running",
"retry_wait",
"settling",
)),
or_(
cls.execution_state != "retry_wait",
and_(
cls.retry_due_at.is_not(None),
cls.retry_due_at <= now_time,
),
),
or_(
cls.lease_token.is_(None),
cls.lease_expires_at.is_(None),
@@ -348,6 +418,345 @@ class TransferPending(Base):
execution_options={"synchronize_session": False},
)
@classmethod
def stage_execution_running(
cls,
db: Session,
*,
task_id: str,
lease_token: str,
now_utc: str,
updated_at: str,
) -> int:
"""以有效租约把可执行任务推进或保持为 running。"""
if not all((task_id, lease_token, now_utc, updated_at)):
return 0
return execute_dml(
db,
update(cls)
.where(
cls.task_id == task_id,
cls.lease_token == lease_token,
cls.lease_expires_at.is_not(None),
cls.lease_expires_at > now_utc,
cls.execution_state.in_(("not_started", "running", "retry_wait")),
)
.values(
execution_state="running",
retry_due_at=None,
last_error=None,
updated_at=updated_at,
),
execution_options={"synchronize_session": False},
)
@classmethod
def defer_execution(
cls,
db: Session,
*,
task_id: str,
lease_token: str,
error: str,
retry_due_at: str,
now_utc: str,
updated_at: str,
) -> int:
"""以有效租约进入 retry_wait,并原子释放当前租约。"""
if not all((task_id, lease_token, error, retry_due_at, now_utc, updated_at)):
return 0
return execute_dml(
db,
update(cls)
.where(
cls.task_id == task_id,
cls.state == "planned",
cls.checkpoint_version.is_not(None),
cls.checkpoint_payload.is_not(None),
cls.execution_state == "running",
cls.lease_token == lease_token,
cls.lease_expires_at.is_not(None),
cls.lease_expires_at > now_utc,
)
.values(
execution_state="retry_wait",
retry_generation=cls.retry_generation + 1,
retry_count=cls.retry_count + 1,
retry_due_at=retry_due_at,
lease_owner=None,
lease_token=None,
lease_expires_at=None,
heartbeat_at=None,
last_error=error,
updated_at=updated_at,
),
execution_options={"synchronize_session": False},
)
@classmethod
def mark_execution_manual_review(
cls,
db: Session,
*,
task_id: str,
lease_token: str,
error: str,
now_utc: str,
updated_at: str,
) -> int:
"""以有效租约隔离执行结果未知的任务,并释放自动调度租约。"""
if not all((task_id, lease_token, error, now_utc, updated_at)):
return 0
return execute_dml(
db,
update(cls)
.where(
cls.task_id == task_id,
cls.execution_state.in_(("not_started", "running", "retry_wait")),
cls.lease_token == lease_token,
cls.lease_expires_at.is_not(None),
cls.lease_expires_at > now_utc,
)
.values(
execution_state="manual_review",
lease_owner=None,
lease_token=None,
lease_expires_at=None,
heartbeat_at=None,
last_error=error,
updated_at=updated_at,
),
execution_options={"synchronize_session": False},
)
@classmethod
def checkpoint_execution(
cls,
db: Session,
*,
task_id: str,
lease_token: str,
execution_version: int,
execution_payload: dict[str, Any],
execution_fingerprint: str,
now_utc: str,
updated_at: str,
) -> int:
"""以有效租约保存可重放执行检查点并进入 settling。"""
if not all((
task_id,
lease_token,
execution_version,
execution_payload,
execution_fingerprint,
now_utc,
updated_at,
)):
return 0
return execute_dml(
db,
update(cls)
.where(
cls.task_id == task_id,
cls.state == "planned",
cls.checkpoint_version.is_not(None),
cls.checkpoint_payload.is_not(None),
cls.execution_state == "running",
cls.lease_token == lease_token,
cls.lease_expires_at.is_not(None),
cls.lease_expires_at > now_utc,
)
.values(
execution_state="settling",
execution_version=execution_version,
execution_payload=execution_payload,
execution_fingerprint=execution_fingerprint,
retry_due_at=None,
last_error=None,
updated_at=updated_at,
),
execution_options={"synchronize_session": False},
)
@classmethod
def checkpoint_exhausted_failure(
cls,
db: Session,
*,
task_id: str,
lease_token: str,
execution_version: int,
execution_payload: dict[str, Any],
execution_fingerprint: str,
error: str,
now_utc: str,
updated_at: str,
) -> int:
"""以有效 lease 保存预算耗尽失败检查点并保持租约进入 settling。"""
return execute_dml(
db,
update(cls)
.where(
cls.task_id == task_id,
cls.state == "planned",
cls.checkpoint_version.is_not(None),
cls.checkpoint_payload.is_not(None),
cls.execution_state == "running",
cls.lease_token == lease_token,
cls.lease_expires_at.is_not(None),
cls.lease_expires_at > now_utc,
)
.values(
execution_state="settling",
execution_version=execution_version,
execution_payload=execution_payload,
execution_fingerprint=execution_fingerprint,
retry_due_at=None,
last_error=error,
updated_at=updated_at,
),
execution_options={"synchronize_session": False},
)
@classmethod
def request_execution_retry(
cls,
db: Session,
*,
task_id: str,
reason: str,
requested_by: str,
retry_due_at: str,
updated_at: str,
) -> int:
"""仅将无租约 FAILED 任务 CAS 为立即到期的 retry_wait。"""
if not all((task_id, reason, requested_by, retry_due_at, updated_at)):
return 0
return execute_dml(
db,
update(cls)
.where(
cls.task_id == task_id,
cls.execution_state == "failed",
cls.lease_token.is_(None),
)
.values(
execution_state="retry_wait",
retry_generation=cls.retry_generation + 1,
retry_due_at=retry_due_at,
retry_requested_by=requested_by,
retry_reason=reason,
updated_at=updated_at,
),
execution_options={"synchronize_session": False},
)
@classmethod
def resolve_manual_review(
cls,
db: Session,
*,
task_id: str,
decision: str,
actor: str,
reason: str,
retry_due_at: str,
updated_at: str,
) -> int:
"""无 lease 地 CAS 提交人工判定审计并交回 retry_wait 调度。"""
if decision not in {"not_applied", "applied"}:
return 0
return execute_dml(
db,
update(cls)
.where(
cls.task_id == task_id,
cls.execution_state == "manual_review",
cls.lease_token.is_(None),
cls.lease_owner.is_(None),
)
.values(
execution_state="retry_wait",
retry_generation=cls.retry_generation + 1,
retry_due_at=retry_due_at,
manual_review_revision=cls.manual_review_revision + 1,
reviewed_at=updated_at,
reviewed_by=actor,
review_reason=reason,
review_decision=decision,
last_error=(reason if decision == "not_applied" else None),
updated_at=updated_at,
),
execution_options={"synchronize_session": False},
)
@classmethod
def stage_terminal_failure(
cls,
db: Session,
*,
task_id: str,
lease_token: str,
execution_fingerprint: str,
expected_revision: int,
history_id: int,
error: Optional[str],
now_utc: str,
updated_at: str,
) -> int:
"""以执行指纹和结算版本 CAS 保留失败终态及其历史。"""
return execute_dml(
db,
update(cls)
.where(
cls.task_id == task_id,
cls.execution_state == "settling",
cls.execution_fingerprint == execution_fingerprint,
cls.settlement_revision == expected_revision,
cls.lease_token == lease_token,
cls.lease_expires_at.is_not(None),
cls.lease_expires_at > now_utc,
)
.values(
execution_state="failed",
settlement_revision=cls.settlement_revision + 1,
terminal_history_id=history_id,
lease_owner=None,
lease_token=None,
lease_expires_at=None,
heartbeat_at=None,
last_error=error,
updated_at=updated_at,
),
execution_options={"synchronize_session": False},
)
@classmethod
def delete_terminal_success(
cls,
db: Session,
*,
task_id: str,
lease_token: str,
execution_fingerprint: str,
expected_revision: int,
now_utc: str,
) -> int:
"""以执行指纹和结算版本 CAS 删除已成功结算的 pending。"""
return execute_dml(
db,
delete(cls).where(
cls.task_id == task_id,
cls.execution_state == "settling",
cls.execution_fingerprint == execution_fingerprint,
cls.settlement_revision == expected_revision,
cls.lease_token == lease_token,
cls.lease_expires_at.is_not(None),
cls.lease_expires_at > now_utc,
),
execution_options={"synchronize_session": False},
)
@classmethod
def record_projection_failure(
cls,
+147
View File
@@ -0,0 +1,147 @@
"""整理任务终态结算回执模型。"""
from __future__ import annotations
from typing import Optional, cast
from sqlalchemy import Boolean, Index, Integer, String, Text, UniqueConstraint, desc, select
from sqlalchemy.orm import Mapped, Session, mapped_column
from app.db.base import Base, get_id_column
class TransferSettlementReceipt(Base):
"""按任务保存独立于最新历史投影的 durable 终态证据。"""
id = get_id_column()
task_id: Mapped[str] = mapped_column(String(64), nullable=False)
history_id: Mapped[int] = mapped_column(Integer, nullable=False)
settlement_revision: Mapped[int] = mapped_column(Integer, nullable=False)
outcome: Mapped[str] = mapped_column(String(16), nullable=False)
execution_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
lease_token: Mapped[str] = mapped_column(String(64), nullable=False)
history_status: Mapped[bool] = mapped_column(Boolean, nullable=False)
src: Mapped[Optional[str]] = mapped_column(String)
src_storage: Mapped[Optional[str]] = mapped_column(String)
pending_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False)
error: Mapped[Optional[str]] = mapped_column(Text)
created_at: Mapped[str] = mapped_column(String(40), nullable=False)
updated_at: Mapped[str] = mapped_column(String(40), nullable=False)
__table_args__ = (
UniqueConstraint(
"task_id",
"settlement_revision",
name="uq_transfersettlementreceipt_task_revision",
),
Index(
"ix_transfersettlementreceipt_task_revision",
"task_id",
"settlement_revision",
),
Index(
"ix_transfersettlementreceipt_history_id",
"history_id",
),
)
@classmethod
def get_latest_by_task_id(
cls,
db: Session,
*,
task_id: str,
) -> Optional["TransferSettlementReceipt"]:
"""按稳定任务标识读取最新已提交结算回执。"""
if not task_id:
return None
return cast(
Optional["TransferSettlementReceipt"],
db.execute(
select(cls)
.where(cls.task_id == task_id)
.order_by(desc(cls.settlement_revision))
).scalars().first(),
)
@classmethod
def get_by_identity(
cls,
db: Session,
*,
task_id: str,
execution_fingerprint: str,
lease_token: str,
outcome: str,
) -> Optional["TransferSettlementReceipt"]:
"""按原始执行身份读取不可变结算回执。"""
if not all((task_id, execution_fingerprint, lease_token, outcome)):
return None
return cast(
Optional["TransferSettlementReceipt"],
db.execute(
select(cls).where(
cls.task_id == task_id,
cls.execution_fingerprint == execution_fingerprint,
cls.lease_token == lease_token,
cls.outcome == outcome,
).order_by(desc(cls.settlement_revision))
).scalars().first(),
)
@classmethod
def stage_append(
cls,
db: Session,
*,
task_id: str,
history_id: int,
settlement_revision: int,
outcome: str,
execution_fingerprint: str,
lease_token: str,
history_status: bool,
src: Optional[str],
src_storage: Optional[str],
pending_deleted: bool,
error: Optional[str],
settled_at: str,
) -> "TransferSettlementReceipt":
"""按连续修订追加任务回执,旧修订证据永不覆盖。"""
if not all((task_id, history_id, settlement_revision, outcome,
execution_fingerprint, lease_token, settled_at)):
raise ValueError("整理结算回执缺少稳定身份或结果证据")
if outcome not in {"succeeded", "failed"}:
raise ValueError(f"不支持的整理结算结果:{outcome}")
if cls.get_by_identity(
db,
task_id=task_id,
execution_fingerprint=execution_fingerprint,
lease_token=lease_token,
outcome=outcome,
) is not None:
raise ValueError("同一整理执行身份不能追加多个结算修订")
latest = cls.get_latest_by_task_id(db, task_id=task_id)
if latest is None:
if settlement_revision != 1:
raise ValueError("整理结算回执必须从修订 1 开始")
elif settlement_revision != latest.settlement_revision + 1:
raise ValueError("整理结算回执修订必须连续递增")
receipt = cls(
task_id=task_id,
history_id=history_id,
settlement_revision=settlement_revision,
outcome=outcome,
execution_fingerprint=execution_fingerprint,
lease_token=lease_token,
history_status=history_status,
src=src,
src_storage=src_storage,
pending_deleted=pending_deleted,
error=error,
created_at=settled_at,
updated_at=settled_at,
)
db.add(receipt)
db.flush()
return receipt
+3
View File
@@ -33,6 +33,7 @@ if TYPE_CHECKING:
from app.db.oper.systemconfig import SystemConfigOper
from app.db.oper.transferhistory import TransferHistoryOper
from app.db.oper.transferpending import TransferPendingOper
from app.db.oper.transfersettlementreceipt import TransferSettlementReceiptOper
from app.db.oper.user import UserOper
from app.db.oper.userconfig import UserConfigOper
from app.db.oper.workflow import WorkflowOper
@@ -52,6 +53,7 @@ _OPER_MODULES = {
"SystemConfigOper": "systemconfig",
"TransferHistoryOper": "transferhistory",
"TransferPendingOper": "transferpending",
"TransferSettlementReceiptOper": "transfersettlementreceipt",
"UserConfigOper": "userconfig",
"UserOper": "user",
"WorkflowOper": "workflow",
@@ -94,6 +96,7 @@ __all__ = [
"SystemConfigOper",
"TransferHistoryOper",
"TransferPendingOper",
"TransferSettlementReceiptOper",
"UserConfigOper",
"UserOper",
"WorkflowOper",
+243
View File
@@ -0,0 +1,243 @@
"""整理外部操作步骤的显式 Session 数据访问对象。"""
from __future__ import annotations
from typing import Any, Optional
from sqlalchemy.orm import Session
from app.db.base import DbOper
from app.db.models.transferexecutionstep import TransferExecutionStep
class TransferExecutionStepOper(DbOper):
"""在调用方事务中查询或暂存整理外部操作步骤。"""
def _session(self) -> Session:
"""返回调用方同步 Session,拒绝隐式事务破坏原子状态推进。"""
if not isinstance(self._db, Session):
raise RuntimeError("整理执行步骤写入需要调用方提供同步 Session")
return self._db
def get_by_operation_id(
self,
*,
operation_id: str,
) -> Optional[TransferExecutionStep]:
"""按稳定操作标识查询步骤。"""
return TransferExecutionStep.get_by_operation_id(
self._session(),
operation_id=operation_id,
)
def list_by_task_id(self, *, task_id: str) -> list[TransferExecutionStep]:
"""按全局序号查询任务的全部步骤。"""
return TransferExecutionStep.list_by_task_id(
self._session(),
task_id=task_id,
)
def stage_prepare(
self,
*,
task_id: str,
operation_id: str,
checkpoint_fingerprint: str,
ordinal: int,
phase: str,
kind: str,
intent_version: int,
intent_payload: dict[str, Any],
now_time: str,
) -> TransferExecutionStep:
"""暂存尚未执行的稳定步骤意图。"""
return TransferExecutionStep.stage_prepare(
self._session(),
task_id=task_id,
operation_id=operation_id,
checkpoint_fingerprint=checkpoint_fingerprint,
ordinal=ordinal,
phase=phase,
kind=kind,
intent_version=intent_version,
intent_payload=intent_payload,
now_time=now_time,
)
def stage_start_attempt(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
now_utc: str,
updated_at: str,
) -> int:
"""以有效任务租约暂存新步骤尝试。"""
return TransferExecutionStep.start_attempt(
self._session(),
task_id=task_id,
lease_token=lease_token,
operation_id=operation_id,
attempt_token=attempt_token,
now_utc=now_utc,
updated_at=updated_at,
)
def stage_restart_after_not_applied(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
previous_attempt_token: str,
attempt_token: str,
result_version: int,
result_payload: dict[str, Any],
now_utc: str,
updated_at: str,
) -> int:
"""以严格未发生证据暂存遗留 STARTED 步骤的安全重启。"""
return TransferExecutionStep.restart_after_not_applied(
self._session(),
task_id=task_id,
lease_token=lease_token,
operation_id=operation_id,
previous_attempt_token=previous_attempt_token,
attempt_token=attempt_token,
result_version=result_version,
result_payload=result_payload,
now_utc=now_utc,
updated_at=updated_at,
)
def stage_resume_failed_attempt(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
now_utc: str,
updated_at: str,
) -> int:
"""以重试调度的新 lease 暂存 FAILED 步骤恢复。"""
return TransferExecutionStep.resume_failed_attempt(
self._session(),
task_id=task_id,
lease_token=lease_token,
operation_id=operation_id,
attempt_token=attempt_token,
now_utc=now_utc,
updated_at=updated_at,
)
def stage_complete_attempt(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
result_version: int,
result_payload: dict[str, Any],
now_utc: str,
updated_at: str,
) -> int:
"""以 lease 与 attempt 双 CAS 暂存成功证据。"""
return TransferExecutionStep.complete_attempt(
self._session(),
task_id=task_id,
lease_token=lease_token,
operation_id=operation_id,
attempt_token=attempt_token,
result_version=result_version,
result_payload=result_payload,
now_utc=now_utc,
updated_at=updated_at,
)
def stage_fail_attempt(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
error: str,
result_version: Optional[int],
result_payload: Optional[dict[str, Any]],
now_utc: str,
updated_at: str,
) -> int:
"""以 lease 与 attempt 双 CAS 暂存已知失败证据。"""
return TransferExecutionStep.fail_attempt(
self._session(),
task_id=task_id,
lease_token=lease_token,
operation_id=operation_id,
attempt_token=attempt_token,
error=error,
result_version=result_version,
result_payload=result_payload,
now_utc=now_utc,
updated_at=updated_at,
)
def stage_manual_review(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: Optional[str],
error: str,
result_version: Optional[int],
result_payload: Optional[dict[str, Any]],
now_utc: str,
updated_at: str,
) -> int:
"""以当前尝试身份暂存人工复核证据。"""
return TransferExecutionStep.mark_manual_review(
self._session(),
task_id=task_id,
lease_token=lease_token,
operation_id=operation_id,
attempt_token=attempt_token,
error=error,
result_version=result_version,
result_payload=result_payload,
now_utc=now_utc,
updated_at=updated_at,
)
def stage_resolve_manual_review(
self,
*,
task_id: str,
operation_id: str,
target_state: str,
reason: str,
result_version: Optional[int],
result_payload: Optional[dict[str, Any]],
updated_at: str,
) -> int:
"""在 pending 同为无租约人工态时暂存步骤判定。"""
return TransferExecutionStep.resolve_manual_review(
self._session(),
task_id=task_id,
operation_id=operation_id,
target_state=target_state,
reason=reason,
result_version=result_version,
result_payload=result_payload,
updated_at=updated_at,
)
def stage_delete_task(self, *, task_id: str) -> int:
"""暂存任务全部步骤删除。"""
return TransferExecutionStep.delete_by_task_id(
self._session(),
task_id=task_id,
)
+92 -9
View File
@@ -2,6 +2,7 @@ import time
from typing import Any, List, Optional
from sqlalchemy import delete as sqlalchemy_delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.db.base import DbOper
@@ -116,6 +117,19 @@ class TransferHistoryOper(DbOper):
lambda session: TransferHistory.get_by_src(session, src, storage)
)
def get_by_transfer_task_id(
self,
*,
task_id: str,
) -> Optional[TransferHistory]:
"""按稳定整理任务标识读取终态历史。"""
return self._execute_sync_query(
lambda session: TransferHistory.get_by_transfer_task_id(
session,
task_id=task_id,
)
)
def get_success_by_src(
self, src: str, storage: Optional[str] = None
) -> Optional[TransferHistory]:
@@ -264,33 +278,60 @@ class TransferHistoryOper(DbOper):
def delete(self, historyid):
"""
删除转移记录
删除转移记录失败任务历史由状态机独占
"""
self._stage_delete(TransferHistory, historyid)
self._execute_sync_write(
lambda session: session.execute(
sqlalchemy_delete(TransferHistory).where(
TransferHistory.id == historyid,
TransferHistory.transfer_task_id.is_(None),
)
)
)
def stage_delete(self, historyid: int) -> None:
"""暂存整理记录删除,事务由调用方统一提交。"""
self._db.execute(
sqlalchemy_delete(TransferHistory).where(
TransferHistory.id == historyid
TransferHistory.id == historyid,
TransferHistory.transfer_task_id.is_(None),
)
)
def stage_truncate(self) -> None:
"""暂存全部整理记录删除,由请求级事务统一提交"""
self._db.execute(sqlalchemy_delete(TransferHistory))
"""暂存整理记录删除,只保留当前失败任务历史"""
self._db.execute(
sqlalchemy_delete(TransferHistory).where(
TransferHistory.transfer_task_id.is_(None)
)
)
async def async_delete(self, historyid):
"""
异步删除转移记录
异步删除转移记录失败任务历史由状态机独占
"""
await self._stage_async_delete(TransferHistory, historyid)
async def stage(session: AsyncSession) -> None:
"""在异步事务内只删除没有任务回执的历史。"""
await session.execute(
sqlalchemy_delete(TransferHistory).where(
TransferHistory.id == historyid,
TransferHistory.transfer_task_id.is_(None),
)
)
await self._execute_async_write(stage)
def truncate(self):
"""
清空转移记录
清空转移记录只保留当前失败任务历史
"""
self._stage_truncate(TransferHistory)
self._execute_sync_write(
lambda session: session.execute(
sqlalchemy_delete(TransferHistory).where(
TransferHistory.transfer_task_id.is_(None)
)
)
)
def add_force(self, **kwargs) -> Optional[TransferHistory]:
"""
@@ -327,6 +368,7 @@ class TransferHistoryOper(DbOper):
sqlalchemy_delete(TransferHistory).where(
TransferHistory.src == kwargs.get("src"),
TransferHistory.src_storage == kwargs["src_storage"],
TransferHistory.transfer_task_id.is_(None),
)
)
self._db.flush()
@@ -335,6 +377,47 @@ class TransferHistoryOper(DbOper):
self._db.flush()
return history
def stage_upsert_by_transfer_task_id(
self,
*,
task_id: str,
settlement_revision: int,
retain_task_mapping: bool,
payload: dict[str, Any],
) -> TransferHistory:
"""在调用方事务内按任务标识幂等暂存终态历史。"""
if not isinstance(self._db, Session):
raise RuntimeError("整理历史任务结算需要调用方提供同步 Session")
payload = dict(payload)
payload["src_storage"] = payload.get("src_storage") or "local"
payload["date"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
return TransferHistory.upsert_by_transfer_task_id(
self._db,
task_id=task_id,
settlement_revision=settlement_revision,
retain_task_mapping=retain_task_mapping,
payload=payload,
)
def stage_bind_settlement(
self,
*,
task_id: str,
settlement_revision: int,
src: str,
storage: Optional[str] = None,
) -> Optional[TransferHistory]:
"""复用已有成功历史且清除失败任务映射,不改写业务字段。"""
if not isinstance(self._db, Session):
raise RuntimeError("整理历史任务回执绑定需要调用方提供同步 Session")
history = TransferHistory.get_success_by_src(self._db, src, storage)
if history is None:
return None
history.transfer_task_id = None
history.transfer_settlement_revision = None
self._db.flush()
return history
def update_download_hash(self, historyid, download_hash):
"""
补充转移记录download_hash
+207
View File
@@ -328,3 +328,210 @@ class TransferPendingOper(DbOper):
now_time=now_time,
)
)
def stage_execution_running(
self,
*,
task_id: str,
lease_token: str,
now_utc: str,
updated_at: str,
) -> int:
"""以有效租约暂存任务执行状态为 running。"""
return self._execute_sync_write(
lambda session: TransferPending.stage_execution_running(
session,
task_id=task_id,
lease_token=lease_token,
now_utc=now_utc,
updated_at=updated_at,
)
)
def stage_defer_execution(
self,
*,
task_id: str,
lease_token: str,
error: str,
retry_due_at: str,
now_utc: str,
updated_at: str,
) -> int:
"""暂存重试世代和到期时间,并原子释放当前租约。"""
return self._execute_sync_write(
lambda session: TransferPending.defer_execution(
session,
task_id=task_id,
lease_token=lease_token,
error=error,
retry_due_at=retry_due_at,
now_utc=now_utc,
updated_at=updated_at,
)
)
def stage_mark_execution_manual_review(
self,
*,
task_id: str,
lease_token: str,
error: str,
now_utc: str,
updated_at: str,
) -> int:
"""暂存人工复核隔离状态,并原子释放当前租约。"""
return self._execute_sync_write(
lambda session: TransferPending.mark_execution_manual_review(
session,
task_id=task_id,
lease_token=lease_token,
error=error,
now_utc=now_utc,
updated_at=updated_at,
)
)
def stage_checkpoint_execution(
self,
*,
task_id: str,
lease_token: str,
execution_version: int,
execution_payload: dict[str, Any],
execution_fingerprint: str,
now_utc: str,
updated_at: str,
) -> int:
"""以有效租约暂存聚合执行检查点并进入 settling。"""
return self._execute_sync_write(
lambda session: TransferPending.checkpoint_execution(
session,
task_id=task_id,
lease_token=lease_token,
execution_version=execution_version,
execution_payload=execution_payload,
execution_fingerprint=execution_fingerprint,
now_utc=now_utc,
updated_at=updated_at,
)
)
def stage_checkpoint_exhausted_failure(
self,
*,
task_id: str,
lease_token: str,
execution_version: int,
execution_payload: dict[str, Any],
execution_fingerprint: str,
error: str,
now_utc: str,
updated_at: str,
) -> int:
"""暂存预算耗尽失败检查点,并保留有效 lease 进入 settling。"""
return self._execute_sync_write(
lambda session: TransferPending.checkpoint_exhausted_failure(
session,
task_id=task_id,
lease_token=lease_token,
execution_version=execution_version,
execution_payload=execution_payload,
execution_fingerprint=execution_fingerprint,
error=error,
now_utc=now_utc,
updated_at=updated_at,
)
)
def stage_request_execution_retry(
self,
*,
task_id: str,
reason: str,
requested_by: str,
retry_due_at: str,
updated_at: str,
) -> int:
"""仅将 FAILED 任务暂存为立即到期的 retry_wait。"""
return self._execute_sync_write(
lambda session: TransferPending.request_execution_retry(
session,
task_id=task_id,
reason=reason,
requested_by=requested_by,
retry_due_at=retry_due_at,
updated_at=updated_at,
)
)
def stage_resolve_manual_review(
self,
*,
task_id: str,
decision: str,
actor: str,
reason: str,
retry_due_at: str,
updated_at: str,
) -> int:
"""无 lease 地暂存人工判定审计并交回 retry_wait 调度。"""
return self._execute_sync_write(
lambda session: TransferPending.resolve_manual_review(
session,
task_id=task_id,
decision=decision,
actor=actor,
reason=reason,
retry_due_at=retry_due_at,
updated_at=updated_at,
)
)
def stage_terminal_failure(
self,
*,
task_id: str,
lease_token: str,
execution_fingerprint: str,
expected_revision: int,
history_id: int,
error: Optional[str],
now_utc: str,
updated_at: str,
) -> int:
"""以执行指纹和结算版本暂存失败终态。"""
return self._execute_sync_write(
lambda session: TransferPending.stage_terminal_failure(
session,
task_id=task_id,
lease_token=lease_token,
execution_fingerprint=execution_fingerprint,
expected_revision=expected_revision,
history_id=history_id,
error=error,
now_utc=now_utc,
updated_at=updated_at,
)
)
def stage_delete_terminal_success(
self,
*,
task_id: str,
lease_token: str,
execution_fingerprint: str,
expected_revision: int,
now_utc: str,
) -> int:
"""以执行指纹和结算版本暂存成功 pending 删除。"""
return self._execute_sync_write(
lambda session: TransferPending.delete_terminal_success(
session,
task_id=task_id,
lease_token=lease_token,
execution_fingerprint=execution_fingerprint,
expected_revision=expected_revision,
now_utc=now_utc,
)
)
+71
View File
@@ -0,0 +1,71 @@
"""整理任务终态结算回执的事务内数据访问。"""
from typing import Optional
from app.db.base import DbOper
from app.db.models.transfersettlementreceipt import TransferSettlementReceipt
class TransferSettlementReceiptOper(DbOper):
"""在调用方 Session 内读取和推进 durable 结算回执。"""
def get_latest_by_task_id(
self,
*,
task_id: str,
) -> Optional[TransferSettlementReceipt]:
"""按稳定任务标识读取最新回执。"""
return TransferSettlementReceipt.get_latest_by_task_id(
self._db,
task_id=task_id,
)
def get_by_identity(
self,
*,
task_id: str,
execution_fingerprint: str,
lease_token: str,
outcome: str,
) -> Optional[TransferSettlementReceipt]:
"""按原始执行身份读取不可变回执。"""
return TransferSettlementReceipt.get_by_identity(
self._db,
task_id=task_id,
execution_fingerprint=execution_fingerprint,
lease_token=lease_token,
outcome=outcome,
)
def stage_append(
self,
*,
task_id: str,
history_id: int,
settlement_revision: int,
outcome: str,
execution_fingerprint: str,
lease_token: str,
history_status: bool,
src: Optional[str],
src_storage: Optional[str],
pending_deleted: bool,
error: Optional[str],
settled_at: str,
) -> TransferSettlementReceipt:
"""在调用方事务内按连续修订追加任务结算回执。"""
return TransferSettlementReceipt.stage_append(
self._db,
task_id=task_id,
history_id=history_id,
settlement_revision=settlement_revision,
outcome=outcome,
execution_fingerprint=execution_fingerprint,
lease_token=lease_token,
history_status=history_status,
src=src,
src_storage=src_storage,
pending_deleted=pending_deleted,
error=error,
settled_at=settled_at,
)
+2
View File
@@ -116,6 +116,8 @@
"会话不存在或无权访问": "The conversation does not exist or you do not have access",
"会话保存失败": "Failed to save conversation",
"后台服务不存在": "Background service does not exist",
"当前管理用户缺少可审计身份": "The current administrator does not have an auditable identity",
"人工复核任务不存在": "The manual review task does not exist",
"任务添加失败": "Failed to add task",
"无法识别媒体信息": "Unable to recognize media information",
"未识别到媒体信息": "Unable to recognize media information",
+2
View File
@@ -100,6 +100,8 @@
}
},
"messages": {
"当前管理用户缺少可审计身份": "当前管理用户缺少可审计身份",
"人工复核任务不存在": "人工复核任务不存在",
"媒体来源和媒体 ID 必须同时提供": "媒体来源和媒体 ID 必须同时提供",
"media_source 和 media_id 必须同时提供": "media_source 和 media_id 必须同时提供",
"模块不支持测试": "模块不支持测试",
+2
View File
@@ -112,6 +112,8 @@
"会话不存在或无权访问": "會話不存在或無權存取",
"会话保存失败": "會話儲存失敗",
"后台服务不存在": "背景服務不存在",
"当前管理用户缺少可审计身份": "目前管理使用者缺少可稽核身分",
"人工复核任务不存在": "人工複核任務不存在",
"任务添加失败": "任務新增失敗",
"无法识别媒体信息": "無法識別媒體資訊",
"未识别到媒体信息": "未識別到媒體資訊",
+11
View File
@@ -5,6 +5,7 @@ from app.adapters.system.host import SystemUtils
from app.application.directory import DirectoryHelper
from app.application.messaging.message import MessageHelper
from app.application.transfer import TransferPlanCheckpoint, TransferPlanningInput
from app.application.transfer_execution import TransferStepRunner
from app.domain.context import MediaInfo, MusicInfo
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic
@@ -542,10 +543,13 @@ class FileManagerModule(_ModuleBase):
source_oper: Optional[StorageBase] = None,
target_oper: Optional[StorageBase] = None,
cleanup_media_file: Optional[Callable[[FileItem], bool]] = None,
observe_cleanup_media_file: Optional[Callable[[FileItem], bool]] = None,
step_runner: Optional[TransferStepRunner] = None,
) -> TransferInfo:
"""解析存储适配器并通过统一删除能力执行已冻结计划。"""
source_fileitem = FileItem(**checkpoint.planning_input.source_fileitem)
cleanup_before_transfer = None
observe_cleanup_before_transfer = None
cleanup_payload = checkpoint.planning_input.options.get(
"cleanup_dest_fileitem"
)
@@ -565,6 +569,11 @@ class FileManagerModule(_ModuleBase):
f"{cleanup_fileitem.path} 删除失败,整理计划保留待重试"
)
if observe_cleanup_media_file:
def observe_cleanup_before_transfer() -> bool:
"""只读确认旧目标是否已经由统一能力清理。"""
return observe_cleanup_media_file(cleanup_fileitem)
source_storage = source_fileitem.storage or "local"
if not source_oper:
source_oper = self.__get_storage_oper(source_storage)
@@ -595,6 +604,8 @@ class FileManagerModule(_ModuleBase):
source_oper=source_oper,
target_oper=target_oper,
cleanup_before_transfer=cleanup_before_transfer,
observe_cleanup_before_transfer=observe_cleanup_before_transfer,
step_runner=step_runner,
)
@staticmethod
+659 -35
View File
@@ -1,3 +1,4 @@
import filecmp
import re
from copy import deepcopy
from pathlib import Path
@@ -14,6 +15,12 @@ from app.application.transfer import (
TransferPlanItem,
TransferPlanningInput,
)
from app.application.transfer_execution import (
TransferOperationObservation,
TransferOperationObservationState,
TransferStepResult,
TransferStepRunner,
)
from app.domain.context import MediaInfo, MusicInfo
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic
@@ -625,6 +632,456 @@ class TransHandler:
)
return False, False, None
@staticmethod
def __serialize_step_item(fileitem: Optional[FileItem]) -> Optional[dict[str, Any]]:
"""把步骤结果中的文件投影冻结为 JSON 对象。"""
return fileitem.model_dump(mode="json") if fileitem else None
@staticmethod
def __restore_step_item(result: TransferStepResult) -> Optional[FileItem]:
"""从已持久成功证据恢复目标文件,不再次访问外部存储。"""
payload = result.payload.get("item")
return FileItem.model_validate(payload) if isinstance(payload, dict) else None
@staticmethod
def __observe_item_presence(
storage_oper: StorageBase,
path: Path,
*,
applied_when_present: bool,
) -> TransferOperationObservation:
"""以严格查询判断目标存在性,查询异常一律视为未知。"""
try:
item = storage_oper.get_item_strict(path)
except Exception as error:
return TransferOperationObservation(
state=TransferOperationObservationState.UNKNOWN,
evidence=TransferStepResult(payload={
"path": path.as_posix(),
"query_error": str(error),
}),
)
exists = item is not None
applied = exists if applied_when_present else not exists
return TransferOperationObservation(
state=(
TransferOperationObservationState.APPLIED
if applied
else TransferOperationObservationState.NOT_APPLIED
),
evidence=TransferStepResult(payload={
"path": path.as_posix(),
"exists": exists,
"item": TransHandler.__serialize_step_item(item),
}),
)
@staticmethod
def __run_persisted_step(
step_runner: Optional[TransferStepRunner],
*,
phase: str,
kind: str,
payload: dict[str, Any],
execute: Callable[[], TransferStepResult],
observe: Callable[[], TransferOperationObservation],
) -> TransferStepResult:
"""在持久任务中委托步骤账本,旧同步调用则直接执行。"""
if step_runner is None:
return execute()
return step_runner.run(
phase=phase,
kind=kind,
payload=payload,
execute=execute,
observe=observe,
)
@staticmethod
def __observe_transfer_operation(
*,
fileitem: FileItem,
target_storage: str,
source_oper: StorageBase,
target_oper: StorageBase,
target_file: Path,
transfer_type: str,
) -> TransferOperationObservation:
"""对遗留传输尝试作保守判定,证据不足时禁止自动重放。"""
try:
source_item = source_oper.get_item_strict(Path(cast(str, fileitem.path)))
target_item = target_oper.get_item_strict(target_file)
except Exception as error:
return TransferOperationObservation(
state=TransferOperationObservationState.UNKNOWN,
evidence=TransferStepResult(payload={"query_error": str(error)}),
)
source_exists = source_item is not None
target_exists = target_item is not None
evidence = TransferStepResult(payload={
"source_exists": source_exists,
"target_exists": target_exists,
"item": TransHandler.__serialize_step_item(target_item),
})
if transfer_type == "move":
if not source_exists and target_exists:
return TransferOperationObservation(
state=TransferOperationObservationState.APPLIED,
evidence=evidence,
)
if source_exists and not target_exists:
return TransferOperationObservation(
state=TransferOperationObservationState.NOT_APPLIED,
evidence=evidence,
)
return TransferOperationObservation(
state=TransferOperationObservationState.CONFLICT,
evidence=evidence,
)
if not target_exists:
return TransferOperationObservation(
state=TransferOperationObservationState.NOT_APPLIED,
evidence=evidence,
)
if fileitem.storage != "local" or target_storage != "local":
return TransferOperationObservation(
state=TransferOperationObservationState.UNKNOWN,
evidence=evidence,
)
source_path = Path(cast(str, fileitem.path))
try:
if transfer_type == "copy":
applied = source_path.is_file() and filecmp.cmp(
source_path, target_file, shallow=False
)
elif transfer_type == "link":
applied = source_path.is_file() and target_file.samefile(source_path)
elif transfer_type == "softlink":
applied = target_file.is_symlink() and target_file.resolve() == source_path.resolve()
else:
return TransferOperationObservation(
state=TransferOperationObservationState.UNKNOWN,
evidence=evidence,
)
except OSError as error:
return TransferOperationObservation(
state=TransferOperationObservationState.UNKNOWN,
evidence=TransferStepResult(payload={
**evidence.payload,
"verification_error": str(error),
}),
)
return TransferOperationObservation(
state=(
TransferOperationObservationState.APPLIED
if applied
else TransferOperationObservationState.CONFLICT
),
evidence=evidence,
)
@classmethod
def __execute_transfer_with_steps(
cls,
*,
step_runner: Optional[TransferStepRunner],
fileitem: FileItem,
target_storage: str,
source_oper: StorageBase,
target_oper: StorageBase,
target_file: Path,
transfer_type: str,
) -> tuple[Optional[FileItem], str]:
"""执行稳定传输步骤,并把跨存储 move 拆为落地与源删除。"""
cross_storage_move = (
transfer_type == "move" and fileitem.storage != target_storage
)
materialize_type = "copy" if cross_storage_move else transfer_type
intent_payload = {
"source": fileitem.model_dump(mode="json"),
"target_storage": target_storage,
"target_path": target_file.as_posix(),
"transfer_type": materialize_type,
}
def execute_materialize() -> TransferStepResult:
"""执行一次目标落地并冻结其返回对象。"""
new_item, error = cls.__transfer_command(
fileitem=fileitem,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=target_file,
transfer_type=materialize_type,
)
if not new_item:
raise RuntimeError(error or f"{fileitem.path} 整理失败")
return TransferStepResult(payload={
"item": cls.__serialize_step_item(new_item),
"message": error,
})
materialized = cls.__run_persisted_step(
step_runner,
phase="transfer",
kind="materialize_target",
payload=intent_payload,
execute=execute_materialize,
observe=lambda: cls.__observe_transfer_operation(
fileitem=fileitem,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=target_file,
transfer_type=materialize_type,
),
)
new_item = cls.__restore_step_item(materialized)
if not new_item:
return None, "整理步骤成功证据缺少目标文件"
if not cross_storage_move:
return new_item, str(materialized.payload.get("message") or "")
def execute_source_delete() -> TransferStepResult:
"""在目标已落地后单独删除跨存储 move 的源文件。"""
if not source_oper.delete(fileitem):
raise RuntimeError(f"{fileitem.path} 源文件删除失败")
return TransferStepResult(payload={
"source_path": fileitem.path,
"deleted": True,
})
cls.__run_persisted_step(
step_runner,
phase="transfer",
kind="delete_move_source",
payload={
"source": fileitem.model_dump(mode="json"),
"target_storage": target_storage,
"target_path": target_file.as_posix(),
},
execute=execute_source_delete,
observe=lambda: cls.__observe_item_presence(
source_oper,
Path(cast(str, fileitem.path)),
applied_when_present=False,
),
)
return new_item, str(materialized.payload.get("message") or "")
@classmethod
def __ensure_directory_with_step(
cls,
*,
step_runner: Optional[TransferStepRunner],
target_oper: StorageBase,
target_storage: str,
path: Path,
) -> Optional[FileItem]:
"""持久记录可能创建目录的 get_folder 操作并恢复其结果。"""
def execute() -> TransferStepResult:
"""获取或创建目标目录并冻结目录对象。"""
directory = target_oper.get_folder(path)
if not directory:
raise RuntimeError(f"目标目录 {path} 获取失败")
return TransferStepResult(payload={
"item": cls.__serialize_step_item(directory),
})
result = cls.__run_persisted_step(
step_runner,
phase="prepare",
kind="ensure_target_directory",
payload={"storage": target_storage, "path": path.as_posix()},
execute=execute,
observe=lambda: cls.__observe_item_presence(
target_oper,
path,
applied_when_present=True,
),
)
return cls.__restore_step_item(result)
@classmethod
def __cleanup_with_step(
cls,
*,
step_runner: Optional[TransferStepRunner],
cleanup: Optional[Callable[[], None]],
observe_cleanup: Optional[Callable[[], bool]],
source_path: str,
) -> None:
"""把兼容清理能力纳入步骤账本,未知遗留结果必须人工复核。"""
if cleanup is None:
return
def execute() -> TransferStepResult:
"""执行统一旧目标清理能力。"""
cleanup()
return TransferStepResult(payload={"cleaned": True})
def observe() -> TransferOperationObservation:
"""通过只读兼容能力确认旧目标是否已经消失。"""
if observe_cleanup is None:
return TransferOperationObservation(
state=TransferOperationObservationState.UNKNOWN,
evidence=TransferStepResult(payload={
"reason": "cleanup observer unavailable",
}),
)
try:
cleaned = observe_cleanup()
except Exception as error:
return TransferOperationObservation(
state=TransferOperationObservationState.UNKNOWN,
evidence=TransferStepResult(payload={"query_error": str(error)}),
)
return TransferOperationObservation(
state=(
TransferOperationObservationState.APPLIED
if cleaned
else TransferOperationObservationState.NOT_APPLIED
),
evidence=TransferStepResult(payload={"cleaned": cleaned}),
)
cls.__run_persisted_step(
step_runner,
phase="prepare",
kind="cleanup_previous_destination",
payload={"source_path": source_path},
execute=execute,
observe=observe,
)
@classmethod
def __delete_target_with_step(
cls,
*,
step_runner: Optional[TransferStepRunner],
target_oper: StorageBase,
target_storage: str,
target_file: Path,
) -> None:
"""幂等删除覆盖目标,并持久记录删除意图和严格存在性证据。"""
def execute() -> TransferStepResult:
"""只删除当前冻结目标,目标已不存在视为成功。"""
current = target_oper.get_item_strict(target_file)
if current is not None and not target_oper.delete(current):
raise RuntimeError(f"{target_storage}{target_file} 删除失败")
return TransferStepResult(payload={"deleted": True})
cls.__run_persisted_step(
step_runner,
phase="prepare",
kind="delete_overwrite_target",
payload={"storage": target_storage, "path": target_file.as_posix()},
execute=execute,
observe=lambda: cls.__observe_item_presence(
target_oper,
target_file,
applied_when_present=False,
),
)
def __resolve_overwrite_with_step(
self,
*,
step_runner: Optional[TransferStepRunner],
fileitem: FileItem,
meta: MetaBase,
mediainfo: MediaInfo | MusicInfo,
target_oper: StorageBase,
target_storage: str,
target_file: Path,
transfer_type: str,
overwrite_mode: Optional[str],
need_notify: bool,
) -> tuple[bool, bool, Optional[TransferInfo]]:
"""冻结覆盖策略判定,避免目标变化后重启得到不同步骤序列。"""
def execute() -> TransferStepResult:
"""执行一次覆盖策略判定并冻结完整裁决。"""
over_flag, delete_versions, failure = self.__resolve_overwrite(
fileitem=fileitem,
meta=meta,
mediainfo=mediainfo,
target_oper=target_oper,
target_storage=target_storage,
target_file=target_file,
transfer_type=transfer_type,
overwrite_mode=overwrite_mode,
need_notify=need_notify,
)
return TransferStepResult(payload={
"over_flag": over_flag,
"delete_versions": delete_versions,
"failure": failure.model_dump(mode="json") if failure else None,
})
result = self.__run_persisted_step(
step_runner,
phase="decision",
kind="resolve_overwrite",
payload={
"source": fileitem.model_dump(mode="json"),
"target_storage": target_storage,
"target_path": target_file.as_posix(),
"transfer_type": transfer_type,
"overwrite_mode": overwrite_mode,
"need_notify": need_notify,
},
execute=execute,
observe=lambda: TransferOperationObservation(
state=TransferOperationObservationState.NOT_APPLIED,
evidence=TransferStepResult(payload={
"reason": "read-only overwrite decision may be repeated",
}),
),
)
failure_payload = result.payload.get("failure")
return (
bool(result.payload.get("over_flag")),
bool(result.payload.get("delete_versions")),
(
TransferInfo.model_validate(failure_payload)
if isinstance(failure_payload, dict)
else None
),
)
def __intercept_with_step(
self,
*,
step_runner: Optional[TransferStepRunner],
payload: dict[str, Any],
invoke: Callable[[], tuple[bool, str]],
) -> tuple[bool, str]:
"""冻结插件拦截裁决;遗留未回执调用因插件不透明而转人工复核。"""
def execute() -> TransferStepResult:
"""执行插件拦截并冻结允许标记与原因。"""
allowed, reason = invoke()
return TransferStepResult(payload={
"allowed": allowed,
"reason": reason,
})
result = self.__run_persisted_step(
step_runner,
phase="decision",
kind="plugin_transfer_intercept",
payload=payload,
execute=execute,
observe=lambda: TransferOperationObservation(
state=TransferOperationObservationState.UNKNOWN,
evidence=TransferStepResult(payload={
"reason": "plugin intercept has no stable invocation receipt",
}),
),
)
return bool(result.payload.get("allowed")), str(result.payload.get("reason") or "")
def execute_transfer_plan(
self,
checkpoint: TransferPlanCheckpoint,
@@ -634,6 +1091,8 @@ class TransHandler:
source_oper: StorageBase,
target_oper: StorageBase,
cleanup_before_transfer: Optional[Callable[[], None]] = None,
observe_cleanup_before_transfer: Optional[Callable[[], bool]] = None,
step_runner: Optional[TransferStepRunner] = None,
) -> TransferInfo:
"""只消费冻结目标和有序操作,并在执行期处理覆盖与插件拦截。"""
fileitem = FileItem(**checkpoint.planning_input.source_fileitem)
@@ -684,7 +1143,7 @@ class TransHandler:
)
if fileitem.type == "dir":
stream_path = Path(fileitem.path) / "BDMV" / "STREAM"
stream_path = Path(cast(str, fileitem.path)) / "BDMV" / "STREAM"
stream_sizes = [
FileItem(**item.source_fileitem).size or 0
for item in checkpoint.items
@@ -693,13 +1152,22 @@ class TransHandler:
]
if stream_sizes:
fileitem.size = sum(stream_sizes)
allowed, reason = self.__intercept_transfer(
fileitem=fileitem,
meta=meta,
mediainfo=mediainfo,
target_storage=target_storage,
target_path=target_path,
transfer_type=transfer_type,
allowed, reason = self.__intercept_with_step(
step_runner=step_runner,
payload={
"source": fileitem.model_dump(mode="json"),
"target_storage": target_storage,
"target_path": target_path.as_posix(),
"transfer_type": transfer_type,
},
invoke=lambda: self.__intercept_transfer(
fileitem=fileitem,
meta=meta,
mediainfo=mediainfo,
target_storage=target_storage,
target_path=target_path,
transfer_type=transfer_type,
),
)
if not allowed:
return TransferInfo(
@@ -709,9 +1177,18 @@ class TransHandler:
transfer_type=transfer_type,
need_notify=checkpoint.need_notify,
)
if cleanup_before_transfer:
cleanup_before_transfer()
target_diritem = target_oper.get_folder(target_path)
self.__cleanup_with_step(
step_runner=step_runner,
cleanup=cleanup_before_transfer,
observe_cleanup=observe_cleanup_before_transfer,
source_path=cast(str, fileitem.path),
)
target_diritem = self.__ensure_directory_with_step(
step_runner=step_runner,
target_oper=target_oper,
target_storage=target_storage,
path=target_path,
)
if not target_diritem:
return TransferInfo(
success=False,
@@ -733,7 +1210,8 @@ class TransHandler:
need_notify=checkpoint.need_notify,
)
source_item = FileItem(**planned_item.source_fileitem)
new_item, error = self.__transfer_command(
new_item, error = self.__execute_transfer_with_steps(
step_runner=step_runner,
fileitem=source_item,
target_storage=planned_item.target_storage,
source_oper=source_oper,
@@ -790,7 +1268,8 @@ class TransHandler:
need_notify=checkpoint.need_notify,
)
target_file = Path(planned_item.target_path)
over_flag, delete_versions, overwrite_failure = self.__resolve_overwrite(
over_flag, delete_versions, overwrite_failure = self.__resolve_overwrite_with_step(
step_runner=step_runner,
fileitem=fileitem,
meta=meta,
mediainfo=mediainfo,
@@ -803,14 +1282,24 @@ class TransHandler:
)
if overwrite_failure:
return overwrite_failure
allowed, reason = self.__intercept_transfer(
fileitem=fileitem,
meta=meta,
mediainfo=mediainfo,
target_storage=target_storage,
target_path=target_file,
transfer_type=transfer_type,
over_flag=over_flag,
allowed, reason = self.__intercept_with_step(
step_runner=step_runner,
payload={
"source": fileitem.model_dump(mode="json"),
"target_storage": target_storage,
"target_path": target_file.as_posix(),
"transfer_type": transfer_type,
"over_flag": over_flag,
},
invoke=lambda: self.__intercept_transfer(
fileitem=fileitem,
meta=meta,
mediainfo=mediainfo,
target_storage=target_storage,
target_path=target_file,
transfer_type=transfer_type,
over_flag=over_flag,
),
)
if not allowed:
return TransferInfo(
@@ -822,9 +1311,18 @@ class TransHandler:
need_notify=checkpoint.need_notify,
)
if cleanup_before_transfer:
cleanup_before_transfer()
target_diritem = target_oper.get_folder(target_file.parent)
self.__cleanup_with_step(
step_runner=step_runner,
cleanup=cleanup_before_transfer,
observe_cleanup=observe_cleanup_before_transfer,
source_path=cast(str, fileitem.path),
)
target_diritem = self.__ensure_directory_with_step(
step_runner=step_runner,
target_oper=target_oper,
target_storage=target_storage,
path=target_file.parent,
)
if not target_diritem:
return TransferInfo(
success=False,
@@ -835,17 +1333,51 @@ class TransHandler:
need_notify=checkpoint.need_notify,
)
if delete_versions:
self.__delete_version_files(target_oper, target_file)
new_item, error = self.__transfer_file(
fileitem=fileitem,
target_storage=target_storage,
target_file=target_file,
transfer_type=transfer_type,
over_flag=over_flag,
source_oper=source_oper,
target_oper=target_oper,
result=result,
)
if step_runner is None:
self.__delete_version_files(target_oper, target_file)
else:
self.__delete_version_files_with_steps(
step_runner=step_runner,
storage_oper=target_oper,
target_storage=target_storage,
path=target_file,
)
if step_runner is None:
new_item, error = self.__transfer_file(
fileitem=fileitem,
target_storage=target_storage,
target_file=target_file,
transfer_type=transfer_type,
over_flag=over_flag,
source_oper=source_oper,
target_oper=target_oper,
result=result,
)
else:
if over_flag:
self.__delete_target_with_step(
step_runner=step_runner,
target_oper=target_oper,
target_storage=target_storage,
target_file=target_file,
)
new_item, error = self.__execute_transfer_with_steps(
step_runner=step_runner,
fileitem=fileitem,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=target_file,
transfer_type=transfer_type,
)
if new_item:
self.__update_result(
result=result,
file_list=[fileitem.path],
file_list_new=[new_item.path],
file_count=1,
total_size=fileitem.size,
)
if not new_item:
error = error or f"{fileitem.path} 整理后未获取到目标文件信息"
return TransferInfo(
@@ -1286,6 +1818,98 @@ class TransHandler:
naming_context.pop("media_id", None)
return naming_context
@staticmethod
def __find_version_files(
storage_oper: StorageBase,
path: Path,
) -> list[FileItem]:
"""稳定列出与冻结目标相同季集和 Part 的其它视频版本。"""
meta = MetaInfoPath(path)
parent_item = storage_oper.get_item_strict(path.parent)
if not parent_item:
return []
media_files = storage_oper.list(parent_item) or []
result: list[FileItem] = []
for media_file in media_files:
media_path = Path(media_file.path)
if media_path == path or media_file.type != "file":
continue
if f".{cast(str, media_file.extension).lower()}" not in get_runtime_setting('RMT_MEDIAEXT'):
continue
filemeta = MetaInfoPath(media_path)
if filemeta.season != meta.season or filemeta.episode != meta.episode:
continue
if meta.part and filemeta.part and filemeta.part != meta.part:
continue
result.append(media_file)
return sorted(result, key=lambda item: (item.path, item.fileid or ""))
@classmethod
def __delete_version_files_with_steps(
cls,
*,
step_runner: TransferStepRunner,
storage_oper: StorageBase,
target_storage: str,
path: Path,
) -> None:
"""先冻结版本清单,再把每一个删除作为独立稳定步骤执行。"""
def discover() -> TransferStepResult:
"""读取并冻结当前版本删除候选,读失败不产生副作用。"""
candidates = cls.__find_version_files(storage_oper, path)
return TransferStepResult(payload={
"items": [item.model_dump(mode="json") for item in candidates],
})
discovery = cls.__run_persisted_step(
step_runner,
phase="prepare",
kind="discover_version_targets",
payload={"storage": target_storage, "path": path.as_posix()},
execute=discover,
observe=lambda: TransferOperationObservation(
state=TransferOperationObservationState.NOT_APPLIED,
evidence=TransferStepResult(payload={
"reason": "read-only discovery may be repeated",
}),
),
)
raw_items = discovery.payload.get("items")
if not isinstance(raw_items, list):
raise RuntimeError("版本删除候选检查点格式无效")
for raw_item in raw_items:
candidate = FileItem.model_validate(raw_item)
def delete_candidate(item: FileItem = candidate) -> TransferStepResult:
"""删除一个冻结版本候选,已不存在时保持幂等成功。"""
current = storage_oper.get_item_strict(Path(cast(str, item.path)))
if current is not None and not storage_oper.delete(current):
raise RuntimeError(f"版本文件 {item.path} 删除失败")
return TransferStepResult(payload={
"path": item.path,
"deleted": True,
})
def observe_candidate(item: FileItem = candidate) -> TransferOperationObservation:
"""查询一个冻结版本候选是否已经删除。"""
return cls.__observe_item_presence(
storage_oper,
Path(cast(str, item.path)),
applied_when_present=False,
)
cls.__run_persisted_step(
step_runner,
phase="prepare",
kind="delete_version_target",
payload={
"storage": target_storage,
"item": candidate.model_dump(mode="json"),
},
execute=delete_candidate,
observe=observe_candidate,
)
@staticmethod
def __delete_version_files(storage_oper: StorageBase, path: Path) -> bool:
"""
+10 -4
View File
@@ -417,6 +417,12 @@ SCHEMA_EXPORTS = {
'TransferInterceptEventData': ('app.schemas.event', 'TransferInterceptEventData'),
'TransferJob': ('app.schemas.transfer', 'TransferJob'),
'TransferJobTask': ('app.schemas.transfer', 'TransferJobTask'),
'TransferManualReviewData': ('app.schemas.transfer', 'TransferManualReviewData'),
'TransferManualReviewPageData': ('app.schemas.transfer', 'TransferManualReviewPageData'),
'TransferManualReviewRequest': ('app.schemas.transfer', 'TransferManualReviewRequest'),
'TransferManualReviewSourceData': ('app.schemas.transfer', 'TransferManualReviewSourceData'),
'TransferManualReviewStepData': ('app.schemas.transfer', 'TransferManualReviewStepData'),
'TransferManualReviewTaskData': ('app.schemas.transfer', 'TransferManualReviewTaskData'),
'TransferOverwriteCheckEventData': ('app.schemas.event', 'TransferOverwriteCheckEventData'),
'TransferRenameBuildEventData': ('app.schemas.event', 'TransferRenameBuildEventData'),
'TransferRenameEventData': ('app.schemas.event', 'TransferRenameEventData'),
@@ -459,7 +465,7 @@ SCHEMA_EXPORTS = {
'field_serializer': ('app.schemas.agent', 'field_serializer'),
'field_validator': ('app.schemas.system', 'field_validator'),
'json': ('app.schemas.subscribe', 'json'),
'model_validator': ('app.schemas.subscribe', 'model_validator'),
'model_validator': ('app.schemas.transfer', 'model_validator'),
're': ('app.schemas.file', 're'),
}
@@ -474,9 +480,9 @@ SCHEMA_CONFLICTS = {
'Field': ['app.schemas.agent', 'app.schemas.cache', 'app.schemas.common', 'app.schemas.context', 'app.schemas.dashboard', 'app.schemas.download', 'app.schemas.event', 'app.schemas.file', 'app.schemas.history', 'app.schemas.llm', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.mfa', 'app.schemas.music', 'app.schemas.notification', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.storage', 'app.schemas.openai', 'app.schemas.servarr', 'app.schemas.servcookie', 'app.schemas.site', 'app.schemas.subscribe', 'app.schemas.system', 'app.schemas.tmdb', 'app.schemas.token', 'app.schemas.transfer', 'app.schemas.user', 'app.schemas.workflow', 'app.schemas.mcp'],
'FileItem': ['app.schemas.event', 'app.schemas.file', 'app.schemas.transfer', 'app.schemas.workflow'],
'FilterRuleGroup': ['app.schemas.rule', 'app.schemas.system'],
'JsonData': ['app.schemas.agent', 'app.schemas.common', 'app.schemas.context', 'app.schemas.dashboard', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.mfa', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.openai', 'app.schemas.servcookie', 'app.schemas.site', 'app.schemas.user', 'app.schemas.workflow', 'app.schemas.mcp'],
'JsonData': ['app.schemas.agent', 'app.schemas.common', 'app.schemas.context', 'app.schemas.dashboard', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.mfa', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.openai', 'app.schemas.servcookie', 'app.schemas.site', 'app.schemas.transfer', 'app.schemas.user', 'app.schemas.workflow', 'app.schemas.mcp'],
'List': ['app.schemas.agent', 'app.schemas.context', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.monitoring', 'app.schemas.plugin', 'app.schemas.openai', 'app.schemas.subscribe', 'app.schemas.transfer', 'app.schemas.workflow'],
'Literal': ['app.schemas.agent', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.servcookie', 'app.schemas.system', 'app.schemas.mcp'],
'Literal': ['app.schemas.agent', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.servcookie', 'app.schemas.system', 'app.schemas.transfer', 'app.schemas.mcp'],
'LocaleHelper': ['app.schemas.dashboard', 'app.schemas.response'],
'MediaInfo': ['app.schemas.context', 'app.schemas.system', 'app.schemas.transfer', 'app.schemas.workflow'],
'MediaSource': ['app.schemas.cache', 'app.schemas.context', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.music', 'app.schemas.subscribe', 'app.schemas.transfer'],
@@ -502,5 +508,5 @@ SCHEMA_CONFLICTS = {
'dataclass': ['app.schemas.notification', 'app.schemas.system'],
'datetime': ['app.schemas.agent', 'app.schemas.monitoring'],
'field_validator': ['app.schemas.event', 'app.schemas.message', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.response', 'app.schemas.subscribe', 'app.schemas.system'],
'model_validator': ['app.schemas.dashboard', 'app.schemas.event', 'app.schemas.mediaserver', 'app.schemas.subscribe'],
'model_validator': ['app.schemas.dashboard', 'app.schemas.event', 'app.schemas.mediaserver', 'app.schemas.subscribe', 'app.schemas.transfer'],
}
+2
View File
@@ -69,6 +69,8 @@ class TransferHistory(OptionalMediaIdentityMixin, BaseModel):
# ID
id: int
# durable 整理任务标识仅供宿主入口选择重试协议,不属于公开历史响应
transfer_task_id: Optional[str] = Field(default=None, exclude=True)
# 源存储类型
src_storage: Optional[str] = None
# 目标存储类型
+72 -2
View File
@@ -1,8 +1,9 @@
from pathlib import Path
from typing import List, Optional, Union
from typing import Literal, List, Optional, Union
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.schemas.common import JsonData
from app.schemas.media import OptionalMediaIdentityMixin
from app.schemas.types import MediaSource, MusicTargetEntityType
@@ -157,6 +158,75 @@ class TransferInfo(BaseModel):
return dicts
class TransferManualReviewRequest(BaseModel): # type: ignore[misc]
"""人工判定外部结果不确定整理步骤的请求。"""
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
operation_id: str = Field(min_length=1, description="待判定的稳定操作标识")
decision: Literal["not_applied", "applied"] = Field(
description="人工判定;不公开 failed,失败终态只能由 durable 结算写入",
)
reason: str = Field(min_length=1, max_length=2000, description="人工判定理由")
result_payload: Optional[dict[str, JsonData]] = Field(
default=None,
description="判定为 applied 时必填的外部结果证据",
)
@model_validator(mode="after") # type: ignore[misc]
def validate_result_payload(self) -> "TransferManualReviewRequest":
"""要求已发生判定携带可持久化的结果证据。"""
if self.decision == "applied" and self.result_payload is None:
raise ValueError("判定为 applied 时必须提供 result_payload")
return self
class TransferManualReviewData(BaseModel): # type: ignore[misc]
"""人工复核提交后的公开状态投影。"""
task_id: str
operation_id: str
decision: Literal["not_applied", "applied"]
state: str
review_revision: int
class TransferManualReviewSourceData(BaseModel): # type: ignore[misc]
"""人工复核任务的源文件身份。"""
storage: str
path: str
class TransferManualReviewStepData(BaseModel): # type: ignore[misc]
"""人工复核步骤的公开意图与事实证据。"""
operation_id: str
kind: str
intent: dict[str, JsonData]
evidence: Optional[dict[str, JsonData]] = None
error: Optional[str] = None
class TransferManualReviewTaskData(BaseModel): # type: ignore[misc]
"""可由管理员发现和判定的 durable 整理任务。"""
task_id: str
source: TransferManualReviewSourceData
state: Literal["manual_review", "retry_wait"]
step: TransferManualReviewStepData
review_revision: int
class TransferManualReviewPageData(BaseModel): # type: ignore[misc]
"""人工复核任务分页结果。"""
items: list[TransferManualReviewTaskData] = Field(default_factory=list)
total: int = 0
page: int = 1
page_size: int = 30
class EpisodeFormat(BaseModel):
"""
剧集自定义识别格式
+43 -9
View File
@@ -1,21 +1,55 @@
"""兼容旧 ``app.db.transferpending_oper`` 的无 Session 数据访问接口。"""
from datetime import datetime
from typing import List, Optional, Tuple
from typing import Any, List, Optional, Tuple
from uuid import uuid4
from sqlalchemy import delete, select
from sqlalchemy import column, delete, exists, select, table
from app.db.base import DbOper, execute_dml
from app.db.models.transferpending import TransferPending as _TransferPending
_TRANSFER_EXECUTION_STEP = table("transferexecutionstep", column("task_id"))
_TRANSFER_HISTORY = table("transferhistory", column("transfer_task_id"))
def _safe_legacy_delete_predicates() -> tuple[Any, ...]:
"""只允许旧接口删除从未 claim、执行或结算的新鲜登记。"""
return (
_TransferPending.lease_owner.is_(None),
_TransferPending.lease_token.is_(None),
_TransferPending.lease_expires_at.is_(None),
_TransferPending.heartbeat_at.is_(None),
_TransferPending.attempt_count == 0,
_TransferPending.execution_state == "not_started",
_TransferPending.execution_version.is_(None),
_TransferPending.execution_payload.is_(None),
_TransferPending.execution_fingerprint.is_(None),
_TransferPending.retry_generation == 0,
_TransferPending.retry_count == 0,
_TransferPending.retry_due_at.is_(None),
_TransferPending.settlement_revision == 0,
_TransferPending.terminal_history_id.is_(None),
_TransferPending.last_error.is_(None),
~exists(
select(_TRANSFER_EXECUTION_STEP.c.task_id).where(
_TRANSFER_EXECUTION_STEP.c.task_id == _TransferPending.task_id
)
),
~exists(
select(_TRANSFER_HISTORY.c.transfer_task_id).where(
_TRANSFER_HISTORY.c.transfer_task_id == _TransferPending.task_id
)
),
)
class TransferPendingOper(DbOper):
"""
保留旧待整理登记 ABI并对执行租约实施兼容写 fencing
本类只由精确旧导入映射加载宿主整理链仍使用 Application Port 和显式
Session canonical Oper旧删除入口只能处理从未取得租约的记录
Session canonical Oper旧删除入口只能处理无任何执行证据的新鲜登记
"""
def register(self, storage: str, src_path: str) -> Optional[_TransferPending]:
@@ -123,10 +157,10 @@ class TransferPendingOper(DbOper):
def discard(self, storage: str, src_path: str) -> int:
"""
按旧签名删除未 claim 的指定登记
按旧签名删除 claim 且没有执行证据的指定登记
任何 token 的记录都由当前租约拥有者通过 fenced canonical API 收口
即使租约已经过期旧插件也不得越权代替恢复调度器删除
任何租约重试步骤或终态证据都归 canonical 状态机所有即使租约
已经过期旧插件也不得越权代替恢复调度器删除
:param storage: 存储
:param src_path: 源文件路径
:return: 删除的记录数
@@ -139,7 +173,7 @@ class TransferPendingOper(DbOper):
delete(_TransferPending).where(
_TransferPending.storage == storage,
_TransferPending.src_path == src_path,
_TransferPending.lease_token.is_(None),
*_safe_legacy_delete_predicates(),
),
execution_options={"synchronize_session": False},
)
@@ -167,7 +201,7 @@ class TransferPendingOper(DbOper):
def clear(self) -> int:
"""
清空全部 claim 登记保留任何带租约 token 的任务
清空 claim 且没有执行证据的登记保留状态机拥有的任务
:return: 删除的记录数
"""
@@ -175,7 +209,7 @@ class TransferPendingOper(DbOper):
lambda session: execute_dml(
session,
delete(_TransferPending).where(
_TransferPending.lease_token.is_(None),
*_safe_legacy_delete_predicates(),
),
execution_options={"synchronize_session": False},
)
+6
View File
@@ -111,6 +111,9 @@ from app.db.adapters.site import TransactionalSiteRepository
from app.db.adapters.subscription import TransactionalSubscribeWriter
from app.db.adapters.transaction import TransactionalWriteRunner
from app.db.adapters.transfer import TransactionalTransferAdmissionRepository
from app.db.adapters.transfer_execution import (
TransactionalTransferExecutionRepository,
)
from app.db.adapters.workflow import TransactionalWorkflowExecutionService
from app.db.oper.agentchat import AgentChatOper
from app.db.oper.agenttask import AgentTaskOper
@@ -865,6 +868,9 @@ async def init_modules() -> HostRuntime:
transfer_pending=lambda: TransactionalTransferAdmissionRepository(
SessionFactory
),
transfer_execution=lambda: TransactionalTransferExecutionRepository(
SessionFactory
),
media_server=lambda: MediaServerOper(),
download_failure=lambda: TransactionalDownloadFailureRepository(
SessionFactory
+856
View File
@@ -0,0 +1,856 @@
"""3.0.16 增加整理步骤执行证据与幂等终态结算字段。
Revision ID: e5c7a9b1d3f6
Revises: d3a9e5f7b2c4
Create Date: 2026-08-27
"""
from collections.abc import Callable
import hashlib
import json
import sqlalchemy as sa
from alembic import op
revision = "e5c7a9b1d3f6"
down_revision = "d3a9e5f7b2c4"
branch_labels = None
depends_on = None
_PENDING_TABLE = "transferpending"
_HISTORY_TABLE = "transferhistory"
_STEP_TABLE = "transferexecutionstep"
_RECEIPT_TABLE = "transfersettlementreceipt"
_PENDING_INDEX = "ix_transferpending_execution_due"
_HISTORY_INDEX = "ux_transferhistory_transfer_task_id"
_STEP_OPERATION_UNIQUE = "uq_transferexecutionstep_operation_id"
_STEP_ORDINAL_UNIQUE = "uq_transferexecutionstep_task_ordinal"
_STEP_INDEX = "ix_transferexecutionstep_task_state_ordinal"
_RECEIPT_TASK_REVISION_UNIQUE = "uq_transfersettlementreceipt_task_revision"
_RECEIPT_HISTORY_INDEX = "ix_transfersettlementreceipt_history_id"
_RECEIPT_TASK_REVISION_INDEX = "ix_transfersettlementreceipt_task_revision"
_MANUAL_REVIEW_DIAGNOSTIC = "升级检测到既有执行迹象,需人工确认后再处理"
_LEGACY_REVIEW_STEP_KIND = "legacy_execution_review"
_LEGACY_REVIEW_STEP_ORDINAL = 2_147_483_647
_LEGACY_REVIEW_FALLBACK_TIME = "1970-01-01 00:00:00"
_PENDING_COLUMNS = {
"execution_state",
"execution_version",
"execution_payload",
"execution_fingerprint",
"retry_generation",
"retry_count",
"retry_due_at",
"retry_requested_by",
"retry_reason",
"settlement_revision",
"terminal_history_id",
"manual_review_revision",
"reviewed_at",
"reviewed_by",
"review_reason",
"review_decision",
}
_HISTORY_COLUMNS = {
"transfer_task_id",
"transfer_settlement_revision",
}
_STEP_COLUMNS = {
"id",
"task_id",
"operation_id",
"checkpoint_fingerprint",
"ordinal",
"phase",
"kind",
"state",
"attempt_token",
"attempt_count",
"intent_version",
"intent_payload",
"result_version",
"result_payload",
"last_error",
"prepared_at",
"started_at",
"completed_at",
"updated_at",
}
_STEP_NULLABLE_COLUMNS = {
"attempt_token",
"result_version",
"result_payload",
"last_error",
"started_at",
"completed_at",
}
_STEP_COLUMN_TYPES = {
"id": ("integer", None),
"task_id": ("string", 64),
"operation_id": ("string", 64),
"checkpoint_fingerprint": ("string", 64),
"ordinal": ("integer", None),
"phase": ("string", 32),
"kind": ("string", 32),
"state": ("string", 32),
"attempt_token": ("string", 64),
"attempt_count": ("integer", None),
"intent_version": ("integer", None),
"intent_payload": ("json", None),
"result_version": ("integer", None),
"result_payload": ("json", None),
"last_error": ("text", None),
"prepared_at": ("string", 40),
"started_at": ("string", 40),
"completed_at": ("string", 40),
"updated_at": ("string", 40),
}
_RECEIPT_COLUMNS = {
"id",
"task_id",
"history_id",
"settlement_revision",
"outcome",
"execution_fingerprint",
"lease_token",
"history_status",
"src",
"src_storage",
"pending_deleted",
"error",
"created_at",
"updated_at",
}
_RECEIPT_NULLABLE_COLUMNS = {
"src",
"src_storage",
"error",
}
_RECEIPT_COLUMN_TYPES = {
"id": ("integer", None),
"task_id": ("string", 64),
"history_id": ("integer", None),
"settlement_revision": ("integer", None),
"outcome": ("string", 16),
"execution_fingerprint": ("string", 64),
"lease_token": ("string", 64),
"history_status": ("boolean", None),
"src": ("string", None),
"src_storage": ("string", None),
"pending_deleted": ("boolean", None),
"error": ("text", None),
"created_at": ("string", 40),
"updated_at": ("string", 40),
}
def _table_names() -> set[str]:
"""返回当前数据库的表名集合。"""
return set(sa.inspect(op.get_bind()).get_table_names())
def _column_names(table_name: str) -> set[str]:
"""返回指定表的字段集合。"""
if table_name not in _table_names():
return set()
return {
column["name"]
for column in sa.inspect(op.get_bind()).get_columns(table_name)
}
def _index_names(table_name: str) -> set[str]:
"""返回指定表的显式索引名称集合。"""
if table_name not in _table_names():
return set()
return {
index["name"]
for index in sa.inspect(op.get_bind()).get_indexes(table_name)
if index.get("name")
}
def _table_row_count(table_name: str) -> int:
"""不依赖业务字段读取迁移表行数,供残缺结构修复决策使用。"""
return op.get_bind().execute(
sa.select(sa.func.count()).select_from(sa.table(table_name))
).scalar_one()
def _repair_unique_constraints(
*,
table_name: str,
expected: dict[str, tuple[str, ...]],
create_table: Callable[[], None],
) -> None:
"""把迁移自有表的唯一约束收敛到命名的 ORM 精确集合。"""
constraints = sa.inspect(op.get_bind()).get_unique_constraints(table_name)
unnamed = [item for item in constraints if not item.get("name")]
if unnamed:
if _table_row_count(table_name) > 0:
raise RuntimeError(
f"检测到含数据迁移表 {table_name} 存在未命名唯一约束,"
"无法安全收敛到当前模型"
)
op.drop_table(table_name)
create_table()
return
actual = {
item["name"]: tuple(item.get("column_names") or ())
for item in constraints
}
to_drop = {
name
for name, columns in actual.items()
if name not in expected or columns != expected[name]
}
to_create = {
name
for name, columns in expected.items()
if actual.get(name) != columns
}
if not to_drop and not to_create:
return
with op.batch_alter_table(table_name) as batch_op:
for constraint_name in sorted(to_drop):
batch_op.drop_constraint(constraint_name, type_="unique")
for constraint_name in sorted(to_create):
batch_op.create_unique_constraint(
constraint_name,
list(expected[constraint_name]),
)
def _repair_indexes(
*,
table_name: str,
expected: dict[str, tuple[str, ...]],
) -> None:
"""把迁移自有表的显式索引收敛到 ORM 精确集合。"""
actual = {
item["name"]: tuple(item.get("column_names") or ())
for item in sa.inspect(op.get_bind()).get_indexes(table_name)
if item.get("name") and not item.get("duplicates_constraint")
}
for index_name, columns in actual.items():
if index_name not in expected or columns != expected[index_name]:
op.drop_index(index_name, table_name=table_name)
remaining = _index_names(table_name)
for index_name, columns in expected.items():
if index_name not in remaining:
op.create_index(
index_name,
table_name,
list(columns),
unique=False,
)
def _column_type_signature(column_type: sa.types.TypeEngine) -> tuple[str, object]:
"""把方言反射类型归一为迁移可稳定比较的类型与长度。"""
if isinstance(column_type, sa.JSON):
return "json", None
if isinstance(column_type, sa.Boolean):
return "boolean", None
if isinstance(column_type, sa.Integer):
return "integer", None
if isinstance(column_type, sa.Text):
return "text", None
if isinstance(column_type, sa.String):
return "string", column_type.length
return column_type.__class__.__name__.lower(), None
def _validate_or_recreate_empty_table(
*,
table_name: str,
expected_columns: set[str],
nullable_columns: set[str],
expected_types: dict[str, tuple[str, object]],
expected_foreign_keys: set[tuple[str, str, str, str]],
create_table: Callable[[], None],
) -> None:
"""校验中断升级留下的表结构;仅空表允许无损重建。"""
inspected_columns = sa.inspect(op.get_bind()).get_columns(table_name)
actual_columns = {column["name"] for column in inspected_columns}
actual_nullable = {
column["name"]
for column in inspected_columns
if column.get("nullable", True)
}
missing = expected_columns - actual_columns
unexpected = actual_columns - expected_columns
wrong_nullable = {
column_name
for column_name in expected_columns & actual_columns
if (column_name in actual_nullable) != (column_name in nullable_columns)
}
wrong_types = {
column["name"]
for column in inspected_columns
if (
column["name"] in expected_types
and _column_type_signature(column["type"])
!= expected_types[column["name"]]
)
}
primary_key = tuple(
sa.inspect(op.get_bind()).get_pk_constraint(table_name)
.get("constrained_columns") or ()
)
foreign_keys = {
(
foreign_key["constrained_columns"][0],
foreign_key["referred_table"],
foreign_key["referred_columns"][0],
str(foreign_key.get("options", {}).get("ondelete") or "").upper(),
)
for foreign_key in sa.inspect(op.get_bind()).get_foreign_keys(table_name)
if (
len(foreign_key.get("constrained_columns") or ()) == 1
and len(foreign_key.get("referred_columns") or ()) == 1
)
}
wrong_primary_key = primary_key != ("id",)
wrong_foreign_keys = foreign_keys != expected_foreign_keys
if not any((
missing,
unexpected,
wrong_nullable,
wrong_types,
wrong_primary_key,
wrong_foreign_keys,
)):
return
row_count = _table_row_count(table_name)
if row_count == 0:
op.drop_table(table_name)
create_table()
return
details = ", ".join(filter(None, (
f"缺少字段 {sorted(missing)}" if missing else "",
f"未知字段 {sorted(unexpected)}" if unexpected else "",
f"空值约束不一致 {sorted(wrong_nullable)}" if wrong_nullable else "",
f"字段类型不一致 {sorted(wrong_types)}" if wrong_types else "",
"主键不一致" if wrong_primary_key else "",
"外键不一致" if wrong_foreign_keys else "",
)))
raise RuntimeError(
f"检测到含数据的不完整迁移表 {table_name}{details}),"
"无法自动修复,请先恢复该版本的完整表结构后重试"
)
def _add_pending_columns() -> None:
"""补齐 pending 执行 checkpoint、重试与结算字段。"""
columns = _column_names(_PENDING_TABLE)
additions = (
("execution_state", sa.Column("execution_state", sa.String(32))),
("execution_version", sa.Column("execution_version", sa.Integer())),
("execution_payload", sa.Column("execution_payload", sa.JSON())),
(
"execution_fingerprint",
sa.Column("execution_fingerprint", sa.String(64)),
),
("retry_generation", sa.Column("retry_generation", sa.Integer())),
("retry_count", sa.Column("retry_count", sa.Integer())),
("retry_due_at", sa.Column("retry_due_at", sa.String(40))),
("retry_requested_by", sa.Column("retry_requested_by", sa.String(128))),
("retry_reason", sa.Column("retry_reason", sa.Text())),
("settlement_revision", sa.Column("settlement_revision", sa.Integer())),
("terminal_history_id", sa.Column("terminal_history_id", sa.Integer())),
("manual_review_revision", sa.Column("manual_review_revision", sa.Integer())),
("reviewed_at", sa.Column("reviewed_at", sa.String(40))),
("reviewed_by", sa.Column("reviewed_by", sa.String(128))),
("review_reason", sa.Column("review_reason", sa.Text())),
("review_decision", sa.Column("review_decision", sa.String(32))),
)
for column_name, column in additions:
if column_name not in columns:
op.add_column(_PENDING_TABLE, column)
def _backfill_pending() -> None:
"""保守隔离旧执行迹象,并补齐新增非空字段。"""
columns = _column_names(_PENDING_TABLE)
if not _PENDING_COLUMNS.issubset(columns):
return
pending = sa.table(
_PENDING_TABLE,
sa.column("state", sa.String(32)),
sa.column("checkpoint_version", sa.Integer()),
sa.column("checkpoint_payload", sa.JSON()),
sa.column("lease_token", sa.String(64)),
sa.column("attempt_count", sa.Integer()),
sa.column("last_error", sa.Text()),
sa.column("execution_state", sa.String(32)),
sa.column("retry_generation", sa.Integer()),
sa.column("retry_count", sa.Integer()),
sa.column("settlement_revision", sa.Integer()),
sa.column("manual_review_revision", sa.Integer()),
)
uncertain = sa.or_(
pending.c.state.in_(("provider_pending", "planned", "manual_review")),
pending.c.checkpoint_version.is_not(None),
pending.c.lease_token.is_not(None),
pending.c.attempt_count > 0,
pending.c.last_error.contains(_MANUAL_REVIEW_DIAGNOSTIC),
)
bind = op.get_bind()
bind.execute(
pending.update()
.where(pending.c.execution_state.is_(None), uncertain)
.values(
execution_state="manual_review",
last_error=sa.func.coalesce(
pending.c.last_error,
_MANUAL_REVIEW_DIAGNOSTIC,
),
)
)
bind.execute(
pending.update()
.where(pending.c.execution_state.is_(None))
.values(execution_state="not_started")
)
for column_name in (
"retry_generation",
"retry_count",
"settlement_revision",
"manual_review_revision",
):
column = getattr(pending.c, column_name)
bind.execute(
pending.update().where(column.is_(None)).values({column_name: 0})
)
with op.batch_alter_table(_PENDING_TABLE) as batch_op:
batch_op.alter_column(
"execution_state",
existing_type=sa.String(32),
nullable=False,
)
for column_name in (
"retry_generation",
"retry_count",
"settlement_revision",
"manual_review_revision",
):
batch_op.alter_column(
column_name,
existing_type=sa.Integer(),
nullable=False,
)
def _add_history_columns() -> None:
"""补齐成功删除 pending 后仍可幂等回读的历史身份字段。"""
columns = _column_names(_HISTORY_TABLE)
if "transfer_task_id" not in columns:
op.add_column(
_HISTORY_TABLE,
sa.Column("transfer_task_id", sa.String(64), nullable=True),
)
if "transfer_settlement_revision" not in columns:
op.add_column(
_HISTORY_TABLE,
sa.Column("transfer_settlement_revision", sa.Integer(), nullable=True),
)
def _create_step_table() -> None:
"""按 ORM 契约创建整理步骤执行证据表。"""
op.create_table(
_STEP_TABLE,
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("task_id", sa.String(64), nullable=False),
sa.Column("operation_id", sa.String(64), nullable=False),
sa.Column("checkpoint_fingerprint", sa.String(64), nullable=False),
sa.Column("ordinal", sa.Integer(), nullable=False),
sa.Column("phase", sa.String(32), nullable=False),
sa.Column("kind", sa.String(32), nullable=False),
sa.Column("state", sa.String(32), nullable=False),
sa.Column("attempt_token", sa.String(64), nullable=True),
sa.Column("attempt_count", sa.Integer(), nullable=False),
sa.Column("intent_version", sa.Integer(), nullable=False),
sa.Column("intent_payload", sa.JSON(), nullable=False),
sa.Column("result_version", sa.Integer(), nullable=True),
sa.Column("result_payload", sa.JSON(), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("prepared_at", sa.String(40), nullable=False),
sa.Column("started_at", sa.String(40), nullable=True),
sa.Column("completed_at", sa.String(40), nullable=True),
sa.Column("updated_at", sa.String(40), nullable=False),
sa.ForeignKeyConstraint(
["task_id"],
["transferpending.task_id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"operation_id",
name=_STEP_OPERATION_UNIQUE,
),
sa.UniqueConstraint(
"task_id",
"ordinal",
name=_STEP_ORDINAL_UNIQUE,
),
)
def _create_or_repair_step_table() -> None:
"""创建步骤证据表,并在任何查询前拒绝有数据的残缺结构。"""
if _STEP_TABLE not in _table_names():
_create_step_table()
else:
_validate_or_recreate_empty_table(
table_name=_STEP_TABLE,
expected_columns=_STEP_COLUMNS,
nullable_columns=_STEP_NULLABLE_COLUMNS,
expected_types=_STEP_COLUMN_TYPES,
expected_foreign_keys={(
"task_id",
_PENDING_TABLE,
"task_id",
"CASCADE",
)},
create_table=_create_step_table,
)
_repair_unique_constraints(
table_name=_STEP_TABLE,
expected={
_STEP_OPERATION_UNIQUE: ("operation_id",),
_STEP_ORDINAL_UNIQUE: ("task_id", "ordinal"),
},
create_table=_create_step_table,
)
def _create_receipt_table() -> None:
"""按 append-only ORM 契约创建任务终态结算回执表。"""
op.create_table(
_RECEIPT_TABLE,
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("task_id", sa.String(64), nullable=False),
sa.Column("history_id", sa.Integer(), nullable=False),
sa.Column("settlement_revision", sa.Integer(), nullable=False),
sa.Column("outcome", sa.String(16), nullable=False),
sa.Column("execution_fingerprint", sa.String(64), nullable=False),
sa.Column("lease_token", sa.String(64), nullable=False),
sa.Column("history_status", sa.Boolean(), nullable=False),
sa.Column("src", sa.String(), nullable=True),
sa.Column("src_storage", sa.String(), nullable=True),
sa.Column("pending_deleted", sa.Boolean(), nullable=False),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("created_at", sa.String(40), nullable=False),
sa.Column("updated_at", sa.String(40), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"task_id",
"settlement_revision",
name=_RECEIPT_TASK_REVISION_UNIQUE,
),
)
def _create_or_repair_receipt_table() -> None:
"""创建结算回执表,并只对空的残缺表执行无损重建。"""
if _RECEIPT_TABLE not in _table_names():
_create_receipt_table()
else:
_validate_or_recreate_empty_table(
table_name=_RECEIPT_TABLE,
expected_columns=_RECEIPT_COLUMNS,
nullable_columns=_RECEIPT_NULLABLE_COLUMNS,
expected_types=_RECEIPT_COLUMN_TYPES,
expected_foreign_keys=set(),
create_table=_create_receipt_table,
)
_repair_unique_constraints(
table_name=_RECEIPT_TABLE,
expected={
_RECEIPT_TASK_REVISION_UNIQUE: (
"task_id",
"settlement_revision",
),
},
create_table=_create_receipt_table,
)
_repair_indexes(
table_name=_RECEIPT_TABLE,
expected={
_RECEIPT_HISTORY_INDEX: ("history_id",),
_RECEIPT_TASK_REVISION_INDEX: (
"task_id",
"settlement_revision",
),
},
)
def _legacy_review_identity(*, task_id: str, suffix: str) -> str:
"""生成迁移遗留复核步骤使用的确定性 SHA-256 身份。"""
return hashlib.sha256(
f"moviepilot:3.0.16:legacy-review:{suffix}:{task_id}".encode("utf-8")
).hexdigest()
def _backfill_legacy_review_steps() -> None:
"""为没有可判定人工步骤的遗留人工态补一条 synthetic 审计步骤。"""
if _STEP_TABLE not in _table_names():
return
pending = sa.table(
_PENDING_TABLE,
sa.column("task_id", sa.String(64)),
sa.column("state", sa.String(32)),
sa.column("execution_state", sa.String(32)),
sa.column("attempt_count", sa.Integer()),
sa.column("last_error", sa.Text()),
sa.column("created_at", sa.String(40)),
sa.column("updated_at", sa.String(40)),
)
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()
rows = bind.execute(
sa.select(
pending.c.task_id,
pending.c.state,
pending.c.attempt_count,
pending.c.last_error,
pending.c.created_at,
pending.c.updated_at,
).where(
pending.c.execution_state == "manual_review",
~sa.exists(
sa.select(steps.c.task_id).where(
steps.c.task_id == pending.c.task_id,
steps.c.state == "manual_review",
)
),
)
).mappings().all()
values = []
for row in rows:
task_id = row["task_id"]
intent_payload = {
"schema_version": 1,
"origin": "3.0.16_migration",
"legacy_state": row["state"],
"diagnostic": row["last_error"] or _MANUAL_REVIEW_DIAGNOSTIC,
}
checkpoint_fingerprint = hashlib.sha256(
json.dumps(
intent_payload,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
).hexdigest()
evidence_time = (
row["updated_at"]
or row["created_at"]
or _LEGACY_REVIEW_FALLBACK_TIME
)
values.append({
"task_id": task_id,
"operation_id": _legacy_review_identity(
task_id=task_id,
suffix="operation",
),
"checkpoint_fingerprint": checkpoint_fingerprint,
"ordinal": _LEGACY_REVIEW_STEP_ORDINAL,
"phase": "legacy_upgrade",
"kind": _LEGACY_REVIEW_STEP_KIND,
"state": "manual_review",
"attempt_token": None,
"attempt_count": row["attempt_count"] or 0,
"intent_version": 1,
"intent_payload": intent_payload,
"result_version": None,
"result_payload": None,
"last_error": row["last_error"] or _MANUAL_REVIEW_DIAGNOSTIC,
"prepared_at": evidence_time,
"started_at": None,
"completed_at": None,
"updated_at": evidence_time,
})
if values:
bind.execute(steps.insert(), values)
def upgrade() -> None:
"""增加执行证据、幂等结算身份和恢复调度字段,支持中断重跑。"""
tables = _table_names()
if _PENDING_TABLE not in tables:
return
_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,
)
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,
)
_create_or_repair_receipt_table()
_create_or_repair_step_table()
_backfill_legacy_review_steps()
_repair_indexes(
table_name=_STEP_TABLE,
expected={
_STEP_INDEX: ("task_id", "state", "ordinal"),
},
)
def _mark_downgrade_uncertain() -> None:
"""在丢弃执行证据前恢复旧版可消费状态并保留再升级诊断。"""
columns = _column_names(_PENDING_TABLE)
if "execution_state" not in columns:
return
pending = sa.table(
_PENDING_TABLE,
sa.column("task_id", sa.String(64)),
sa.column("state", sa.String(32)),
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("execution_state", sa.String(32)),
sa.column("execution_version", sa.Integer()),
sa.column("execution_fingerprint", sa.String(64)),
sa.column("retry_count", sa.Integer()),
sa.column("settlement_revision", sa.Integer()),
sa.column("terminal_history_id", sa.Integer()),
)
uncertain = sa.or_(
pending.c.execution_state != "not_started",
pending.c.execution_version.is_not(None),
pending.c.execution_fingerprint.is_not(None),
pending.c.retry_count > 0,
pending.c.settlement_revision > 0,
pending.c.terminal_history_id.is_not(None),
)
if _STEP_TABLE in _table_names():
steps = sa.table(
_STEP_TABLE,
sa.column("task_id", sa.String(64)),
)
uncertain = sa.or_(
uncertain,
sa.exists(sa.select(steps.c.task_id).where(
steps.c.task_id == pending.c.task_id
)),
)
op.get_bind().execute(
pending.update()
.where(uncertain)
.values(
state="accepted",
lease_owner=None,
lease_token=None,
lease_expires_at=None,
heartbeat_at=None,
last_error=sa.case(
(
sa.or_(
pending.c.last_error.is_(None),
pending.c.last_error == "",
),
_MANUAL_REVIEW_DIAGNOSTIC,
),
(
pending.c.last_error.contains(_MANUAL_REVIEW_DIAGNOSTIC),
pending.c.last_error,
),
else_=(
pending.c.last_error
+ "\n"
+ _MANUAL_REVIEW_DIAGNOSTIC
),
),
)
)
def downgrade() -> None:
"""保守折叠执行状态后移除 3.0.16 字段、索引与步骤表。"""
if _PENDING_TABLE not in _table_names():
return
_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)
if _HISTORY_TABLE in _table_names():
history_columns = _column_names(_HISTORY_TABLE)
if _HISTORY_INDEX in _index_names(_HISTORY_TABLE):
op.drop_index(_HISTORY_INDEX, table_name=_HISTORY_TABLE)
with op.batch_alter_table(_HISTORY_TABLE) as batch_op:
for column_name in (
"transfer_settlement_revision",
"transfer_task_id",
):
if column_name in history_columns:
batch_op.drop_column(column_name)
pending_columns = _column_names(_PENDING_TABLE)
if _PENDING_INDEX in _index_names(_PENDING_TABLE):
op.drop_index(_PENDING_INDEX, table_name=_PENDING_TABLE)
with op.batch_alter_table(_PENDING_TABLE) as batch_op:
for column_name in (
"terminal_history_id",
"review_decision",
"review_reason",
"reviewed_by",
"reviewed_at",
"manual_review_revision",
"settlement_revision",
"retry_reason",
"retry_requested_by",
"retry_due_at",
"retry_count",
"retry_generation",
"execution_fingerprint",
"execution_payload",
"execution_version",
"execution_state",
):
if column_name in pending_columns:
batch_op.drop_column(column_name)
+24 -16
View File
@@ -69,16 +69,16 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| 指标 | 当前值 | 解释 |
|---|---:|---|
| 宿主 Python 模块 / 内部依赖边 | 837 / 6,834 | `dependency-baseline.json` 当前快照 |
| 宿主 Python 模块 / 内部依赖边 | 843 / 6,883 | `dependency-baseline.json` 当前快照 |
| 非平凡 SCC | 2 | 新增 Chain 包根环;另一个是隔离的 29 模块 TMDB 移植包环 |
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
| Module Contract | 217 specs / 215 methods / 265 calls | 动态方法名为 0;内部 planning 合同不进入插件调度,旧 transfer 只保留 provider ABI |
| Module Contract | 217 specs / 215 methods / 266 calls | 动态方法名为 0;内部 planning 合同不进入插件调度,旧 transfer 只保留 provider ABI |
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
| Python 源码量 | 约 271,400 行 | 60 个文件超过 1,000 行,14 个超过 2,000 行 |
| 长方法 | 281 个超过 80 行 | 67 个超过 150 行,23 个超过 250 行;大量是私有方法 |
| 全量 mypy 历史债务 | 11,983 / 601 文件 | strict frontier 当前只覆盖 41 个文件,且 ratchet 已新增 2 个错误 |
| Ruff 历史诊断 | 934 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| Ruff 历史诊断 | 929 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率低水位 | Application 78.24%Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
### 3.3 热点文件
@@ -194,8 +194,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
返回空后再以第二次 CAS 提交 `planned`;planned 重放只消费冻结上下文和目标。
- `S1-L1.3 Lease 与恢复调度``VERIFIED`。已交付 token fencing 的
claim/lease/heartbeat/attempt、过期接管、固定退避的唯一恢复入口和有界关闭 owner。
- `S1-L1.4 幂等执行与终态结算``PLANNED`。交付文件/历史幂等、唯一 retry owner 和
`manual_review` 语义
- `S1-L1.4 幂等执行与终态结算``VERIFIED`交付稳定 operation ledger、严格结果探测、
唯一 retry owner、`manual_review` 人工判定和 history/pending/outbox 同 UoW 终态结算
- `S1-L1.5 E3 全链收口``PLANNED`。完成崩溃矩阵、兼容验收与旧路径删除。此叶交付前,
ARCH-102 父项保持“执行中”,不得以局部绿色宣称 E3 完成。
@@ -216,10 +216,18 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
fencing。启动和同进程恢复共享唯一 scheduler,确定性失败按固定轮询退避,关闭时 worker、replay、
lease release 和 heartbeat 都由有界生命周期 owner 持有。损坏投影以无有效租约 CAS 留痕,同错不
重复刷写,且不会阻塞后续健康任务。
- `TransferPending` 仍缺少逐步骤执行结果和 `manual_review`,因此还不能判定“文件已移动、历史未提交”
等外部结果未知的后续中间态。
- 这与 `docs/adr/0007-background-action-reliability.md:123-139` 对 E3 的稳定身份、步骤状态、
lease/heartbeat 和人工恢复要求不一致。
- `S1-L1.4` 已增加 `TransferExecutionStep` 独立账本:每一步在副作用前冻结 intent 和稳定
operation ID,以 lease + attempt 双 CAS 提交结果;重启遇到遗留 `STARTED` 时必须先严格探测,
只有 `NOT_APPLIED` 能轮换 attempt 自动重试,`UNKNOWN/CONFLICT` 进入 `manual_review`
- 文件 cleanup、目录创建、版本发现/删除、覆盖目标删除、目标物化和跨存储 move 的源删除均已拆为
可重放步骤;本地复制使用完整内容比较,远端结果证据不足时不会伪造 exactly-once。
- 成功、失败及覆盖拒绝均通过 task-aware writer 在一个 UoW 内提交 history、pending、step cleanup
与可选 outboxrevision 和确定性 occurrence key 使“文件已移动、历史未提交”在恢复后只补历史,
不重复文件副作用。历史/API/Agent 重试只登记 durable retry intent,由唯一 scheduler 重新 claim。
- 管理员人工判定 API 只公开 `not_applied` 与带结果证据的 `applied`,并持久记录操作者、理由、结论
和 revision;无租约人工路径不能直接伪造失败终态。
- 以上实现已满足 `docs/adr/0007-background-action-reliability.md:123-139` 对 E3 稳定身份、步骤状态、
lease/heartbeat 和人工恢复的阶段性要求;完整崩溃矩阵与兼容收口仍由 `S1-L1.5` 验收。
**目标与步骤**
@@ -228,9 +236,9 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
- [x] 初始登记保存稳定源身份、版本化请求和状态;目标与有序操作在纯规划完成后以 planning
checkpoint 原子更新,任何文件副作用不得早于该提交。
- [x] 增加 claim/lease/heartbeat/attempt 与过期接管,同一任务同时只能有一个 worker owner。
- [ ] 设计幂等文件操作和历史提交;只有所有必要步骤达到持久终态后才能删除记录。
- [ ] 在持久状态机与现有失败历史/AI retry 之间指定唯一 retry owner,定义旧记录迁移和兼容规则。
- [ ] E3 失败使用持久 `failed/manual_review`、最后稳定 checkpoint 和补偿边界,不直接套用 E2
- [x] 设计幂等文件操作和历史提交;只有所有必要步骤达到持久终态后才能删除记录。
- [x] 在持久状态机与现有失败历史/AI retry 之间指定唯一 retry owner,定义旧记录迁移和兼容规则。
- [x] E3 失败使用持久 `failed/manual_review`、最后稳定 checkpoint 和补偿边界,不直接套用 E2
Outbox 的 dead-letter 语义;禁止按年龄通用清理 pending。
- [x] 当前 admission/planning 数据模型变更均配套 Alembic migration,并验证升级、降级和中断重跑。
@@ -238,10 +246,10 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
- [x] 登记后、内存入队前崩溃,重启可继续。
- [x] 持久登记成功但内存入队失败,重启可继续。
- [ ] 文件移动后、历史提交前崩溃,在支持稳定身份/幂等操作的存储上不重复移动且可补齐历史。
- [ ] worker 未知异常和 lease 超时后保留可诊断状态。
- [ ] 重复回放、重复消息和人工重试都保持幂等。
- [ ] 外部存储返回结果未知时进入 `manual_review`,不得伪装成 exactly-once 成功。
- [x] 文件移动后、历史提交前崩溃,在支持稳定身份/幂等操作的存储上不重复移动且可补齐历史。
- [x] worker 未知异常和 lease 超时后保留可诊断状态。
- [x] 重复回放、重复消息和人工重试都保持幂等。
- [x] 外部存储返回结果未知时进入 `manual_review`,不得伪装成 exactly-once 成功。
```bash
.venv/bin/python -m pytest \
+2 -2
View File
@@ -704,8 +704,8 @@ flowchart LR
| 指标 | 当前值 |
|---|---:|
| Python 模块 | 837 |
| 内部导入边 | 6,834 |
| Python 模块 | 843 |
| 内部导入边 | 6,883 |
| 非平凡 SCC | 2`ARCH-107` 临时 Chain 包根环;精确 containment 的 TMDB 移植包环) |
| Direct egress | 6612 条待迁移债务,54 条精确 containment |
| Module Contract V2 spec | 217(其中 215 个进入 `run_module` 观察面) |
+2 -2
View File
@@ -95,7 +95,7 @@ G-ARCH 只有在以下条件全部满足后才可完成:
| S1-L1.1 Durable admission | `VERIFIED` | S0 | Application-owned typed Port + DB adapter + migration 落地;先持久 commit 再入队,入队失败保留可恢复记录;宿主不再通过 raw/`Any` `TransferPendingOper` 处理 admission |
| S1-L1.2 Planning checkpoint | `VERIFIED` | S1-L1.1 | 版本化输入与指纹先持久化;无 legacy provider 时以 `accepted -> planned` CAS 提交完整计划,有 provider 时先提交 `provider_pending`,全部返回空后再以第二次 CAS 提交 `planned`;重放只执行冻结目标,所有文件副作用晚于对应 checkpoint commit |
| S1-L1.3 Lease 与恢复调度 | `VERIFIED` | S1-L1.2 | claim/lease/heartbeat/attempt 与过期接管规则落地;启动回放和同进程恢复共用唯一调度入口,同一任务同时只有一个 worker owner |
| S1-L1.4 幂等执行与终态结算 | `PLANNED` | S1-L1.3 | 文件操作、历史提交和 checkpoint 可重放;唯一 retry owner 生效,未知外部结果进入 `manual_review`,仅完整终态删除 pending |
| S1-L1.4 幂等执行与终态结算 | `VERIFIED` | S1-L1.3 | 文件操作、历史提交和 checkpoint 可重放;唯一 retry owner 生效,未知外部结果进入 `manual_review`,仅完整终态删除 pending |
| S1-L1.5 E3 全链收口 | `PLANNED` | S1-L1.4 | 崩溃矩阵、升级/降级、重复回放和插件 ABI 验收完整;旧 fail-open、重复状态与兼容层外旧入口删除,ARCH-102 债务归零 |
| S1-L2 Workflow typed query | `PLANNED` | S0 | Workflow Application Port 不返回 `Any`/ORMSession 内投影 DTO,正式调用方全部切换 |
| S1-L3 Chain/Agent typed data ports | `PLANNED` | S1-L2 | `ChainDataPorts`/`AgentDataPorts` 的 raw Oper/`Any` factory 全部清零,兼容调用进入 Legacy 层 |
@@ -144,7 +144,7 @@ G-ARCH 只有在以下条件全部满足后才可完成:
| S4-L2 Event strict contract | `PLANNED` | S0-L2.6,S1-L6 | 宿主事件输入/输出按风险 strict,诊断例外只属于第三方插件兼容 |
| S4-L3 Complexity v2 | `PLANNED` | S3 | 私有方法、class/file、圈复杂度进入门禁;所有超限通过职责拆分归零 |
| S4-L4 全量 mypy 清零 | `PLANNED` | S3,S4-L1,S4-L2 | `mypy-baseline.json` 归零并删除债务接受路径,全宿主 strict 类型通过 |
| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 934 条诊断归零,规则集扩展经过独立审查且新增诊断为零 |
| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 929 条诊断归零,规则集扩展经过独立审查且新增诊断为零 |
| S4-L6 Coverage/并发/质量证据 | `PLANNED` | S3,S4-L1,S4-L2 | 高风险包纳入 coverageraw concurrency 分类清零;Module Quality 有真实 evidence test |
### S5Plugin、Agent、Domain、Startup 与最终收口
+3
View File
@@ -158,6 +158,9 @@ FastAPI 的 HTTP 异常和参数校验异常统一使用 `message`,不再返
| POST | `/api/v1/transfer/manual/target-path` | 按源文件与目录配置匹配手动整理目标路径;请求体为 `ManualTransferItem`,该接口不执行媒体识别 |
| POST | `/api/v1/transfer/manual/history` | 查询文件、批量文件或目录命中的成功整理历史摘要,用于进入手动整理界面时显示重新整理状态 |
| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源;音乐请求未传 `music_type` 时,目录按 `album`、文件按 `recording` 解释;命中失败历史时自动清理旧目标和记录后重试,`reorganize=true` 时清理命中的成功历史和非移动模式旧目标后重新整理 |
| GET | `/api/v1/transfer/tasks/manual-reviews` | 管理员分页查询 durable 人工复核任务;`state` 仅允许 `manual_review`(默认)或已经人工判定、等待调度恢复的 `retry_wait`,支持 `page``page_size`。响应只公开任务、源文件、状态、步骤意图/证据/错误和复核修订号,不返回 lease 或 attempt 身份 |
| GET | `/api/v1/transfer/tasks/{task_id}/manual-review` | 管理员查询单个 durable 人工复核任务详情;仅可读取 `manual_review` 或已经人工判定的 `retry_wait` 任务,其余状态按不存在处理 |
| POST | `/api/v1/transfer/tasks/{task_id}/manual-review` | 管理员判定处于 `manual_review` 的 durable 整理步骤;请求包含 `operation_id``decision=not_applied|applied``reason``applied` 还必须提供 `result_payload``failed` 不属于公开决策,失败终态只能由持租约的 durable 结算写入;响应仅返回任务、操作、决策、后续状态和复核修订号 |
#### 站点
+18
View File
@@ -68,6 +68,7 @@ to make the directory tree look symmetrical.
| `app/application/chain/` | Injectable Chain runtime context and compatibility provider |
| `app/application/agentdata.py` | Named Agent data ports; canonical Agent consumers use `get_agent_*_port()` and do not alias legacy proxies to Oper classes |
| `app/application/outbox.py` | Durable intent and Outbox repository/dispatcher contracts for post-commit side effects |
| `app/application/transfer_execution.py` | Durable transfer execution contracts: stable operation identity, step/checkpoint state, retry/manual-review commands and terminal-settlement DTOs; contains no SQLAlchemy or external I/O |
| `app/application/plugin/` | Plugin market catalog, installation command, installed-plugin identity contract, runtime port, folder operations and dynamic-route use cases; filenames remain single words (`catalog.py`, `identity.py`, `install.py`, `runtime.py`, `folders.py`, `routes.py`) |
| `app/application/server/` | MoviePilot Server reporting and sharing use cases; local data readers and transport callbacks are injected by startup |
| `app/application/site/` | Configured site catalog, authentication level and index-resource capability; the generated extension and its data bundle stay together here |
@@ -148,6 +149,23 @@ must not be reintroduced as Oper aliases in canonical Agent modules.
Monitor history checks use `get_transfer_history_port()` from
`app/application/history.py`; the constructible `TransferHistoryPort` facade is
retained only for compatibility and is not a canonical Oper substitute.
Durable transfer execution follows one explicit boundary. The Chain freezes each
external file operation into the Application-owned contract in
`app/application/transfer_execution.py`; `app/db/adapters/transfer_execution.py`
uses short transactions to persist the task ledger and fences every state change
with the current lease and attempt token. `app/db/oper/transferexecutionstep.py`
remains table-oriented and never owns retry or recovery policy. External file I/O
runs outside those transactions. A legacy or remote operation whose result cannot
be proven as applied or not applied enters `manual_review` and must not be replayed
automatically. Terminal history, pending state, execution-step cleanup and the
optional outbox intent are committed only by the task-aware implementation in
`app/db/adapters/chain.py`; canonical callers must not add a second settlement or
direct pending-deletion path. Task-aware settlement never performs synchronous
event publication inside the worker callback; the committed outbox owns delivery.
History mutation and maintenance paths may delete or replace only legacy rows with
no `transfer_task_id`, because durable receipts are recovery evidence rather than
ordinary user-maintained history.
Canonical Chain, API, Scheduler and Agent consumers read notification and media
server configuration through the named helpers in `app/application/notification.py`
and `app/application/mediaserver.py`. `ServiceConfigHelper` remains the parser at
+6
View File
@@ -231,6 +231,9 @@ def configure_plugin_system_services():
from app.db.oper.subscribehistory import SubscribeHistoryOper
from app.db.oper.transferhistory import TransferHistoryOper
from app.db.adapters.transfer import TransactionalTransferAdmissionRepository
from app.db.adapters.transfer_execution import (
TransactionalTransferExecutionRepository,
)
from app.db.oper.user import UserOper
from app.db.oper.workflow import WorkflowOper, configure_workflow_legacy_writer
from app.db.oper.message import MessageOper
@@ -306,6 +309,9 @@ def configure_plugin_system_services():
transfer_pending=lambda: TransactionalTransferAdmissionRepository(
SessionFactory
),
transfer_execution=lambda: TransactionalTransferExecutionRepository(
SessionFactory
),
media_server=lambda: MediaServerOper(),
download_failure=lambda: TransactionalDownloadFailureRepository(
SessionFactory
+58 -3
View File
@@ -1441,8 +1441,8 @@
"runtime_only": true
}
},
"edge_count": 6834,
"edge_sha256": "07c0b6f24ef3ce3e7ea0fa7c1b617388e11e2453ce2b0b35bddb38ed2d7f5835",
"edge_count": 6883,
"edge_sha256": "f44ae63f222eda2fc1b8ce805ab5138550a560bef6c266e3f2fae6338a243cd2",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -2217,6 +2217,9 @@
"app.agent.tools.impl.delete_transfer_history -> app.agent.tools.tags",
"app.agent.tools.impl.delete_transfer_history -> app.application",
"app.agent.tools.impl.delete_transfer_history -> app.application.agentdata",
"app.agent.tools.impl.delete_transfer_history -> app.application.chain",
"app.agent.tools.impl.delete_transfer_history -> app.application.chain.data",
"app.agent.tools.impl.delete_transfer_history -> app.application.transfer_execution",
"app.agent.tools.impl.delete_transfer_history -> app.chain",
"app.agent.tools.impl.delete_transfer_history -> app.chain.storage",
"app.agent.tools.impl.delete_transfer_history -> app.runtime",
@@ -3306,8 +3309,11 @@
"app.api.endpoints.history -> app.api.response",
"app.api.endpoints.history -> app.application",
"app.api.endpoints.history -> app.application.agent",
"app.api.endpoints.history -> app.application.chain",
"app.api.endpoints.history -> app.application.chain.data",
"app.api.endpoints.history -> app.application.configuration",
"app.api.endpoints.history -> app.application.history",
"app.api.endpoints.history -> app.application.transfer_execution",
"app.api.endpoints.history -> app.runtime",
"app.api.endpoints.history -> app.runtime.config",
"app.api.endpoints.history -> app.runtime.log",
@@ -3814,9 +3820,12 @@
"app.api.endpoints.transfer -> app.api.dependencies.history",
"app.api.endpoints.transfer -> app.api.response",
"app.api.endpoints.transfer -> app.application",
"app.api.endpoints.transfer -> app.application.chain",
"app.api.endpoints.transfer -> app.application.chain.data",
"app.api.endpoints.transfer -> app.application.configuration",
"app.api.endpoints.transfer -> app.application.directory",
"app.api.endpoints.transfer -> app.application.history",
"app.api.endpoints.transfer -> app.application.transfer_execution",
"app.api.endpoints.transfer -> app.chain",
"app.api.endpoints.transfer -> app.chain.media",
"app.api.endpoints.transfer -> app.chain.transfer",
@@ -3975,8 +3984,10 @@
"app.application.chain.context -> app.runtime.stop",
"app.application.chain.data -> app.application",
"app.application.chain.data -> app.application.transfer",
"app.application.chain.data -> app.application.transfer_execution",
"app.application.chain.durable_events -> app.application",
"app.application.chain.durable_events -> app.application.history",
"app.application.chain.durable_events -> app.application.transfer_execution",
"app.application.chain.durable_events -> app.domain",
"app.application.chain.durable_events -> app.domain.context",
"app.application.chain.durable_events -> app.domain.meta",
@@ -4436,6 +4447,7 @@
"app.application.transfer -> app.adapters.system.host",
"app.application.transfer -> app.application",
"app.application.transfer -> app.application.agent",
"app.application.transfer -> app.application.transfer_execution",
"app.application.transfer -> app.domain",
"app.application.transfer -> app.domain.context",
"app.application.transfer -> app.domain.media",
@@ -4557,6 +4569,7 @@
"app.chain._transfer -> app.application.formatting",
"app.chain._transfer -> app.application.history",
"app.chain._transfer -> app.application.transfer",
"app.chain._transfer -> app.application.transfer_execution",
"app.chain._transfer -> app.chain",
"app.chain._transfer -> app.chain._contracts",
"app.chain._transfer -> app.chain.media",
@@ -4982,12 +4995,14 @@
"app.chain.transfer -> app.application",
"app.chain.transfer -> app.application.chain",
"app.chain.transfer -> app.application.chain.data",
"app.chain.transfer -> app.application.chain.durable_events",
"app.chain.transfer -> app.application.configuration",
"app.chain.transfer -> app.application.directory",
"app.chain.transfer -> app.application.formatting",
"app.chain.transfer -> app.application.history",
"app.chain.transfer -> app.application.outbox",
"app.chain.transfer -> app.application.transfer",
"app.chain.transfer -> app.application.transfer_execution",
"app.chain.transfer -> app.chain",
"app.chain.transfer -> app.chain._transfer",
"app.chain.transfer -> app.chain.media",
@@ -5094,12 +5109,18 @@
"app.db.adapters.chain -> app.application.chain.durable_events",
"app.db.adapters.chain -> app.application.history",
"app.db.adapters.chain -> app.application.outbox",
"app.db.adapters.chain -> app.application.transfer_execution",
"app.db.adapters.chain -> app.db",
"app.db.adapters.chain -> app.db.adapters",
"app.db.adapters.chain -> app.db.adapters.outbox",
"app.db.adapters.chain -> app.db.models",
"app.db.adapters.chain -> app.db.models.transfersettlementreceipt",
"app.db.adapters.chain -> app.db.oper",
"app.db.adapters.chain -> app.db.oper.downloadhistory",
"app.db.adapters.chain -> app.db.oper.transferexecutionstep",
"app.db.adapters.chain -> app.db.oper.transferhistory",
"app.db.adapters.chain -> app.db.oper.transferpending",
"app.db.adapters.chain -> app.db.oper.transfersettlementreceipt",
"app.db.adapters.chain -> app.db.uow",
"app.db.adapters.download -> app.db",
"app.db.adapters.download -> app.db.oper",
@@ -5153,6 +5174,16 @@
"app.db.adapters.transfer -> app.db.oper",
"app.db.adapters.transfer -> app.db.oper.transferpending",
"app.db.adapters.transfer -> app.db.uow",
"app.db.adapters.transfer_execution -> app.application",
"app.db.adapters.transfer_execution -> app.application.transfer_execution",
"app.db.adapters.transfer_execution -> app.db",
"app.db.adapters.transfer_execution -> app.db.models",
"app.db.adapters.transfer_execution -> app.db.models.transferexecutionstep",
"app.db.adapters.transfer_execution -> app.db.models.transferpending",
"app.db.adapters.transfer_execution -> app.db.oper",
"app.db.adapters.transfer_execution -> app.db.oper.transferexecutionstep",
"app.db.adapters.transfer_execution -> app.db.oper.transferpending",
"app.db.adapters.transfer_execution -> app.db.uow",
"app.db.adapters.workflow -> app.application",
"app.db.adapters.workflow -> app.application.workflow",
"app.db.adapters.workflow -> app.db",
@@ -5258,6 +5289,10 @@
"app.db.models.subscribehistory -> app.schemas.types",
"app.db.models.systemconfig -> app.db",
"app.db.models.systemconfig -> app.db.base",
"app.db.models.transferexecutionstep -> app.db",
"app.db.models.transferexecutionstep -> app.db.base",
"app.db.models.transferexecutionstep -> app.db.models",
"app.db.models.transferexecutionstep -> app.db.models.transferpending",
"app.db.models.transferhistory -> app.db",
"app.db.models.transferhistory -> app.db.base",
"app.db.models.transferhistory -> app.db.models",
@@ -5266,6 +5301,8 @@
"app.db.models.transferhistory -> app.schemas.types",
"app.db.models.transferpending -> app.db",
"app.db.models.transferpending -> app.db.base",
"app.db.models.transfersettlementreceipt -> app.db",
"app.db.models.transfersettlementreceipt -> app.db.base",
"app.db.models.user -> app.db",
"app.db.models.user -> app.db.base",
"app.db.models.userconfig -> app.db",
@@ -5345,6 +5382,10 @@
"app.db.oper.systemconfig -> app.foundation.singleton",
"app.db.oper.systemconfig -> app.schemas",
"app.db.oper.systemconfig -> app.schemas.types",
"app.db.oper.transferexecutionstep -> app.db",
"app.db.oper.transferexecutionstep -> app.db.base",
"app.db.oper.transferexecutionstep -> app.db.models",
"app.db.oper.transferexecutionstep -> app.db.models.transferexecutionstep",
"app.db.oper.transferhistory -> app.db",
"app.db.oper.transferhistory -> app.db.base",
"app.db.oper.transferhistory -> app.db.models",
@@ -5355,6 +5396,10 @@
"app.db.oper.transferpending -> app.db.base",
"app.db.oper.transferpending -> app.db.models",
"app.db.oper.transferpending -> app.db.models.transferpending",
"app.db.oper.transfersettlementreceipt -> app.db",
"app.db.oper.transfersettlementreceipt -> app.db.base",
"app.db.oper.transfersettlementreceipt -> app.db.models",
"app.db.oper.transfersettlementreceipt -> app.db.models.transfersettlementreceipt",
"app.db.oper.user -> app.db",
"app.db.oper.user -> app.db.base",
"app.db.oper.user -> app.db.models",
@@ -5808,6 +5853,7 @@
"app.modules.filemanager.module -> app.application.messaging",
"app.modules.filemanager.module -> app.application.messaging.message",
"app.modules.filemanager.module -> app.application.transfer",
"app.modules.filemanager.module -> app.application.transfer_execution",
"app.modules.filemanager.module -> app.domain",
"app.modules.filemanager.module -> app.domain.context",
"app.modules.filemanager.module -> app.domain.meta",
@@ -5960,6 +6006,7 @@
"app.modules.filemanager.transhandler -> app.application.messaging",
"app.modules.filemanager.transhandler -> app.application.messaging.message",
"app.modules.filemanager.transhandler -> app.application.transfer",
"app.modules.filemanager.transhandler -> app.application.transfer_execution",
"app.modules.filemanager.transhandler -> app.domain",
"app.modules.filemanager.transhandler -> app.domain.context",
"app.modules.filemanager.transhandler -> app.domain.meta",
@@ -7587,6 +7634,7 @@
"app.schemas.token -> app.schemas",
"app.schemas.token -> app.schemas.user",
"app.schemas.transfer -> app.schemas",
"app.schemas.transfer -> app.schemas.common",
"app.schemas.transfer -> app.schemas.context",
"app.schemas.transfer -> app.schemas.file",
"app.schemas.transfer -> app.schemas.media",
@@ -7923,6 +7971,7 @@
"app.startup.initializers.modules -> app.db.adapters.subscription",
"app.startup.initializers.modules -> app.db.adapters.transaction",
"app.startup.initializers.modules -> app.db.adapters.transfer",
"app.startup.initializers.modules -> app.db.adapters.transfer_execution",
"app.startup.initializers.modules -> app.db.adapters.workflow",
"app.startup.initializers.modules -> app.db.oper",
"app.startup.initializers.modules -> app.db.oper.agentchat",
@@ -8279,7 +8328,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 837,
"module_count": 843,
"modules": [
"app",
"app.adapters",
@@ -8627,6 +8676,7 @@
"app.application.torrent",
"app.application.torrent_cache",
"app.application.transfer",
"app.application.transfer_execution",
"app.application.workflow",
"app.chain",
"app.chain._contracts",
@@ -8678,6 +8728,7 @@
"app.db.adapters.subscription",
"app.db.adapters.transaction",
"app.db.adapters.transfer",
"app.db.adapters.transfer_execution",
"app.db.adapters.workflow",
"app.db.base",
"app.db.decorators",
@@ -8707,8 +8758,10 @@
"app.db.models.subscribe",
"app.db.models.subscribehistory",
"app.db.models.systemconfig",
"app.db.models.transferexecutionstep",
"app.db.models.transferhistory",
"app.db.models.transferpending",
"app.db.models.transfersettlementreceipt",
"app.db.models.user",
"app.db.models.userconfig",
"app.db.models.workflow",
@@ -8726,8 +8779,10 @@
"app.db.oper.subscribe",
"app.db.oper.subscribehistory",
"app.db.oper.systemconfig",
"app.db.oper.transferexecutionstep",
"app.db.oper.transferhistory",
"app.db.oper.transferpending",
"app.db.oper.transfersettlementreceipt",
"app.db.oper.user",
"app.db.oper.userconfig",
"app.db.oper.workflow",
-15
View File
@@ -149,9 +149,6 @@
"app/agent/tools/impl/delete_rule_group.py": {
"I001": 1
},
"app/agent/tools/impl/delete_transfer_history.py": {
"I001": 1
},
"app/agent/tools/impl/execute_command.py": {
"I001": 1
},
@@ -411,9 +408,6 @@
"app/db/__init__.py": {
"I001": 1
},
"app/db/adapters/chain.py": {
"I001": 1
},
"app/db/adapters/outbox.py": {
"I001": 1
},
@@ -477,9 +471,6 @@
"app/db/models/systemconfig.py": {
"I001": 1
},
"app/db/models/transferhistory.py": {
"I001": 1
},
"app/db/models/user.py": {
"I001": 1
},
@@ -1265,9 +1256,6 @@
"tests/test_capability_runtime.py": {
"I001": 1
},
"tests/test_chain_durable_events.py": {
"I001": 1
},
"tests/test_chain_layering.py": {
"I001": 1
},
@@ -1334,9 +1322,6 @@
"tests/test_db_session_lifecycle.py": {
"I001": 1
},
"tests/test_db_transferhistory_queries.py": {
"F401": 1
},
"tests/test_delete_transfer_history_tool.py": {
"I001": 1
},
+6 -1
View File
@@ -7974,7 +7974,7 @@
}
},
"run_module": {
"call_count": 265,
"call_count": 266,
"dynamic_call_count": 0,
"dynamic_calls": [],
"method_count": 215,
@@ -8801,6 +8801,11 @@
"caller": "app.chain",
"count": 1,
"mode": "sync"
},
{
"caller": "app.chain.transfer",
"count": 1,
"mode": "sync"
}
],
"filter_torrents": [
+2
View File
@@ -482,6 +482,8 @@ def test_transfer_pending_oper_import_is_confined_to_database_boundary():
"""宿主仅允许事务适配器和兼容导出直接导入整理待处理 Oper。"""
allowed_paths = {
"app/db/adapters/transfer.py",
"app/db/adapters/chain.py",
"app/db/adapters/transfer_execution.py",
"app/db/oper/__init__.py",
}
violations: list[str] = []
+875 -1
View File
@@ -1,13 +1,18 @@
"""下载与整理 durable 事件的原子写入和对象恢复测试。"""
import json
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from unittest.mock import Mock
from uuid import UUID
import pytest
from sqlalchemy import create_engine, delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker
from app.application.chain.durable_events import (
TransferResultSettlement,
download_added_event_key,
restore_download_added,
restore_transfer_result,
@@ -15,16 +20,26 @@ from app.application.chain.durable_events import (
snapshot_transfer_result,
transfer_result_event_key,
)
from app.application.history import TransferHistoryMutationCommand
from app.application.transfer_execution import (
TransferExecutionLeaseLostError,
TransferSettlementResult,
)
from app.db.adapters.chain import TransactionalChainDurableEventWriter
from app.db.base import Base
from app.db.models.downloadhistory import DownloadFiles, DownloadHistory
from app.db.models.outbox import OutboxMessage
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.db.oper.transferhistory import TransferHistoryOper
from app.db.uow import SqlAlchemyUnitOfWork
from app.domain.context import Context, MediaInfo, TorrentInfo
from app.domain.metainfo import MetaInfo
from app.schemas.file import FileItem
from app.schemas.transfer import TransferInfo
from app.schemas.types import MediaSource, MediaType
from app.db.adapters.chain import TransactionalChainDurableEventWriter
def _session_factory():
@@ -66,6 +81,79 @@ def _objects():
return meta, media, context, fileitem, transferinfo
def _add_settling_pending(
factory,
*,
task_id: str = "task-1",
lease_token: str = "lease-1",
execution_fingerprint: str = "execution-1",
settlement_revision: int = 0,
src_path: str | None = None,
) -> None:
"""写入具备有效长租约和执行检查点的待结算任务。"""
with factory() as session:
session.add(TransferPending(
task_id=task_id,
storage="local",
src_path=src_path or f"/downloads/{task_id}.mkv",
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}",
checkpoint_version=1,
checkpoint_payload={"schema_version": 1, "task_id": task_id},
planned_at="2026-08-27 09:00:00",
lease_owner="worker-1",
lease_token=lease_token,
lease_expires_at="2099-01-01 00:00:00.000000",
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,
retry_generation=0,
retry_count=0,
settlement_revision=settlement_revision,
))
session.commit()
def _settlement(
*,
outcome: str,
task_id: str = "task-1",
lease_token: str = "lease-1",
execution_fingerprint: str = "execution-1",
) -> TransferResultSettlement:
"""构造测试使用的稳定终态结算身份。"""
return TransferResultSettlement(
task_id=task_id,
lease_token=lease_token,
execution_fingerprint=execution_fingerprint,
outcome=outcome,
error="目标文件校验失败" if outcome == "failed" else None,
)
def _stage_result_history(
repository,
*,
task_id: str,
succeeded: bool,
src_path: str | None = None,
):
"""通过兼容历史端口暂存一条最小任务结算记录。"""
return repository.add_force(
src=src_path or f"/downloads/{task_id}.mkv",
src_storage="local",
status=succeeded,
errmsg=None if succeeded else "目标文件校验失败",
)
def _assert_event_key(
event_key: str,
topic: str,
@@ -213,6 +301,71 @@ def test_event_keys_distinguish_reused_history_ids():
_assert_event_key(event_key, "transfer.completed", 7)
def test_task_settlement_event_key_is_deterministic_and_revision_scoped():
"""任务结算按稳定任务、修订号和结果生成可重放的唯一事件键。"""
succeeded = TransferResultSettlement(
task_id="task-1",
lease_token="lease-1",
execution_fingerprint="execution-1",
outcome="succeeded",
)
failed = TransferResultSettlement(
task_id="task-1",
lease_token="lease-1",
execution_fingerprint="execution-1",
outcome="failed",
error="目标文件校验失败",
)
assert transfer_result_event_key(
"transfer.completed", 7, settlement=succeeded, settlement_revision=2
) == "transfer.result:task-1:2:succeeded:v1"
assert transfer_result_event_key(
"transfer.completed", 99, settlement=succeeded, settlement_revision=2
) == "transfer.result:task-1:2:succeeded:v1"
assert transfer_result_event_key(
"transfer.failed", 7, settlement=failed, settlement_revision=3
) == "transfer.result:task-1:3:failed:v1"
@pytest.mark.parametrize(
("kwargs", "message"),
[
({"task_id": ""}, "缺少任务"),
({"outcome": "unknown"}, "不支持的整理终态"),
({"outcome": "failed", "error": None}, "必须包含可诊断原因"),
],
)
def test_task_settlement_rejects_incomplete_identity(kwargs, message):
"""任务结算在进入数据库适配器前拒绝不完整的 fencing 身份。"""
values = {
"task_id": "task-1",
"lease_token": "lease-1",
"execution_fingerprint": "execution-1",
"outcome": "succeeded",
"error": None,
}
values.update(kwargs)
with pytest.raises(ValueError, match=message):
TransferResultSettlement(**values)
def test_task_settlement_event_key_requires_transaction_revision():
"""任务结算事件键只能使用持久层已取得的正向修订号。"""
settlement = TransferResultSettlement(
task_id="task-1",
lease_token="lease-1",
execution_fingerprint="execution-1",
outcome="succeeded",
)
with pytest.raises(ValueError, match="缺少有效结算修订号"):
transfer_result_event_key(
"transfer.completed",
7,
settlement=settlement,
)
def test_transfer_succeeds_when_history_id_is_reused_with_retained_outbox():
"""整理历史删除而 outbox 保留时,复用主键不得阻断新整理记录。"""
factory = _session_factory()
@@ -302,3 +455,724 @@ def test_transfer_event_failure_leaves_committed_intent_pending():
assert history.status is True
assert outbox.status == "pending"
_assert_event_key(outbox.event_key, "transfer.completed", history.id)
def test_task_success_settlement_atomically_deletes_pending_and_steps():
"""成功终态原子提交历史、pending、步骤和待异步投递的 intent。"""
factory = _session_factory()
_add_settling_pending(factory)
with factory() as session:
session.add(TransferExecutionStep(
task_id="task-1",
operation_id="operation-1",
checkpoint_fingerprint="plan-1",
ordinal=0,
phase="transfer",
kind="copy",
state="succeeded",
attempt_count=1,
intent_version=1,
intent_payload={"src": "/downloads/task-1.mkv"},
result_version=1,
result_payload={"dest": "/library/task-1.mkv"},
prepared_at="2026-08-27 09:00:00",
completed_at="2026-08-27 09:01:00",
updated_at="2026-08-27 09:01:00",
))
session.commit()
writer = TransactionalChainDurableEventWriter(factory)
published = []
def publish(payload):
"""验证即时发布只能观察到已提交的完整终态。"""
with factory() as session:
assert session.execute(select(TransferPending)).scalar_one_or_none() is None
assert session.execute(select(TransferHistory)).scalar_one().status is True
assert session.execute(select(OutboxMessage)).scalar_one().status == "pending"
published.append(dict(payload))
result = writer.transfer_result(
topic="transfer.completed",
stage_history=lambda repository: _stage_result_history(
repository,
task_id="task-1",
succeeded=True,
),
event_payload={},
publish=publish,
settlement=_settlement(outcome="succeeded"),
)
assert result == TransferSettlementResult(
history_id=1,
settlement_revision=1,
pending_deleted=True,
)
with factory() as session:
history = session.execute(select(TransferHistory)).scalar_one()
receipt = session.execute(select(TransferSettlementReceipt)).scalar_one()
outbox = session.execute(select(OutboxMessage)).scalar_one()
assert session.execute(select(TransferPending)).scalar_one_or_none() is None
assert session.execute(select(TransferExecutionStep)).scalar_one_or_none() is None
assert history.transfer_task_id is None
assert history.transfer_settlement_revision is None
assert receipt.task_id == "task-1"
assert receipt.history_id == history.id
assert receipt.outcome == "succeeded"
assert receipt.execution_fingerprint == "execution-1"
assert receipt.lease_token == "lease-1"
assert receipt.history_status is True
assert receipt.src == "/downloads/task-1.mkv"
assert receipt.src_storage == "local"
assert receipt.pending_deleted is True
assert outbox.event_key == "transfer.result:task-1:1:succeeded:v1"
assert outbox.status == "pending"
assert outbox.payload["idempotency_key"] == outbox.event_key
assert "task_id" not in outbox.payload
assert published == []
def test_task_success_replay_reads_history_without_new_event():
"""成功删除 pending 后重复结算只回读历史,不重复登记或发布事件。"""
factory = _session_factory()
_add_settling_pending(factory)
writer = TransactionalChainDurableEventWriter(factory)
settlement = _settlement(outcome="succeeded")
calls = []
first = writer.transfer_result(
topic="transfer.completed",
stage_history=lambda repository: _stage_result_history(
repository,
task_id="task-1",
succeeded=True,
),
event_payload={},
publish=lambda _payload: calls.append("first"),
settlement=settlement,
)
replay = writer.transfer_result(
topic="transfer.completed",
stage_history=lambda _repository: pytest.fail("幂等回读不得重写历史"),
event_payload={},
publish=lambda _payload: pytest.fail("幂等回读不得重复发布"),
settlement=settlement,
)
assert isinstance(first, TransferSettlementResult)
assert replay == TransferSettlementResult(
history_id=first.history_id,
settlement_revision=1,
pending_deleted=True,
already_settled=True,
)
with factory() as session:
assert len(session.execute(select(TransferHistory)).scalars().all()) == 1
assert len(session.execute(select(OutboxMessage)).scalars().all()) == 1
assert calls == []
def test_multiple_same_source_tasks_keep_independent_replay_receipts():
"""同源多代任务可依次完成,旧任务仍由独立回执幂等回读。"""
factory = _session_factory()
writer = TransactionalChainDurableEventWriter(factory)
shared_src = "/downloads/shared-generation.mkv"
_add_settling_pending(
factory,
task_id="old-task",
src_path=shared_src,
)
old_settlement = _settlement(outcome="succeeded", task_id="old-task")
old_result = writer.transfer_result(
topic="transfer.completed",
stage_history=lambda repository: _stage_result_history(
repository,
task_id="old-task",
succeeded=True,
src_path=shared_src,
),
event_payload={},
publish=None,
settlement=old_settlement,
)
_add_settling_pending(
factory,
task_id="new-task",
lease_token="lease-2",
execution_fingerprint="execution-2",
src_path=shared_src,
)
new_result = writer.transfer_result(
topic="transfer.completed",
stage_history=lambda repository: _stage_result_history(
repository,
task_id="new-task",
succeeded=True,
src_path=shared_src,
),
event_payload={},
publish=None,
settlement=_settlement(
outcome="succeeded",
task_id="new-task",
lease_token="lease-2",
execution_fingerprint="execution-2",
),
)
old_replay = writer.transfer_result(
topic="transfer.completed",
stage_history=lambda _repository: pytest.fail("旧任务不得改写最新投影"),
event_payload={},
publish=None,
settlement=old_settlement,
)
with factory() as session:
history = session.execute(select(TransferHistory)).scalar_one()
receipts = session.execute(
select(TransferSettlementReceipt).order_by(TransferSettlementReceipt.id)
).scalars().all()
assert session.execute(select(TransferPending)).scalar_one_or_none() is None
assert isinstance(old_result, TransferSettlementResult)
assert isinstance(new_result, TransferSettlementResult)
assert old_replay == TransferSettlementResult(
history_id=old_result.history_id,
settlement_revision=1,
pending_deleted=True,
already_settled=True,
)
assert history.transfer_task_id is None
assert history.transfer_settlement_revision is None
assert [receipt.task_id for receipt in receipts] == ["old-task", "new-task"]
assert [receipt.history_id for receipt in receipts] == [history.id, history.id]
def test_task_settlement_without_public_topic_commits_no_outbox():
"""无公共事件的文件仍原子结算历史和 pending,且不登记或发布事件。"""
factory = _session_factory()
_add_settling_pending(factory, task_id="lyrics-task")
writer = TransactionalChainDurableEventWriter(factory)
result = writer.transfer_result(
topic=None,
stage_history=lambda repository: _stage_result_history(
repository,
task_id="lyrics-task",
succeeded=True,
),
event_payload={"unexpected": "must-not-publish"},
publish=lambda _payload: pytest.fail("无公共 topic 不得发布"),
settlement=_settlement(
outcome="succeeded",
task_id="lyrics-task",
),
)
assert result == TransferSettlementResult(
history_id=1,
settlement_revision=1,
pending_deleted=True,
)
with factory() as session:
history = session.execute(select(TransferHistory)).scalar_one()
assert history.transfer_task_id is None
assert session.execute(select(TransferPending)).scalar_one_or_none() is None
assert session.execute(select(OutboxMessage)).scalar_one_or_none() is None
def test_task_settlement_binds_receipt_without_overwriting_success_history():
"""不覆盖裁决只绑定任务回执,保留旧成功历史的全部业务字段。"""
factory = _session_factory()
_add_settling_pending(factory, task_id="declined-task")
with factory() as session:
session.add(TransferHistory(
src="/downloads/declined-task.mkv",
src_storage="local",
dest="/library/original.mkv",
title="Original",
status=True,
date="2026-08-26 20:00:00",
))
session.commit()
writer = TransactionalChainDurableEventWriter(factory)
settlement = _settlement(
outcome="succeeded",
task_id="declined-task",
)
first = writer.transfer_result(
topic=None,
stage_history=lambda repository: repository.get_success_by_src(
"/downloads/declined-task.mkv",
"local",
),
event_payload={},
publish=None,
settlement=settlement,
)
replay = writer.transfer_result(
topic=None,
stage_history=lambda _repository: pytest.fail("回执重放不得重新查写历史"),
event_payload={},
publish=None,
settlement=settlement,
)
assert isinstance(first, TransferSettlementResult)
assert replay == TransferSettlementResult(
history_id=first.history_id,
settlement_revision=1,
pending_deleted=True,
already_settled=True,
)
with factory() as session:
history = session.execute(select(TransferHistory)).scalar_one()
assert session.execute(select(TransferPending)).scalar_one_or_none() is None
assert session.execute(select(OutboxMessage)).scalar_one_or_none() is None
assert history.dest == "/library/original.mkv"
assert history.title == "Original"
assert history.status is True
assert history.date == "2026-08-26 20:00:00"
assert history.transfer_task_id is None
assert history.transfer_settlement_revision is None
@pytest.mark.parametrize("cleanup", ["delete", "truncate"])
def test_receipt_replay_survives_real_history_command_cleanup(cleanup):
"""真实历史删除或清空命令执行后,独立回执仍可重放成功终态。"""
factory = _session_factory()
_add_settling_pending(factory, task_id="cleanup-task")
writer = TransactionalChainDurableEventWriter(factory)
settlement = _settlement(outcome="succeeded", task_id="cleanup-task")
first = writer.transfer_result(
topic=None,
stage_history=lambda repository: _stage_result_history(
repository,
task_id="cleanup-task",
succeeded=True,
),
event_payload={},
publish=None,
settlement=settlement,
)
with factory() as session:
command = TransferHistoryMutationCommand(
repository=TransferHistoryOper(session),
download_repository=Mock(),
unit_of_work=SqlAlchemyUnitOfWork(session),
file_item_factory=Mock(),
delete_media_file=Mock(return_value=True),
publish_download_file_deleted=Mock(),
clear_failures=Mock(),
)
cleanup_result = (
command.delete(first.history_id)
if cleanup == "delete"
else command.truncate()
)
assert cleanup_result.success is True
replay = writer.transfer_result(
topic=None,
stage_history=lambda _repository: pytest.fail("清理后重放不得重建历史"),
event_payload={},
publish=None,
settlement=settlement,
)
with factory() as session:
assert session.execute(select(TransferHistory)).scalar_one_or_none() is None
receipts = session.execute(
select(TransferSettlementReceipt)
.order_by(TransferSettlementReceipt.settlement_revision)
).scalars().all()
assert isinstance(first, TransferSettlementResult)
assert replay == TransferSettlementResult(
history_id=first.history_id,
settlement_revision=1,
pending_deleted=True,
already_settled=True,
)
assert len(receipts) == 1
assert receipts[0].task_id == "cleanup-task"
assert receipts[0].history_id == first.history_id
def test_success_receipt_allows_expiry_and_legacy_same_source_replace():
"""成功回执不锁死业务历史,过期清理和旧兼容替换仍按原契约工作。"""
factory = _session_factory()
shared_src = "/downloads/cleanup-compatible.mkv"
_add_settling_pending(
factory,
task_id="compatible-task",
src_path=shared_src,
)
writer = TransactionalChainDurableEventWriter(factory)
settlement = _settlement(outcome="succeeded", task_id="compatible-task")
first = writer.transfer_result(
topic=None,
stage_history=lambda repository: _stage_result_history(
repository,
task_id="compatible-task",
succeeded=True,
src_path=shared_src,
),
event_payload={},
publish=None,
settlement=settlement,
)
with factory() as session:
assert TransferHistory.delete_before(
session,
before_time="9999-12-31 23:59:59",
limit=100,
) == 1
session.commit()
legacy = TransferHistoryOper(session).stage_replace_by_src(
src=shared_src,
src_storage="local",
status=True,
)
session.commit()
assert legacy.transfer_task_id is None
replay = writer.transfer_result(
topic=None,
stage_history=lambda _repository: pytest.fail("旧兼容替换后不得重写历史"),
event_payload={},
publish=None,
settlement=settlement,
)
assert isinstance(first, TransferSettlementResult)
assert replay == TransferSettlementResult(
history_id=first.history_id,
settlement_revision=1,
pending_deleted=True,
already_settled=True,
)
def test_task_failure_settlement_is_replayable_and_retry_advances_revision():
"""失败保留终态证据,重复调用幂等,显式重试后才递增修订号。"""
factory = _session_factory()
_add_settling_pending(factory)
writer = TransactionalChainDurableEventWriter(factory)
calls = []
first_settlement = _settlement(outcome="failed")
first = writer.transfer_result(
topic="transfer.failed",
stage_history=lambda repository: _stage_result_history(
repository,
task_id="task-1",
succeeded=False,
),
event_payload={},
publish=lambda _payload: calls.append("first"),
settlement=first_settlement,
)
replay = writer.transfer_result(
topic="transfer.failed",
stage_history=lambda _repository: pytest.fail("失败回读不得重写历史"),
event_payload={},
publish=lambda _payload: pytest.fail("失败回读不得重复发布"),
settlement=first_settlement,
)
assert isinstance(first, TransferSettlementResult)
assert replay.already_settled is True
assert replay.settlement_revision == 1
with factory() as session:
pending = session.execute(select(TransferPending)).scalar_one()
assert pending.execution_state == "failed"
assert pending.lease_token is None
assert pending.settlement_revision == 1
assert pending.terminal_history_id == first.history_id
pending.execution_state = "settling"
pending.execution_fingerprint = "execution-2"
pending.lease_owner = "worker-2"
pending.lease_token = "lease-2"
pending.lease_expires_at = "2099-01-01 00:00:00.000000"
session.commit()
retried = writer.transfer_result(
topic="transfer.failed",
stage_history=lambda repository: _stage_result_history(
repository,
task_id="task-1",
succeeded=False,
),
event_payload={},
publish=lambda _payload: calls.append("retry"),
settlement=_settlement(
outcome="failed",
lease_token="lease-2",
execution_fingerprint="execution-2",
),
)
assert retried == TransferSettlementResult(
history_id=first.history_id,
settlement_revision=2,
pending_deleted=False,
)
stale_replay = writer.transfer_result(
topic="transfer.failed",
stage_history=lambda _repository: pytest.fail(
"旧修订延迟重放不得覆盖最新历史"
),
event_payload={},
publish=None,
settlement=first_settlement,
)
assert stale_replay == TransferSettlementResult(
history_id=first.history_id,
settlement_revision=1,
pending_deleted=False,
already_settled=True,
)
with factory() as session:
histories = session.execute(select(TransferHistory)).scalars().all()
outboxes = session.execute(
select(OutboxMessage).order_by(OutboxMessage.id)
).scalars().all()
pending = session.execute(select(TransferPending)).scalar_one()
receipts = session.execute(
select(TransferSettlementReceipt)
.order_by(TransferSettlementReceipt.settlement_revision)
).scalars().all()
assert len(histories) == 1
assert histories[0].transfer_settlement_revision == 2
assert pending.settlement_revision == 2
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].lease_token == "lease-1"
assert receipts[1].outcome == "failed"
assert receipts[1].execution_fingerprint == "execution-2"
assert receipts[1].lease_token == "lease-2"
assert receipts[1].pending_deleted is False
assert receipts[1].error == "目标文件校验失败"
assert [item.event_key for item in outboxes] == [
"transfer.result:task-1:1:failed:v1",
"transfer.result:task-1:2:failed:v1",
]
assert calls == []
def test_failed_revision_replays_after_later_success_deleted_pending():
"""后续重试成功删除 pending 后,旧失败修订仍按原执行身份幂等回读。"""
factory = _session_factory()
_add_settling_pending(factory)
writer = TransactionalChainDurableEventWriter(factory)
failed_settlement = _settlement(outcome="failed")
failed = 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=failed_settlement,
)
with factory() as session:
pending = session.execute(select(TransferPending)).scalar_one()
pending.execution_state = "settling"
pending.execution_fingerprint = "execution-2"
pending.lease_owner = "worker-2"
pending.lease_token = "lease-2"
pending.lease_expires_at = "2099-01-01 00:00:00.000000"
session.commit()
succeeded_settlement = _settlement(
outcome="succeeded",
lease_token="lease-2",
execution_fingerprint="execution-2",
)
succeeded = writer.transfer_result(
topic="transfer.completed",
stage_history=lambda repository: _stage_result_history(
repository,
task_id="task-1",
succeeded=True,
),
event_payload={},
publish=None,
settlement=succeeded_settlement,
)
stale_replay = writer.transfer_result(
topic="transfer.failed",
stage_history=lambda _repository: pytest.fail("旧失败回执不得重写历史"),
event_payload={},
publish=None,
settlement=failed_settlement,
)
success_replay = writer.transfer_result(
topic="transfer.completed",
stage_history=lambda _repository: pytest.fail("成功回执不得重写历史"),
event_payload={},
publish=None,
settlement=succeeded_settlement,
)
assert isinstance(failed, TransferSettlementResult)
assert isinstance(succeeded, TransferSettlementResult)
assert stale_replay == TransferSettlementResult(
history_id=failed.history_id,
settlement_revision=1,
pending_deleted=False,
already_settled=True,
)
assert success_replay == TransferSettlementResult(
history_id=succeeded.history_id,
settlement_revision=2,
pending_deleted=True,
already_settled=True,
)
with factory() as session:
history = session.execute(select(TransferHistory)).scalar_one()
receipts = session.execute(
select(TransferSettlementReceipt)
.order_by(TransferSettlementReceipt.settlement_revision)
).scalars().all()
assert session.execute(select(TransferPending)).scalar_one_or_none() is None
assert history.transfer_task_id is None
assert history.transfer_settlement_revision is None
assert [receipt.outcome for receipt in receipts] == ["failed", "succeeded"]
def test_task_settlement_outbox_conflict_rolls_back_history_and_pending():
"""intent 唯一键冲突时回滚此前已暂存的历史与 pending 终态。"""
factory = _session_factory()
_add_settling_pending(factory)
event_key = "transfer.result:task-1:1:succeeded:v1"
with factory() as session:
session.add(OutboxMessage(
event_key=event_key,
topic="transfer.completed",
payload_version=1,
payload={},
status="completed",
attempt=0,
next_retry_at="2026-08-27T01:00:00+00:00",
created_at="2026-08-27T01:00:00+00:00",
))
session.commit()
writer = TransactionalChainDurableEventWriter(factory)
with pytest.raises(IntegrityError):
writer.transfer_result(
topic="transfer.completed",
stage_history=lambda repository: _stage_result_history(
repository,
task_id="task-1",
succeeded=True,
),
event_payload={},
publish=lambda _payload: pytest.fail("事务失败不得发布"),
settlement=_settlement(outcome="succeeded"),
)
with factory() as session:
pending = session.execute(select(TransferPending)).scalar_one()
histories = session.execute(select(TransferHistory)).scalars().all()
receipts = session.execute(select(TransferSettlementReceipt)).scalars().all()
outboxes = session.execute(select(OutboxMessage)).scalars().all()
assert pending.execution_state == "settling"
assert pending.settlement_revision == 0
assert pending.lease_token == "lease-1"
assert histories == []
assert receipts == []
assert len(outboxes) == 1
assert outboxes[0].event_key == event_key
def test_task_settlement_rejects_stale_lease_without_business_writes():
"""陈旧 lease 在历史回调前即被 fencing,不能留下历史或 intent。"""
factory = _session_factory()
_add_settling_pending(factory)
writer = TransactionalChainDurableEventWriter(factory)
with pytest.raises(TransferExecutionLeaseLostError):
writer.transfer_result(
topic="transfer.completed",
stage_history=lambda _repository: pytest.fail("陈旧 lease 不得写历史"),
event_payload={},
publish=lambda _payload: pytest.fail("陈旧 lease 不得发布"),
settlement=_settlement(
outcome="succeeded",
lease_token="stale-lease",
),
)
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.lease_token == "lease-1"
def test_concurrent_duplicate_settlement_returns_one_commit_and_one_replay(
tmp_path,
monkeypatch,
):
"""并发重复结算只有一个事务写入,竞争输家回读同一不可变回执。"""
engine = create_engine(
f"sqlite+pysqlite:///{tmp_path / 'settlement-race.db'}",
connect_args={"check_same_thread": False, "timeout": 10},
)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
_add_settling_pending(factory)
writer = TransactionalChainDurableEventWriter(factory)
settlement = _settlement(outcome="succeeded")
barrier = Barrier(2)
original_read = TransactionalChainDurableEventWriter._read_settlement_result
def synchronized_read(**kwargs):
"""让两个调用都先观察到未结算,再同时进入事务竞争。"""
result = original_read(**kwargs)
if result is None:
barrier.wait(timeout=10)
return result
monkeypatch.setattr(
TransactionalChainDurableEventWriter,
"_read_settlement_result",
staticmethod(synchronized_read),
)
def settle_once():
"""用相同 fencing 身份提交同一成功终态。"""
return writer.transfer_result(
topic=None,
stage_history=lambda repository: _stage_result_history(
repository,
task_id="task-1",
succeeded=True,
),
event_payload={},
publish=None,
settlement=settlement,
)
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(lambda _index: settle_once(), range(2)))
assert sorted(result.already_settled for result in results) == [False, True]
assert {result.history_id for result in results} == {1}
with factory() as session:
assert len(session.execute(select(TransferHistory)).scalars().all()) == 1
assert len(
session.execute(select(TransferSettlementReceipt)).scalars().all()
) == 1
assert session.execute(select(TransferPending)).scalar_one_or_none() is None
+59 -1
View File
@@ -5,7 +5,6 @@
来的统计查重误判会重复整理或永久漏件挂载故障那一类问题最终就落在这张表上
溯源查错会让重新整理把不相干的文件搬走
"""
import asyncio
import time as _time
import pytest
@@ -515,6 +514,65 @@ def test_delete_before_is_batched_and_keeps_recent(db):
assert TransferHistory.get_by_src(db.session, "/data/recent.mkv") is not None
def test_delete_before_preserves_current_failed_task_history(db):
"""过期维护不得删除当前失败 pending 映射使用的历史。"""
durable = _hist(
"durable-old",
src="/data/durable-old.mkv",
date="2026-01-01 10:00:00",
status=False,
)
durable.transfer_task_id = "task-durable-old"
durable.transfer_settlement_revision = 1
db.add(durable)
assert TransferHistory.delete_before(
db.session,
before_time="2026-08-01",
limit=100,
) == 0
assert TransferHistory.get_by_transfer_task_id(
db.session,
task_id="task-durable-old",
) is not None
def test_upsert_durable_projection_advances_to_new_same_source_task(db):
"""同源历史只表达最新任务投影,旧任务重放身份由独立回执持有。"""
durable = _hist(
"old-task",
src="/data/reused.mkv",
date="2026-08-01 10:00:00",
)
durable.transfer_task_id = "old-task"
durable.transfer_settlement_revision = 1
db.add(durable)
projected = TransferHistory.upsert_by_transfer_task_id(
db.session,
task_id="new-task",
settlement_revision=1,
retain_task_mapping=False,
payload={
"src": "/data/reused.mkv",
"src_storage": "local",
"status": True,
},
)
assert TransferHistory.get_by_transfer_task_id(
db.session,
task_id="old-task",
) is None
assert TransferHistory.get_by_transfer_task_id(
db.session,
task_id="new-task",
) is None
assert projected is durable
assert projected.transfer_task_id is None
assert projected.transfer_settlement_revision is None
def test_delete_before_keeps_the_row_exactly_at_the_boundary(db):
"""
保留时间点上的整理历史属于保留期内不能被清理``date < before_time``
+2
View File
@@ -13,6 +13,7 @@ from sqlalchemy.orm import sessionmaker
from app.db import base as db_base
from app.db.adapters.transfer import TransactionalTransferAdmissionRepository
from app.db.models.transferhistory import TransferHistory
from app.db.models.transferpending import TransferPending
from app.db.oper.transferpending import TransferPendingOper
@@ -143,6 +144,7 @@ def test_oper_staging_reuses_explicit_write_session(db, monkeypatch):
def test_transactional_repository_commits_frozen_projections(tmp_path):
"""适配器应独立提交 UoW,并在会话关闭前冻结应用 DTO。"""
engine = create_engine(f"sqlite:///{tmp_path / 'transfer.db'}")
TransferHistory.__table__.create(engine)
TransferPending.__table__.create(engine)
factory = sessionmaker(bind=engine)
repository = TransactionalTransferAdmissionRepository(factory)
+437
View File
@@ -0,0 +1,437 @@
"""验证 AI 历史入口不会绕过 durable 整理任务的唯一重试权。"""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from app.agent.tools.impl.delete_transfer_history import DeleteTransferHistoryTool
from app.api.endpoints import history as history_endpoint
from app.application.configuration import ApiRuntimeConfig
from app.application.transfer_execution import (
TransferExecutionState,
TransferRetryRequestResult,
)
from app.runtime.progress import AsyncProgressHelper
from app.schemas.history import BatchTransferHistoryRedoRequest, TransferHistory
class _HistoryQuery:
"""按测试输入返回脱离数据库的整理历史 DTO。"""
def __init__(self, histories: list[TransferHistory]) -> None:
"""保存按 ID 可查的测试历史。"""
self._histories = {history.id: history for history in histories}
async def get_transfer(self, history_id: int) -> TransferHistory | None:
"""返回单条测试历史。"""
return self._histories.get(history_id)
async def get_transfers(
self,
history_ids: list[int],
) -> tuple[list[TransferHistory], list[int]]:
"""按输入顺序返回存在和缺失的测试历史。"""
records = [self._histories[item] for item in history_ids if item in self._histories]
missing = [item for item in history_ids if item not in self._histories]
return records, missing
class _RetryCommand:
"""记录 execution 重试请求并返回逐任务测试结果。"""
calls: list[tuple[object, dict]] = []
results: dict[str, TransferRetryRequestResult] = {}
def __init__(self, repository: object) -> None:
"""保存调用方取得的 execution 仓储。"""
self._repository = repository
def request_retry(self, **kwargs) -> TransferRetryRequestResult:
"""记录调用并返回任务对应结果。"""
self.calls.append((self._repository, kwargs))
return self.results[kwargs["task_id"]]
async def _record_async(target: list[dict], payload: dict) -> None:
"""记录被 await 的异步边界调用。"""
target.append(payload)
def _runtime(*, ai_enabled: bool = True) -> ApiRuntimeConfig:
"""构造历史端点需要的最小稳定配置快照。"""
return ApiRuntimeConfig(
advanced_mode=False,
access_token_expire_minutes=30,
btrfs_fsid_dedup=False,
ai_agent_enable=ai_enabled,
)
def _retry_result(
*,
accepted: bool,
state: TransferExecutionState,
message: str,
) -> TransferRetryRequestResult:
"""构造 execution 重试登记结果。"""
return TransferRetryRequestResult(
accepted=accepted,
state=state,
retry_generation=3,
message=message,
)
def _install_retry_command(monkeypatch, results: dict[str, TransferRetryRequestResult]) -> object:
"""安装不会接触真实数据库的 execution 端口和命令替身。"""
repository = object()
_RetryCommand.calls = []
_RetryCommand.results = results
monkeypatch.setattr(
history_endpoint,
"get_chain_transfer_execution_port",
lambda: repository,
)
monkeypatch.setattr(history_endpoint, "TransferExecutionCommand", _RetryCommand)
return repository
def test_transfer_history_task_id_is_internal_projection_only() -> None:
"""durable 任务标识可供宿主读取,但不得扩展公开历史响应。"""
history = TransferHistory(id=7, transfer_task_id="task-7")
assert history.transfer_task_id == "task-7"
assert "transfer_task_id" not in history.model_dump()
def test_durable_retry_progress_is_immediately_completed_for_existing_sse() -> None:
"""durable 重试完成进度应让既有 SSE 客户端首次读取即可收口。"""
async def scenario() -> dict:
"""写入并回读同一个测试进度键。"""
progress_key = "test_history_durable_retry_completed"
await history_endpoint._complete_durable_retry_progress(
progress_key=progress_key,
text="整理任务已登记重试",
history_ids=[8],
)
detail = await AsyncProgressHelper(progress_key).get()
assert detail is not None
return detail
detail = asyncio.run(scenario())
assert detail["enable"] is False
assert detail["value"] == 100
assert detail["data"]["history_ids"] == [8]
assert detail["data"]["success"] is True
assert detail["data"]["completed"] is True
assert detail["data"]["message"] == "整理任务已登记重试"
def test_single_ai_redo_requests_durable_retry_without_agent(monkeypatch) -> None:
"""单条 durable AI 重做只登记调度重试,即使 Agent 功能未启用。"""
repository = _install_retry_command(
monkeypatch,
{
"task-11": _retry_result(
accepted=True,
state=TransferExecutionState.RETRY_WAIT,
message="整理任务已登记重试",
)
},
)
monkeypatch.setattr(
history_endpoint,
"_start_ai_redo_task",
lambda **_kwargs: (_ for _ in ()).throw(
AssertionError("durable 重试不得启动 Agent")
),
)
completed_progress: list[dict] = []
monkeypatch.setattr(
history_endpoint,
"_complete_durable_retry_progress",
lambda **kwargs: _record_async(completed_progress, kwargs),
)
response = asyncio.run(
history_endpoint.ai_redo_transfer_history(
11,
query=_HistoryQuery([TransferHistory(id=11, transfer_task_id="task-11")]),
runtime_config=_runtime(ai_enabled=False),
task_registry=object(),
_=object(),
)
)
assert response.success is True
assert response.data is not None
assert response.data["progress_key"].startswith("transfer_retry_11_")
assert response.message == "整理任务已登记重试"
assert completed_progress[0]["history_ids"] == [11]
assert _RetryCommand.calls == [
(
repository,
{
"task_id": "task-11",
"reason": "AI REST 请求重试整理历史 #11",
"requested_by": "history_ai_redo",
},
)
]
def test_single_ai_redo_reports_manual_review_rejection(monkeypatch) -> None:
"""人工复核状态必须原样拒绝,且不得回退到破坏性 Agent 流程。"""
_install_retry_command(
monkeypatch,
{
"task-12": _retry_result(
accepted=False,
state=TransferExecutionState.MANUAL_REVIEW,
message="人工复核任务必须先完成专门判定",
)
},
)
monkeypatch.setattr(
history_endpoint,
"build_manual_redo_prompt",
lambda _history: (_ for _ in ()).throw(
AssertionError("拒绝后不得生成 Agent 提示词")
),
)
response = asyncio.run(
history_endpoint.ai_redo_transfer_history(
12,
query=_HistoryQuery([TransferHistory(id=12, transfer_task_id="task-12")]),
runtime_config=_runtime(),
task_registry=object(),
_=object(),
)
)
assert response.success is False
assert response.message == "人工复核任务必须先完成专门判定"
def test_batch_ai_redo_returns_completed_progress_for_durable_tasks(monkeypatch) -> None:
"""全 durable 批量接受后保留前端既有 progress_key 协议。"""
_install_retry_command(
monkeypatch,
{
"task-18": _retry_result(
accepted=True,
state=TransferExecutionState.RETRY_WAIT,
message="整理任务已登记重试",
),
"task-19": _retry_result(
accepted=True,
state=TransferExecutionState.RETRY_WAIT,
message="整理任务已在等待重试",
),
},
)
completed_progress: list[dict] = []
monkeypatch.setattr(
history_endpoint,
"_complete_durable_retry_progress",
lambda **kwargs: _record_async(completed_progress, kwargs),
)
monkeypatch.setattr(
history_endpoint,
"_start_batch_ai_redo_task",
lambda **_kwargs: (_ for _ in ()).throw(
AssertionError("全 durable 批量不得启动 Agent")
),
)
response = asyncio.run(
history_endpoint.batch_ai_redo_transfer_history(
BatchTransferHistoryRedoRequest(history_ids=[18, 19]),
query=_HistoryQuery([
TransferHistory(id=18, transfer_task_id="task-18"),
TransferHistory(id=19, transfer_task_id="task-19"),
]),
runtime_config=_runtime(ai_enabled=False),
task_registry=object(),
_=object(),
)
)
assert response.success is True
assert response.data is not None
assert response.data["progress_key"].startswith("transfer_retry_batch_")
assert response.data["history_ids"] == [18, 19]
assert completed_progress[0]["history_ids"] == [18, 19]
def test_batch_ai_redo_reports_each_rejection_without_starting_legacy_agent(
monkeypatch,
) -> None:
"""混合批量逐 task 返回拒绝,避免同时产生无人监听的旧 Agent 任务。"""
_install_retry_command(
monkeypatch,
{
"task-21": _retry_result(
accepted=True,
state=TransferExecutionState.RETRY_WAIT,
message="整理任务已登记重试",
),
"task-22": _retry_result(
accepted=False,
state=TransferExecutionState.RUNNING,
message="整理任务当前状态不接受用户重试",
),
},
)
started: list[dict] = []
prompted: list[list[int]] = []
monkeypatch.setattr(
history_endpoint,
"build_batch_manual_redo_prompt",
lambda histories: prompted.append([history.id for history in histories]) or "legacy prompt",
)
monkeypatch.setattr(
history_endpoint,
"_start_batch_ai_redo_task",
lambda **kwargs: started.append(kwargs),
)
histories = [
TransferHistory(id=21, transfer_task_id="task-21"),
TransferHistory(id=22, transfer_task_id="task-22"),
TransferHistory(id=23),
]
response = asyncio.run(
history_endpoint.batch_ai_redo_transfer_history(
BatchTransferHistoryRedoRequest(history_ids=[21, 22, 23]),
query=_HistoryQuery(histories),
runtime_config=_runtime(),
task_registry=object(),
_=object(),
)
)
assert response.success is False
assert "已登记 1 个持久整理任务重试" in response.message
assert "#22 [running]: 整理任务当前状态不接受用户重试" in response.message
assert response.data is None
assert "1 条旧历史未提交" in response.message
assert prompted == []
assert [call[1]["task_id"] for call in _RetryCommand.calls] == [
"task-21",
"task-22",
]
assert started == []
def test_batch_ai_redo_sends_only_legacy_records_after_durable_acceptance(
monkeypatch,
) -> None:
"""混合批量全接受时 durable 只登记重试,旧历史才进入 Agent。"""
_install_retry_command(
monkeypatch,
{
"task-24": _retry_result(
accepted=True,
state=TransferExecutionState.RETRY_WAIT,
message="整理任务已登记重试",
)
},
)
prompted: list[list[int]] = []
started: list[dict] = []
monkeypatch.setattr(
history_endpoint,
"build_batch_manual_redo_prompt",
lambda histories: prompted.append([history.id for history in histories]) or "legacy prompt",
)
monkeypatch.setattr(
history_endpoint,
"_start_batch_ai_redo_task",
lambda **kwargs: started.append(kwargs),
)
response = asyncio.run(
history_endpoint.batch_ai_redo_transfer_history(
BatchTransferHistoryRedoRequest(history_ids=[24, 25]),
query=_HistoryQuery([
TransferHistory(id=24, transfer_task_id="task-24"),
TransferHistory(id=25),
]),
runtime_config=_runtime(),
task_registry=object(),
_=object(),
)
)
assert response.success is True
assert response.data is not None
assert response.data["history_ids"] == [24, 25]
assert prompted == [[25]]
assert started[0]["history_ids"] == [25]
def test_agent_delete_tool_requests_retry_before_any_destructive_action(monkeypatch) -> None:
"""Agent 工具命中 durable 历史时保留目标、历史和失败证据。"""
history = SimpleNamespace(
id=31,
transfer_task_id="task-31",
dest_fileitem={"path": "/library/demo.mkv"},
)
delete_calls: list[int] = []
class _HistoryPort:
"""提供 durable 历史并观察是否发生删除。"""
async def async_get(self, history_id: int) -> object:
"""返回 durable 历史。"""
assert history_id == 31
return history
async def async_delete(self, history_id: int) -> None:
"""记录不应发生的历史删除。"""
delete_calls.append(history_id)
retry_calls: list[dict] = []
monkeypatch.setattr(
"app.agent.tools.impl.delete_transfer_history.get_agent_transfer_history_port",
_HistoryPort,
)
monkeypatch.setattr(
"app.agent.tools.impl.delete_transfer_history._request_transfer_retry",
lambda **kwargs: retry_calls.append(kwargs)
or _retry_result(
accepted=False,
state=TransferExecutionState.MANUAL_REVIEW,
message="人工复核任务必须先完成专门判定",
),
)
monkeypatch.setattr(
"app.agent.tools.impl.delete_transfer_history.StorageChain",
lambda: (_ for _ in ()).throw(
AssertionError("durable 目标文件不得删除")
),
)
result = asyncio.run(
DeleteTransferHistoryTool(
session_id="redo-session",
user_id="10001",
).run(history_id=31)
)
assert "未登记重试" in result
assert "state=manual_review" in result
assert "不要调用 transfer_file" in result
assert retry_calls == [
{
"history_id": 31,
"task_id": "task-31",
"user_id": "10001",
}
]
assert delete_calls == []
@@ -45,3 +45,22 @@ def test_batch_manual_redo_job_definition_contains_plain_text_rules():
assert any("plain text only" in rule for rule in task_rules)
assert any("Markdown formatting" in rule for rule in task_rules)
def test_manual_redo_tasks_stop_after_durable_retry_result():
"""系统任务必须禁止 durable 历史在登记重试后继续直接整理。"""
definition = prompt_manager.load_system_tasks_definition()
for task_name in (
"transfer_failed_retry",
"batch_transfer_failed_retry",
"manual_transfer_redo",
"batch_manual_transfer_redo",
):
task = definition.task_types[task_name]
instructions = [*task.steps, *task.task_rules]
assert any("persistent retry scheduler" in item for item in instructions)
assert any(
"do not call `transfer_file`" in item.lower()
for item in instructions
)
+17
View File
@@ -37,6 +37,7 @@ def _history():
download_hash="abc",
src_fileitem={"path": "/downloads/demo.mkv"},
dest_fileitem={"path": "/media/demo.mkv"},
transfer_task_id=None,
)
@@ -119,5 +120,21 @@ def test_transfer_truncate_uses_single_transaction():
result = command.truncate()
assert result.success is True
assert result.message == "已清空旧整理记录,失败任务记录已保留"
dependencies["repository"].stage_truncate.assert_called_once_with()
dependencies["unit_of_work"].commit.assert_called_once_with()
def test_transfer_delete_rejects_durable_receipt_before_file_side_effects():
"""durable 回执不能被历史 API 连同源或目标文件一起删除。"""
history = _history()
history.transfer_task_id = "task-durable"
command, dependencies = _transfer_command(history=history)
result = command.delete(7, delete_source=True, delete_destination=True)
assert result.success is False
assert result.message == "持久整理失败记录不可删除,请使用重试或人工复核入口"
dependencies["delete_media_file"].assert_not_called()
dependencies["repository"].stage_delete.assert_not_called()
dependencies["unit_of_work"].commit.assert_not_called()
+45
View File
@@ -3,6 +3,7 @@ import importlib
import pytest
from app.application.transfer import TransferTask as CanonicalTransferTask
from app.db.models.transferhistory import TransferHistory
from app.schemas.file import FileItem
@@ -136,6 +137,50 @@ def test_legacy_transfer_history_writes_delegate_to_application_service(
assert captured == {**arguments, "transfer_history_oper": oper}
def test_legacy_transfer_history_mutations_preserve_durable_receipts(db):
"""旧插件 delete/truncate/add_force 不能删除或覆盖 durable 终态回执。"""
legacy = importlib.import_module("app.db.transferhistory_oper")
durable = TransferHistory(
src="/downloads/durable.mkv",
src_storage="local",
status=True,
transfer_task_id="task-durable",
transfer_settlement_revision=1,
)
legacy_row = TransferHistory(
src="/downloads/legacy.mkv",
src_storage="local",
status=True,
)
db.add(durable, legacy_row)
oper = legacy.TransferHistoryOper(db.session)
oper.delete(durable.id)
oper.truncate()
assert TransferHistory.get_by_transfer_task_id(
db.session,
task_id="task-durable",
) is not None
assert TransferHistory.get_by_src(
db.session,
"/downloads/legacy.mkv",
"local",
) is None
with pytest.raises(ValueError, match="持久整理回执"):
oper.add_force(
src="/downloads/durable.mkv",
src_storage="local",
status=False,
)
receipt = TransferHistory.get_by_transfer_task_id(
db.session,
task_id="task-durable",
)
assert receipt is not None
assert receipt.status is True
class LegacyPydanticValue:
"""模拟旧插件放进 TransferTask 的 Pydantic 风格对象。"""
+213
View File
@@ -0,0 +1,213 @@
"""验证历史重试入口只把 durable 任务交给持久恢复调度器。"""
from types import SimpleNamespace
from app.application.transfer_execution import (
TransferExecutionState,
TransferRetryRequestResult,
)
from app.chain.transfer import TransferChain
from app.schemas.types import NotificationChannel
class _RetryCommand:
"""记录历史入口提交的类型化重试请求。"""
calls: list[tuple[object, dict]] = []
result = TransferRetryRequestResult(
accepted=True,
state=TransferExecutionState.RETRY_WAIT,
retry_generation=2,
message="整理任务已登记重试",
)
def __init__(self, repository: object) -> None:
"""保存测试仓储实例。"""
self._repository = repository
def request_retry(self, **kwargs) -> TransferRetryRequestResult:
"""记录请求并返回用例指定结果。"""
self.calls.append((self._repository, kwargs))
return self.result
def _install_retry_port(monkeypatch) -> object:
"""安装不会接触数据库的 execution 端口与命令替身。"""
repository = object()
_RetryCommand.calls = []
_RetryCommand.result = TransferRetryRequestResult(
accepted=True,
state=TransferExecutionState.RETRY_WAIT,
retry_generation=2,
message="整理任务已登记重试",
)
monkeypatch.setattr(
"app.chain._transfer.get_chain_transfer_execution_port",
lambda: repository,
)
monkeypatch.setattr(
"app.chain._transfer.TransferExecutionCommand",
_RetryCommand,
)
return repository
def test_durable_history_redo_only_requests_persistent_retry(monkeypatch):
"""durable 重做不得检查源文件、重新识别或重新准入执行。"""
repository = _install_retry_port(monkeypatch)
history = SimpleNamespace(
id=81,
transfer_task_id="transfer-task-81",
src="/missing/source.mkv",
)
monkeypatch.setattr(
"app.chain._transfer.get_chain_transfer_history_port",
lambda: SimpleNamespace(get=lambda history_id: history),
)
monkeypatch.setattr(
"app.chain._transfer.Path.exists",
lambda _path: (_ for _ in ()).throw(
AssertionError("durable 重试不应检查源路径")
),
)
chain = object.__new__(TransferChain)
monkeypatch.setattr(
chain,
"do_transfer",
lambda **_kwargs: (_ for _ in ()).throw(
AssertionError("durable 重试不应重新准入")
),
)
state, message = chain._re_transfer(logid=81)
assert state is True
assert message == "整理任务已登记重试"
assert _RetryCommand.calls == [
(
repository,
{
"task_id": "transfer-task-81",
"reason": "用户请求重试整理历史 #81",
"requested_by": "history_redo",
},
)
]
def test_durable_manual_cleanup_keeps_target_history_and_failure_budget(monkeypatch):
"""手动重整命中 durable 历史时不得先删目标、历史或失败计数。"""
_install_retry_port(monkeypatch)
history = SimpleNamespace(
id=82,
transfer_task_id="transfer-task-82",
status=False,
mode="copy",
src="/downloads/source.mkv",
src_storage="local",
dest_fileitem={
"storage": "local",
"path": "/library/source.mkv",
"type": "file",
},
)
history_port = SimpleNamespace(
delete=lambda _history_id: (_ for _ in ()).throw(
AssertionError("durable 历史不得删除")
)
)
monkeypatch.setattr(
"app.chain._transfer.StorageChain",
lambda: (_ for _ in ()).throw(
AssertionError("durable 目标不得删除")
),
)
monkeypatch.setattr(
"app.chain._transfer.clear_transfer_failures",
lambda *_args: (_ for _ in ()).throw(
AssertionError("durable 失败计数不得清零")
),
)
chain = object.__new__(TransferChain)
state, message = chain._delete_manual_transfer_history(
history=history,
transfer_history_oper=history_port,
)
assert state is False
assert message == "整理任务已登记重试"
assert _RetryCommand.calls[0][1]["requested_by"] == "manual_reorganize"
def test_durable_ai_button_bypasses_agent_and_requests_scheduler(monkeypatch):
"""AI 按钮命中 durable 历史时也只能登记调度重试。"""
_install_retry_port(monkeypatch)
history = SimpleNamespace(id=83, transfer_task_id="transfer-task-83")
monkeypatch.setattr(
"app.chain._transfer.get_chain_transfer_history_port",
lambda: SimpleNamespace(get=lambda history_id: history),
)
monkeypatch.setattr(
"app.chain._transfer.build_manual_redo_prompt",
lambda _history: (_ for _ in ()).throw(
AssertionError("durable 重试不得生成 Agent 破坏性提示词")
),
)
monkeypatch.setattr(
"app.chain._transfer.get_task_registry",
lambda: (_ for _ in ()).throw(
AssertionError("durable 重试不得提交 Agent 任务")
),
)
messages = []
chain = object.__new__(TransferChain)
chain.runtime_config = SimpleNamespace(
ai_agent_enable=False,
history_url="/history",
)
chain.post_message = messages.append
chain._take_over_transfer_history_by_ai(
history_id=83,
channel=NotificationChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
)
assert len(messages) == 1
assert messages[0].title == "整理任务已登记重试"
assert _RetryCommand.calls[0][1]["requested_by"] == "ai_retry_button"
def test_durable_manual_review_rejection_does_not_fall_back_to_legacy(monkeypatch):
"""人工复核任务被拒绝后不得回退到旧识别和重整流程。"""
_install_retry_port(monkeypatch)
_RetryCommand.result = TransferRetryRequestResult(
accepted=False,
state=TransferExecutionState.MANUAL_REVIEW,
retry_generation=1,
message="人工复核任务必须先完成专门判定",
)
history = SimpleNamespace(
id=84,
transfer_task_id="transfer-task-84",
src="/downloads/source.mkv",
)
monkeypatch.setattr(
"app.chain._transfer.get_chain_transfer_history_port",
lambda: SimpleNamespace(get=lambda history_id: history),
)
monkeypatch.setattr(
"app.chain._transfer.Path.exists",
lambda _path: (_ for _ in ()).throw(
AssertionError("拒绝后不得回退旧流程")
),
)
chain = object.__new__(TransferChain)
state, message = chain._re_transfer(logid=84)
assert state is False
assert message == "人工复核任务必须先完成专门判定"
+769
View File
@@ -0,0 +1,769 @@
"""整理执行证据 3.0.16 Alembic 迁移的保守升级与可逆性测试。"""
import importlib
from datetime import datetime, timezone
from io import StringIO
from types import SimpleNamespace
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,
TransferExecutionConflictError,
TransferExecutionState,
TransferManualReviewDecision,
TransferManualReviewQuery,
TransferStepIntent,
TransferStepResult,
)
from app.db.adapters.transfer_execution import (
TransactionalTransferExecutionRepository,
)
from app.db.models.transferpending import TransferPending
MIGRATION = "database.versions.e5c7a9b1d3f6_3_0_16"
LEGACY_DIAGNOSTIC = "升级检测到既有执行迹象,需人工确认后再处理"
def _bind_migration(monkeypatch, connection):
"""把 3.0.16 迁移绑定到隔离 SQLite 连接。"""
migration = importlib.import_module(MIGRATION)
monkeypatch.setattr(
migration,
"op",
Operations(MigrationContext.configure(connection)),
)
return migration
def _create_legacy_tables(connection) -> tuple[sa.Table, sa.Table]:
"""创建具备 3.0.15 租约字段的最小 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)),
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"),
)
history = 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),
)
metadata.create_all(connection)
return pending, history
def _insert_legacy_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,
"input_version": 1,
"planning_input": {"schema_version": 1},
"input_fingerprint": "0" * 64,
"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,
}
rows = [
{**base, "id": 1, "task_id": "safe", "src_path": "/safe", "state": "accepted"},
{
**base,
"id": 2,
"task_id": "planned",
"src_path": "/planned",
"state": "planned",
"checkpoint_version": 1,
"checkpoint_payload": {"schema_version": 1},
},
{
**base,
"id": 3,
"task_id": "attempted",
"src_path": "/attempted",
"state": "accepted",
"attempt_count": 1,
},
{
**base,
"id": 4,
"task_id": "provider",
"src_path": "/provider",
"state": "provider_pending",
"checkpoint_version": 1,
"checkpoint_payload": {"provider": True},
},
]
connection.execute(pending.insert(), rows)
def _assert_execution_tables_match_models(connection) -> None:
"""断言步骤与 append-only 回执表的字段、约束和索引精确匹配 ORM。"""
inspector = sa.inspect(connection)
step_columns = {
column["name"]: column["nullable"]
for column in inspector.get_columns("transferexecutionstep")
}
assert step_columns == {
"id": False,
"task_id": False,
"operation_id": False,
"checkpoint_fingerprint": False,
"ordinal": False,
"phase": False,
"kind": False,
"state": False,
"attempt_token": True,
"attempt_count": False,
"intent_version": False,
"intent_payload": False,
"result_version": True,
"result_payload": True,
"last_error": True,
"prepared_at": False,
"started_at": True,
"completed_at": True,
"updated_at": False,
}
assert {
column["name"]: str(column["type"])
for column in inspector.get_columns("transferexecutionstep")
} == {
"id": "INTEGER",
"task_id": "VARCHAR(64)",
"operation_id": "VARCHAR(64)",
"checkpoint_fingerprint": "VARCHAR(64)",
"ordinal": "INTEGER",
"phase": "VARCHAR(32)",
"kind": "VARCHAR(32)",
"state": "VARCHAR(32)",
"attempt_token": "VARCHAR(64)",
"attempt_count": "INTEGER",
"intent_version": "INTEGER",
"intent_payload": "JSON",
"result_version": "INTEGER",
"result_payload": "JSON",
"last_error": "TEXT",
"prepared_at": "VARCHAR(40)",
"started_at": "VARCHAR(40)",
"completed_at": "VARCHAR(40)",
"updated_at": "VARCHAR(40)",
}
assert inspector.get_pk_constraint("transferexecutionstep")[
"constrained_columns"
] == ["id"]
assert [
(
item["constrained_columns"],
item["referred_table"],
item["referred_columns"],
item["options"].get("ondelete"),
)
for item in inspector.get_foreign_keys("transferexecutionstep")
] == [(["task_id"], "transferpending", ["task_id"], "CASCADE")]
step_uniques = {
item["name"]: item["column_names"]
for item in inspector.get_unique_constraints("transferexecutionstep")
}
assert step_uniques == {
"uq_transferexecutionstep_operation_id": ["operation_id"],
"uq_transferexecutionstep_task_ordinal": ["task_id", "ordinal"],
}
step_indexes = {
item["name"]: item["column_names"]
for item in inspector.get_indexes("transferexecutionstep")
}
assert step_indexes == {
"ix_transferexecutionstep_task_state_ordinal": [
"task_id",
"state",
"ordinal",
],
}
receipt_columns = {
column["name"]: column["nullable"]
for column in inspector.get_columns("transfersettlementreceipt")
}
assert receipt_columns == {
"id": False,
"task_id": False,
"history_id": False,
"settlement_revision": False,
"outcome": False,
"execution_fingerprint": False,
"lease_token": False,
"history_status": False,
"src": True,
"src_storage": True,
"pending_deleted": False,
"error": True,
"created_at": False,
"updated_at": False,
}
assert {
column["name"]: str(column["type"])
for column in inspector.get_columns("transfersettlementreceipt")
} == {
"id": "INTEGER",
"task_id": "VARCHAR(64)",
"history_id": "INTEGER",
"settlement_revision": "INTEGER",
"outcome": "VARCHAR(16)",
"execution_fingerprint": "VARCHAR(64)",
"lease_token": "VARCHAR(64)",
"history_status": "BOOLEAN",
"src": "VARCHAR",
"src_storage": "VARCHAR",
"pending_deleted": "BOOLEAN",
"error": "TEXT",
"created_at": "VARCHAR(40)",
"updated_at": "VARCHAR(40)",
}
assert inspector.get_pk_constraint("transfersettlementreceipt")[
"constrained_columns"
] == ["id"]
assert inspector.get_foreign_keys("transfersettlementreceipt") == []
receipt_uniques = {
item["name"]: item["column_names"]
for item in inspector.get_unique_constraints("transfersettlementreceipt")
}
assert receipt_uniques == {
"uq_transfersettlementreceipt_task_revision": [
"task_id",
"settlement_revision",
],
}
receipt_indexes = {
item["name"]: item["column_names"]
for item in inspector.get_indexes("transfersettlementreceipt")
}
assert receipt_indexes == {
"ix_transfersettlementreceipt_history_id": ["history_id"],
"ix_transfersettlementreceipt_task_revision": [
"task_id",
"settlement_revision",
],
}
def test_upgrade_is_conservative_and_repairs_interrupted_indexes(monkeypatch):
"""旧执行迹象必须隔离,重复升级应补齐索引且不覆盖保守状态。"""
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(
"DROP INDEX ix_transferexecutionstep_task_state_ordinal"
))
connection.execute(sa.text(
"DROP INDEX ix_transfersettlementreceipt_history_id"
))
connection.execute(sa.text(
"DROP INDEX ix_transfersettlementreceipt_task_revision"
))
migration.upgrade()
states = dict(connection.execute(sa.text(
"SELECT task_id, execution_state FROM transferpending ORDER BY id"
)).all())
assert states == {
"safe": "not_started",
"planned": "manual_review",
"attempted": "manual_review",
"provider": "manual_review",
}
inspector = sa.inspect(connection)
assert {
"transferexecutionstep",
"transfersettlementreceipt",
}.issubset(inspector.get_table_names())
execution_due_index = next(
index
for index in inspector.get_indexes("transferpending")
if index["name"] == "ix_transferpending_execution_due"
)
assert execution_due_index["column_names"] == [
"execution_state",
"retry_due_at",
"state",
"created_at",
"id",
]
assert "ix_transferexecutionstep_task_state_ordinal" in {
index["name"] for index in inspector.get_indexes("transferexecutionstep")
}
assert "ux_transferhistory_transfer_task_id" in {
index["name"] for index in inspector.get_indexes("transferhistory")
}
_assert_execution_tables_match_models(connection)
engine.dispose()
def test_downgrade_marks_step_evidence_then_reupgrade_keeps_manual_review(
monkeypatch,
) -> None:
"""降级不得丢失执行不确定性,再升级也不能把该任务自动重放。"""
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 transferexecutionstep ("
"task_id, operation_id, checkpoint_fingerprint, ordinal, phase, kind, "
"state, attempt_token, attempt_count, intent_version, intent_payload, "
"prepared_at, updated_at"
") VALUES ("
"'safe', 'operation', 'plan', 0, 'transfer', 'copy', "
"'prepared', NULL, 0, 1, '{}', "
"'2026-08-27 10:00:00', '2026-08-27 10:00:00'"
")"
))
connection.execute(sa.text(
"UPDATE transferpending SET last_error = '原始失败细节' "
"WHERE task_id = 'safe'"
))
migration.downgrade()
assert connection.execute(sa.text(
"SELECT state FROM transferpending WHERE task_id = 'safe'"
)).scalar_one() == "accepted"
assert connection.execute(sa.text(
"SELECT last_error FROM transferpending WHERE task_id = 'safe'"
)).scalar_one() == f"原始失败细节\n{LEGACY_DIAGNOSTIC}"
inspector = sa.inspect(connection)
assert "transferexecutionstep" not in inspector.get_table_names()
assert "transfersettlementreceipt" not in inspector.get_table_names()
assert "execution_state" not in {
column["name"] for column in inspector.get_columns("transferpending")
}
migration.upgrade()
assert connection.execute(sa.text(
"SELECT execution_state FROM transferpending WHERE task_id = 'safe'"
)).scalar_one() == "manual_review"
engine.dispose()
def test_upgrade_without_pending_table_is_a_safe_noop(monkeypatch):
"""全新数据库尚未执行前置迁移时本版本应安全等待迁移链建表。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
assert sa.inspect(connection).get_table_names() == []
engine.dispose()
def test_execution_table_ddl_compiles_for_postgresql(monkeypatch) -> None:
"""步骤与回执建表 DDL 必须在生产 PostgreSQL 方言下可编译。"""
output = StringIO()
context = MigrationContext.configure(
dialect_name="postgresql",
opts={"as_sql": True, "output_buffer": output},
)
migration = importlib.import_module(MIGRATION)
monkeypatch.setattr(migration, "op", Operations(context))
migration._create_step_table()
migration._create_receipt_table()
ddl = output.getvalue()
assert "CREATE TABLE transferexecutionstep" in ddl
assert "FOREIGN KEY(task_id) REFERENCES transferpending (task_id) ON DELETE CASCADE" in ddl
assert "CREATE TABLE transfersettlementreceipt" in ddl
assert "CONSTRAINT uq_transfersettlementreceipt_task_revision UNIQUE" in ddl
def test_repair_indexes_ignores_postgresql_unique_backing_index(monkeypatch) -> None:
"""PG 唯一约束后端索引应留给约束治理,普通意外索引仍须删除。"""
migration = importlib.import_module(MIGRATION)
table_name = "transfersettlementreceipt"
indexes = [
{
"name": "uq_transfersettlementreceipt_task_revision",
"column_names": ["task_id", "settlement_revision"],
"unique": True,
"duplicates_constraint": "uq_transfersettlementreceipt_task_revision",
},
{
"name": "ix_transfersettlementreceipt_task_revision",
"column_names": ["task_id", "settlement_revision"],
"unique": False,
},
{
"name": "ix_transfersettlementreceipt_unexpected",
"column_names": ["outcome"],
"unique": False,
},
]
dropped = []
created = []
def drop_index(index_name: str, *, table_name: str) -> None:
"""记录删除并模拟 PostgreSQL 反射结果随 DDL 更新。"""
dropped.append((index_name, table_name))
indexes[:] = [item for item in indexes if item["name"] != index_name]
inspector = SimpleNamespace(
get_table_names=lambda: [table_name],
get_indexes=lambda inspected_table: list(indexes),
)
monkeypatch.setattr(migration.sa, "inspect", lambda bind: inspector)
monkeypatch.setattr(
migration,
"op",
SimpleNamespace(
get_bind=lambda: object(),
drop_index=drop_index,
create_index=lambda *args, **kwargs: created.append((args, kwargs)),
),
)
migration._repair_indexes(
table_name=table_name,
expected={
"ix_transfersettlementreceipt_task_revision": (
"task_id",
"settlement_revision",
),
},
)
assert dropped == [
("ix_transfersettlementreceipt_unexpected", table_name),
]
assert created == []
assert any(
item["name"] == "uq_transfersettlementreceipt_task_revision"
for item in indexes
)
def test_interrupted_column_upgrade_preserves_existing_manual_state(monkeypatch):
"""字段阶段中断后重跑应补齐 schema 且保留已写入的人工复核状态。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
pending, _ = _create_legacy_tables(connection)
_insert_legacy_rows(connection, pending)
connection.execute(sa.text(
"ALTER TABLE transferpending ADD COLUMN execution_state VARCHAR(32)"
))
connection.execute(sa.text(
"UPDATE transferpending SET execution_state = 'manual_review' "
"WHERE task_id = 'safe'"
))
connection.execute(sa.text(
"ALTER TABLE transferhistory ADD COLUMN transfer_task_id VARCHAR(64)"
))
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
assert connection.execute(sa.text(
"SELECT execution_state FROM transferpending WHERE task_id = 'safe'"
)).scalar_one() == "manual_review"
pending_columns = {
column["name"]
for column in sa.inspect(connection).get_columns("transferpending")
}
assert {
"execution_fingerprint",
"retry_generation",
"retry_requested_by",
"settlement_revision",
}.issubset(pending_columns)
assert "transfer_settlement_revision" in {
column["name"]
for column in sa.inspect(connection).get_columns("transferhistory")
}
engine.dispose()
def test_upgrade_recreates_empty_partial_execution_tables(monkeypatch) -> None:
"""中断升级留下的空残表应无损重建为完整步骤与回执 schema。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
pending, _ = _create_legacy_tables(connection)
_insert_legacy_rows(connection, pending)
connection.execute(sa.text(
"CREATE TABLE transferexecutionstep ("
"id INTEGER PRIMARY KEY, task_id VARCHAR(64) NOT NULL)"
))
connection.execute(sa.text(
"CREATE TABLE transfersettlementreceipt ("
"id INTEGER PRIMARY KEY, task_id VARCHAR(64) NOT NULL)"
))
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
_assert_execution_tables_match_models(connection)
engine.dispose()
def test_upgrade_replaces_old_single_task_receipt_unique(monkeypatch) -> None:
"""中断版本的 task 单列唯一约束必须移除,保留数据后允许追加新 revision。"""
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 ("
"'task-a', 1, 1, 'succeeded', 'fingerprint-1', 'lease-1', 1, "
"'/src', 'local', 1, NULL, "
"'2026-08-27 10:00:00', '2026-08-27 10:00:00'"
")"
))
with migration.op.batch_alter_table("transfersettlementreceipt") as batch_op:
batch_op.drop_constraint(
"uq_transfersettlementreceipt_task_revision",
type_="unique",
)
batch_op.create_unique_constraint(
"uq_transfersettlementreceipt_task_id",
["task_id"],
)
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 ("
"'task-a', 2, 2, 'failed', 'fingerprint-2', 'lease-2', 0, "
"'/src', 'local', 1, 'failed', "
"'2026-08-27 11:00:00', '2026-08-27 11:00:00'"
")"
))
assert connection.execute(sa.text(
"SELECT settlement_revision FROM transfersettlementreceipt "
"WHERE task_id = 'task-a' ORDER BY settlement_revision"
)).scalars().all() == [1, 2]
_assert_execution_tables_match_models(connection)
engine.dispose()
@pytest.mark.parametrize(
("table_name", "create_sql", "insert_sql"),
(
(
"transferexecutionstep",
"CREATE TABLE transferexecutionstep ("
"id INTEGER PRIMARY KEY, task_id VARCHAR(64) NOT NULL)",
"INSERT INTO transferexecutionstep (id, task_id) VALUES (1, 'planned')",
),
(
"transfersettlementreceipt",
"CREATE TABLE transfersettlementreceipt ("
"id INTEGER PRIMARY KEY, task_id VARCHAR(64) NOT NULL)",
"INSERT INTO transfersettlementreceipt (id, task_id) "
"VALUES (1, 'settled')",
),
),
)
def test_upgrade_rejects_nonempty_partial_execution_table(
monkeypatch,
table_name: str,
create_sql: str,
insert_sql: str,
) -> None:
"""含数据残表不能猜测修复,且必须报告明确迁移冲突而非缺列 SQL。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
pending, _ = _create_legacy_tables(connection)
_insert_legacy_rows(connection, pending)
connection.execute(sa.text(create_sql))
connection.execute(sa.text(insert_sql))
migration = _bind_migration(monkeypatch, connection)
with pytest.raises(
RuntimeError,
match=rf"含数据的不完整迁移表 {table_name}.*缺少字段",
):
migration.upgrade()
engine.dispose()
def test_upgrade_adds_synthetic_review_when_nonmanual_step_already_exists(
monkeypatch,
) -> None:
"""已有普通步骤不代表可人工判定,迁移仍须补 synthetic 并稳定回填时间。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
pending, _ = _create_legacy_tables(connection)
_insert_legacy_rows(connection, pending)
connection.execute(sa.text(
"UPDATE transferpending SET updated_at = NULL WHERE task_id = 'planned'"
))
migration = _bind_migration(monkeypatch, connection)
migration._add_pending_columns()
migration._backfill_pending()
migration._create_step_table()
connection.execute(sa.text(
"INSERT INTO transferexecutionstep ("
"task_id, operation_id, checkpoint_fingerprint, ordinal, phase, kind, "
"state, attempt_token, attempt_count, intent_version, intent_payload, "
"prepared_at, updated_at"
") VALUES ("
"'planned', 'existing-operation', 'existing-plan', 0, 'transfer', 'copy', "
"'prepared', NULL, 0, 1, '{}', "
"'2026-08-27 09:00:00', '2026-08-27 09:00:00'"
")"
))
migration.upgrade()
migration.upgrade()
rows = connection.execute(sa.text(
"SELECT kind, state, prepared_at FROM transferexecutionstep "
"WHERE task_id = 'planned' ORDER BY ordinal"
)).all()
assert rows == [
("copy", "prepared", "2026-08-27 09:00:00"),
("legacy_execution_review", "manual_review", "2026-08-27 10:00:00"),
]
engine.dispose()
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)
_insert_legacy_rows(connection, pending)
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
factory = sessionmaker(bind=engine, expire_on_commit=False)
repository = TransactionalTransferExecutionRepository(
factory,
local_clock=lambda: datetime(2026, 8, 27, 11, 0, 0),
lease_clock=lambda: datetime(
2026,
8,
27,
3,
0,
0,
tzinfo=timezone.utc,
),
)
query = TransferManualReviewQuery(repository)
command = TransferExecutionCommand(repository)
first_page = query.list(page=1, page_size=2)
second_page = query.list(page=2, page_size=2)
assert first_page.total == 3
assert len(first_page.items) == 2
assert len(second_page.items) == 1
reviews = {
item.task_id: item
for item in (*first_page.items, *second_page.items)
}
assert set(reviews) == {"planned", "attempted", "provider"}
assert all(
item.step.kind == "legacy_execution_review"
for item in reviews.values()
)
assert query.get(task_id="planned") == reviews["planned"]
with pytest.raises(
TransferExecutionConflictError,
match="没有足够证据证明外部操作已发生",
):
command.resolve_manual_review(
task_id="planned",
operation_id=reviews["planned"].step.operation_id,
decision=TransferManualReviewDecision.APPLIED,
actor="admin",
reason="无法仅凭旧状态确认外部结果",
result=TransferStepResult(payload={"confirmed": True}),
)
assert query.get(task_id="planned").state is TransferExecutionState.MANUAL_REVIEW
resolved = []
for task_id in ("planned", "attempted"):
resolved.append(command.resolve_manual_review(
task_id=task_id,
operation_id=reviews[task_id].step.operation_id,
decision=TransferManualReviewDecision.NOT_APPLIED,
actor="admin",
reason="已回滚或确认旧步骤未发生",
result=TransferStepResult(payload={"confirmed": False}),
))
assert all(
item.state is TransferExecutionState.RETRY_WAIT
for item in resolved
)
retry_page = query.list(
state=TransferExecutionState.RETRY_WAIT,
page=1,
page_size=10,
)
assert {item.task_id for item in retry_page.items} == {"planned", "attempted"}
assert query.get(task_id="planned").step.evidence == {"confirmed": False}
assert query.get(task_id="attempted").step.evidence == {"confirmed": False}
with factory() as session:
rows = list(session.scalars(
sa.select(TransferPending).where(
TransferPending.task_id.in_(("planned", "attempted"))
)
).all())
for row in rows:
row.lease_owner = "worker"
row.lease_token = f"lease-{row.task_id}"
row.lease_expires_at = "2099-01-01 00:00:00.000000"
session.commit()
for task_id in ("planned", "attempted"):
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(
task_id=task_id,
checkpoint_fingerprint="f" * 64,
ordinal=0,
phase="transfer",
kind="copy",
payload={"source": task_id},
),
)
assert prepared.ordinal == 0
engine.dispose()
@@ -0,0 +1,454 @@
"""验证整理执行证据、CAS fencing 与终态结算持久化。"""
from datetime import datetime, timezone
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.application.transfer_execution import (
TransferExecutionCheckpoint,
TransferExecutionCommand,
TransferExecutionConflictError,
TransferExecutionState,
TransferManualReviewDecision,
TransferOperationObservation,
TransferOperationObservationState,
TransferStepIntent,
TransferStepResult,
TransferStepState,
build_transfer_checkpoint_fingerprint,
build_transfer_operation_id,
)
from app.db.adapters.transfer_execution import (
TransactionalTransferExecutionRepository,
)
from app.db.base import Base
from app.db.models.transferexecutionstep import TransferExecutionStep
from app.db.models.transferhistory import TransferHistory
from app.db.models.transferpending import TransferPending
@pytest.fixture
def execution_store():
"""构造只含整理执行相关表的独立内存数据库。"""
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(
engine,
tables=[
TransferPending.__table__,
TransferHistory.__table__,
TransferExecutionStep.__table__,
],
)
factory = sessionmaker(bind=engine, expire_on_commit=False)
try:
yield factory
finally:
engine.dispose()
def _seed_pending(factory, *, task_id: str = "task-1", lease_token: str = "lease-1"):
"""写入一条带有效租约与合法 planning checkpoint 的待执行任务。"""
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",
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",
lease_owner="worker-1",
lease_token=lease_token,
lease_expires_at="2099-01-01 00:00:00.000000",
heartbeat_at="2026-08-27 01:00:00.000000",
attempt_count=1,
execution_state="not_started",
retry_generation=0,
retry_count=0,
settlement_revision=0,
))
session.commit()
def _repository(factory, token_values: list[str] | None = None):
"""构造固定时钟与可预测 attempt token 的执行命令。"""
repository = TransactionalTransferExecutionRepository(
factory,
local_clock=lambda: datetime(2026, 8, 27, 9, 30, 0),
lease_clock=lambda: datetime(2026, 8, 27, 1, 30, 0, tzinfo=timezone.utc),
)
values = iter(token_values or ["attempt-1", "attempt-2", "attempt-3"])
return repository, TransferExecutionCommand(
repository,
attempt_token_factory=lambda: next(values),
)
def _intent(*, task_id: str = "task-1", ordinal: int = 0) -> TransferStepIntent:
"""构造稳定且可重复计算身份的测试步骤意图。"""
return TransferStepIntent.create(
task_id=task_id,
checkpoint_fingerprint="plan-fingerprint",
ordinal=ordinal,
phase="transfer",
kind="copy",
payload={"src": f"/{task_id}.mkv", "dest": f"/media/{task_id}.mkv"},
)
def test_stable_operation_and_checkpoint_identities_are_canonical():
"""字段顺序不能改变 operation ID 或执行 checkpoint 指纹。"""
first = build_transfer_operation_id(
task_id="task",
checkpoint_fingerprint="plan",
ordinal=2,
phase="scrape",
kind="write",
intent_payload={"b": 2, "a": 1},
)
second = build_transfer_operation_id(
task_id="task",
checkpoint_fingerprint="plan",
ordinal=2,
phase="scrape",
kind="write",
intent_payload={"a": 1, "b": 2},
)
assert first == second
assert build_transfer_checkpoint_fingerprint({"b": 2, "a": 1}) == (
build_transfer_checkpoint_fingerprint({"a": 1, "b": 2})
)
mutable_payload = {"path": "/original"}
intent = TransferStepIntent.create(
task_id="task",
checkpoint_fingerprint="plan",
ordinal=0,
phase="transfer",
kind="copy",
payload=mutable_payload,
)
mutable_payload["path"] = "/mutated"
assert intent.payload == {"path": "/original"}
def test_success_path_persists_steps_and_execution_checkpoint(execution_store):
"""成功路径应保留每步证据,并提交可供唯一 durable writer 结算的检查点。"""
_seed_pending(execution_store)
repository, command = _repository(execution_store)
prepared = command.prepare(
task_id="task-1",
lease_token="lease-1",
intent=_intent(),
)
assert prepared.state is TransferStepState.PREPARED
started = command.begin(
task_id="task-1",
lease_token="lease-1",
operation_id=prepared.operation_id,
)
assert started.attempt_token == "attempt-1"
succeeded = command.complete(
task_id="task-1",
lease_token="lease-1",
step=started,
result=TransferStepResult(payload={"dest_exists": True}),
)
checkpoint = TransferExecutionCheckpoint.create(
payload={"dest": "/media/task-1.mkv"},
operation_ids=(succeeded.operation_id,),
)
snapshot = command.checkpoint(
task_id="task-1",
lease_token="lease-1",
checkpoint=checkpoint,
)
assert snapshot.state is TransferExecutionState.SETTLING
with execution_store() as session:
pending = session.scalar(select(TransferPending))
assert pending is not None
assert pending.execution_fingerprint == checkpoint.fingerprint
step = session.scalar(select(TransferExecutionStep))
assert step is not None and step.state == "succeeded"
def test_retry_wait_resumes_same_failed_operation_with_new_attempt(execution_store):
"""到期重试必须复用 operation ID、保留失败证据并轮换 attempt token。"""
_seed_pending(execution_store)
repository, 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,
)
deferred = command.defer(
task_id="task-1",
lease_token="lease-1",
step=started,
error="destination unavailable",
retry_due_at="2026-08-27 01:30:01.000000",
evidence=TransferStepResult(payload={"applied": False}),
)
assert deferred.state is TransferExecutionState.RETRY_WAIT
assert deferred.retry_generation == 1
with execution_store() as session:
claimed = TransferPending.claim_task(
session,
task_id="task-1",
states=("planned",),
owner_id="worker-2",
lease_token="lease-2",
now_time="2026-08-27 01:30:02.000000",
lease_expires_at="2099-01-01 00:00:00.000000",
updated_at="2026-08-27 09:30:02",
)
session.commit()
assert claimed == 1
resumed = command.resume_failed(
task_id="task-1",
lease_token="lease-2",
step=deferred.steps[0],
)
assert resumed.operation_id == prepared.operation_id
assert resumed.attempt_token == "attempt-2"
assert resumed.attempt_count == 2
assert resumed.result == TransferStepResult(payload={"applied": False})
assert resumed.last_error == "destination unavailable"
def test_orphan_started_requires_observation_before_attempt_rotation(execution_store):
"""遗留 STARTED 只能凭 NOT_APPLIED 证据轮换 attempt,旧 attempt 随即失效。"""
_seed_pending(execution_store)
repository, 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,
)
observation = TransferOperationObservation(
state=TransferOperationObservationState.NOT_APPLIED,
evidence=TransferStepResult(payload={"dest_exists": False}),
)
restarted = command.restart_after_not_applied(
task_id="task-1",
lease_token="lease-1",
step=started,
evidence=observation.evidence,
)
assert restarted.attempt_token == "attempt-2"
assert restarted.attempt_count == 2
with pytest.raises(TransferExecutionConflictError):
repository.complete_step(
task_id="task-1",
lease_token="lease-1",
operation_id=started.operation_id,
attempt_token="attempt-1",
result=TransferStepResult(payload={"stale": True}),
)
def test_zero_side_effect_checkpoint_is_vacuously_complete(execution_store):
"""纯策略拒绝可在没有步骤行时提交带 skip_reason 的确定执行结果。"""
_seed_pending(execution_store)
_, command = _repository(execution_store)
checkpoint = TransferExecutionCheckpoint.create(
payload={"preview": True, "accepted": False},
operation_ids=(),
skip_reason="preview",
)
snapshot = command.checkpoint(
task_id="task-1",
lease_token="lease-1",
checkpoint=checkpoint,
)
assert snapshot.state is TransferExecutionState.SETTLING
assert snapshot.checkpoint == checkpoint
assert snapshot.steps == ()
def test_exhausted_step_builds_failure_checkpoint_and_keeps_lease(execution_store):
"""预算耗尽应原子建立失败结算检查点,并为 durable writer 保留 lease。"""
_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,
)
snapshot = command.exhaust(
task_id="task-1",
lease_token="lease-1",
step=started,
error="retry budget exhausted",
evidence=TransferStepResult(payload={"applied": False}),
)
assert snapshot.state is TransferExecutionState.SETTLING
assert snapshot.checkpoint is not None
assert snapshot.checkpoint.payload["outcome"] == "failed"
assert snapshot.checkpoint.payload["error"] == "retry budget exhausted"
assert snapshot.checkpoint.operation_ids == (started.operation_id,)
assert snapshot.steps[0].state is TransferStepState.FAILED
with execution_store() as session:
pending = session.scalar(select(TransferPending))
assert pending is not None
assert pending.lease_token == "lease-1"
def test_user_retry_is_single_generation_and_rejects_manual_review(execution_store):
"""FAILED 用户重试只递增一次世代,重复请求幂等且人工复核必须拒绝。"""
_seed_pending(execution_store)
with execution_store() as session:
pending = session.scalar(select(TransferPending))
assert pending is not None
pending.execution_state = "failed"
pending.lease_owner = None
pending.lease_token = None
pending.lease_expires_at = None
pending.terminal_history_id = 42
pending.retry_count = 3
session.commit()
_, command = _repository(execution_store)
first = command.request_retry(
task_id="task-1",
reason="用户确认目标未落地",
requested_by="admin",
)
repeated = command.request_retry(
task_id="task-1",
reason="重复点击",
requested_by="admin",
)
assert first.accepted and repeated.accepted
assert first.retry_generation == repeated.retry_generation == 1
with execution_store() as session:
pending = session.scalar(select(TransferPending))
assert pending is not None
assert pending.execution_state == "retry_wait"
assert pending.retry_count == 3
assert pending.terminal_history_id == 42
assert pending.retry_reason == "用户确认目标未落地"
assert pending.retry_requested_by == "admin"
pending.execution_state = "manual_review"
session.commit()
rejected = command.request_retry(
task_id="task-1",
reason="强制重试",
requested_by="admin",
)
assert not rejected.accepted
assert rejected.state is TransferExecutionState.MANUAL_REVIEW
assert "人工" in rejected.message
def test_manual_not_applied_decision_is_audited_and_schedules_same_step(
execution_store,
) -> None:
"""人工判定未发生应无 lease 地恢复 FAILED,并只交给唯一调度器。"""
_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,
)
manual = command.manual_review(
task_id="task-1",
lease_token="lease-1",
step=started,
error="external result unknown",
)
assert manual.state is TransferExecutionState.MANUAL_REVIEW
resolved = command.resolve_manual_review(
task_id="task-1",
operation_id=started.operation_id,
decision=TransferManualReviewDecision.NOT_APPLIED,
actor="admin",
reason="目标与临时文件均不存在",
result=TransferStepResult(payload={"dest_exists": False}),
)
assert resolved.state is TransferExecutionState.RETRY_WAIT
assert resolved.review_revision == 1
assert resolved.step.state is TransferStepState.FAILED
with execution_store() as session:
pending = session.scalar(select(TransferPending))
assert pending is not None
assert pending.lease_token is None
assert pending.reviewed_by == "admin"
assert pending.review_decision == "not_applied"
assert pending.review_reason == "目标与临时文件均不存在"
with pytest.raises(TransferExecutionConflictError):
command.resolve_manual_review(
task_id="task-1",
operation_id=started.operation_id,
decision=TransferManualReviewDecision.NOT_APPLIED,
actor="admin",
reason="重复判定",
)
def test_manual_applied_requires_result_and_failed_decision_is_rejected(
execution_store,
) -> None:
"""人工判定已发生必须带结果证据,FAILED 不得绕过 lease durable 结算。"""
_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.manual_review(
task_id="task-1",
lease_token="lease-1",
step=started,
error="external result unknown",
)
with pytest.raises(ValueError, match="结果证据"):
command.resolve_manual_review(
task_id="task-1",
operation_id=started.operation_id,
decision=TransferManualReviewDecision.APPLIED,
actor="admin",
reason="已确认目标存在",
)
with pytest.raises(TransferExecutionConflictError, match="durable"):
command.resolve_manual_review(
task_id="task-1",
operation_id=started.operation_id,
decision=TransferManualReviewDecision.FAILED,
actor="admin",
reason="确认失败",
)
resolved = command.resolve_manual_review(
task_id="task-1",
operation_id=started.operation_id,
decision=TransferManualReviewDecision.APPLIED,
actor="admin",
reason="目标摘要匹配",
result=TransferStepResult(payload={"dest_exists": True, "hash_match": True}),
)
assert resolved.step.state is TransferStepState.SUCCEEDED
assert resolved.step.result == TransferStepResult(
payload={"dest_exists": True, "hash_match": True}
)
+217
View File
@@ -0,0 +1,217 @@
"""验证 TransferChain 步骤 runner 与文件执行器的崩溃恢复边界。"""
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import Mock
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.application.transfer_execution import (
TransferExecutionCommand,
TransferExecutionState,
TransferOperationObservation,
TransferOperationObservationState,
TransferStepIntent,
TransferStepResult,
)
from app.chain import transfer as transfer_chain_module
from app.db.adapters.transfer_execution import (
TransactionalTransferExecutionRepository,
)
from app.db.base import Base
from app.db.models.transferexecutionstep import TransferExecutionStep
from app.db.models.transferhistory import TransferHistory
from app.db.models.transferpending import TransferPending
from app.modules.filemanager.transhandler import TransHandler
from app.schemas.workflow import FileItem
@pytest.fixture
def execution_repository():
"""构造带有效 pending 租约的独立执行仓储。"""
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(
engine,
tables=[
TransferPending.__table__,
TransferHistory.__table__,
TransferExecutionStep.__table__,
],
)
factory = sessionmaker(bind=engine, expire_on_commit=False)
with factory() as session:
session.add(TransferPending(
task_id="task-runner",
storage="local",
src_path="/source.mkv",
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},
input_fingerprint="input",
checkpoint_version=1,
checkpoint_payload={"schema_version": 1},
planned_at="2026-08-27 09:00:00",
lease_owner="worker",
lease_token="lease",
lease_expires_at="2099-01-01 00:00:00.000000",
heartbeat_at="2026-08-27 01:00:00.000000",
attempt_count=1,
execution_state="not_started",
retry_generation=0,
retry_count=0,
settlement_revision=0,
))
session.commit()
repository = TransactionalTransferExecutionRepository(
factory,
local_clock=lambda: datetime(2026, 8, 27, 9, 30, 0),
lease_clock=lambda: datetime(2026, 8, 27, 1, 30, 0, tzinfo=timezone.utc),
)
try:
yield repository
finally:
engine.dispose()
def _runner(repository):
"""构造绑定固定任务、租约与计划身份的 durable runner。"""
return transfer_chain_module._DurableTransferStepRunner(
task_id="task-runner",
lease_token="lease",
checkpoint_fingerprint="plan",
repository=repository,
)
def test_runner_replay_returns_persisted_result_without_repeating_side_effect(
execution_repository,
):
"""成功步骤重放只能回读结果,不能再次调用外部执行函数。"""
calls = []
first = _runner(execution_repository).run(
phase="transfer",
kind="copy",
payload={"source": "/source.mkv", "target": "/target.mkv"},
execute=lambda: calls.append("executed") or TransferStepResult(
payload={"item": {"path": "/target.mkv"}}
),
observe=lambda: pytest.fail("新步骤不应执行恢复探测"),
)
second = _runner(execution_repository).run(
phase="transfer",
kind="copy",
payload={"source": "/source.mkv", "target": "/target.mkv"},
execute=lambda: pytest.fail("已成功步骤不得重复执行"),
observe=lambda: pytest.fail("已成功步骤不得执行恢复探测"),
)
assert first == second
assert calls == ["executed"]
def test_runner_routes_unknown_orphaned_attempt_to_manual_review(
execution_repository,
):
"""遗留 STARTED 无法严格判断时必须隔离,不能再次执行副作用。"""
command = TransferExecutionCommand(execution_repository)
prepared = command.prepare(
task_id="task-runner",
lease_token="lease",
intent=TransferStepIntent.create(
task_id="task-runner",
checkpoint_fingerprint="plan",
ordinal=0,
phase="provider",
kind="opaque",
payload={"provider": "legacy"},
),
)
command.begin(
task_id="task-runner",
lease_token="lease",
operation_id=prepared.operation_id,
)
with pytest.raises(
transfer_chain_module._TransferManualReviewRequired,
match="禁止自动重放",
):
_runner(execution_repository).run(
phase="provider",
kind="opaque",
payload={"provider": "legacy"},
execute=lambda: pytest.fail("未知遗留步骤不得重放"),
observe=lambda: TransferOperationObservation(
state=TransferOperationObservationState.UNKNOWN,
evidence=TransferStepResult(payload={"receipt": None}),
),
)
snapshot = execution_repository.get_snapshot(task_id="task-runner")
assert snapshot is not None
assert snapshot.state is TransferExecutionState.MANUAL_REVIEW
class _ImmediateStepRunner:
"""记录 TransHandler 拆分顺序并立即执行步骤的测试 runner。"""
def __init__(self) -> None:
"""初始化步骤记录。"""
self.steps = []
def run(self, *, phase, kind, payload, execute, observe):
"""记录稳定意图并直接执行,不触发恢复探测。"""
self.steps.append((phase, kind, payload))
return execute()
def test_cross_storage_move_materializes_before_independent_source_delete(tmp_path):
"""跨存储 move 必须先复制目标,再用独立步骤删除源。"""
source_path = tmp_path / "source.mkv"
source_path.write_bytes(b"movie")
source_item = FileItem(
storage="local",
path=source_path.as_posix(),
name=source_path.name,
type="file",
size=source_path.stat().st_size,
extension="mkv",
)
target_item = FileItem(
storage="remote",
path="/library/source.mkv",
name="source.mkv",
type="file",
size=source_item.size,
extension="mkv",
)
source_oper = Mock()
source_oper.delete.return_value = True
target_oper = Mock()
target_oper.get_folder.return_value = FileItem(
storage="remote", path="/library", name="library", type="dir"
)
target_oper.upload.return_value = target_item
runner = _ImmediateStepRunner()
result, error = TransHandler._TransHandler__execute_transfer_with_steps(
step_runner=runner,
fileitem=source_item,
target_storage="remote",
source_oper=source_oper,
target_oper=target_oper,
target_file=Path("/library/source.mkv"),
transfer_type="move",
)
assert error == ""
assert result == target_item
assert [kind for _phase, kind, _payload in runner.steps] == [
"materialize_target",
"delete_move_source",
]
assert runner.steps[0][2]["transfer_type"] == "copy"
target_oper.upload.assert_called_once()
source_oper.delete.assert_called_once_with(source_item)
+23 -1
View File
@@ -16,6 +16,24 @@ LEASE_COLUMNS = {
"heartbeat_at",
"attempt_count",
}
TRANSFER_EXECUTION_COLUMNS = {
"execution_state",
"execution_version",
"execution_payload",
"execution_fingerprint",
"retry_generation",
"retry_count",
"retry_due_at",
"retry_requested_by",
"retry_reason",
"settlement_revision",
"terminal_history_id",
"manual_review_revision",
"reviewed_at",
"reviewed_by",
"review_reason",
"review_decision",
}
def _bind_migration(monkeypatch, connection):
@@ -105,7 +123,11 @@ def test_transfer_lease_upgrade_downgrade_reupgrade(monkeypatch) -> None:
assert {
column["name"]
for column in inspector.get_columns("transferpending")
} == {column.name for column in TransferPending.__table__.columns}
} == {
column.name
for column in TransferPending.__table__.columns
if column.name not in TRANSFER_EXECUTION_COLUMNS
}
assert "ix_transferpending_recovery_lease" in {
index["name"]
for index in inspector.get_indexes("transferpending")
+2
View File
@@ -20,6 +20,7 @@ from app.application.transfer import (
TransferProviderReference,
)
from app.db.adapters.transfer import TransactionalTransferAdmissionRepository
from app.db.models.transferhistory import TransferHistory
from app.db.models.transferpending import TransferPending
from app.db.oper.transferpending import TransferPendingOper
@@ -90,6 +91,7 @@ def repository_factory(tmp_path):
f"sqlite:///{tmp_path / 'transfer-lease.db'}",
connect_args={"check_same_thread": False, "timeout": 10},
)
TransferHistory.__table__.create(engine)
TransferPending.__table__.create(engine)
factory = sessionmaker(bind=engine)
yield lambda: TransactionalTransferAdmissionRepository(factory)
@@ -0,0 +1,264 @@
"""验证旧插件同步整理 ABI 复用 canonical durable 终态写入口。"""
import threading
from types import SimpleNamespace
from unittest.mock import Mock, patch
from app.application.transfer_execution import (
TransferExecutionCheckpoint,
TransferSettlementResult,
)
from app.chain.transfer import TransferChain
from app.domain.context import MediaInfo
from app.domain.meta.metabase import MetaBase
from app.schemas.file import FileItem
from app.schemas.transfer import TransferInfo
from app.schemas.types import MediaType
def _fileitem(*, fileid: str = "source-v1", size: int = 1024) -> FileItem:
"""构造可区分同路径版本的兼容调用文件项。"""
return FileItem(
storage="local",
path="/downloads/Movie.2026.mkv",
type="file",
name="Movie.2026.mkv",
basename="Movie.2026",
extension="mkv",
size=size,
modify_time=1770000000 + size,
fileid=fileid,
)
def _result(task, *, success: bool, overwrite_skipped: bool = False) -> TransferInfo:
"""按当前任务构造足以写历史的兼容整理结果。"""
return TransferInfo(
success=success,
overwrite_skipped=overwrite_skipped,
message=None if success else "copy failed",
fileitem=task.fileitem,
target_item=FileItem(
storage="local",
path="/library/Movie (2026)/Movie.mkv",
type="file",
name="Movie.mkv",
basename="Movie",
extension="mkv",
),
transfer_type="copy",
file_list=[task.fileitem.path],
)
def _compat_chain(result_factory):
"""构造只执行旧同步命令和 task-aware 结算的 TransferChain 骨架。"""
chain = object.__new__(TransferChain)
chain._worker_owner_id = "compat-owner"
chain._owned_leases = {}
chain._queued_lease_tokens = set()
chain._worker_state_lock = threading.RLock()
chain.durable_event_writer = Mock()
executed = []
def execute(task, **_kwargs):
"""模拟外部步骤已完成并建立可独立结算的执行检查点。"""
executed.append(task.fileitem.fileid)
task_id = f"task-{task.fileitem.fileid}"
lease_token = f"lease-{task.fileitem.fileid}"
task.bind_admission_task_id(task_id)
task.bind_execution_lease(
owner_id=chain._worker_owner_id,
lease_token=lease_token,
)
chain._owned_leases[task_id] = (lease_token, float("inf"))
result = result_factory(task)
task.bind_execution_checkpoint(TransferExecutionCheckpoint.create(
payload={
"outcome": (
"succeeded"
if result.success
else "failed"
),
"transferinfo": result.model_dump(mode="json"),
},
operation_ids=(f"operation-{task.fileitem.fileid}",),
))
return result
chain._plan_checkpoint_and_execute = Mock(side_effect=execute)
return chain, executed
def _invoke(chain: TransferChain, fileitem: FileItem) -> TransferInfo:
"""以插件可见参数调用同步兼容入口。"""
return chain.execute_legacy_transfer_command(
fileitem=fileitem,
meta=MetaBase(fileitem.name),
mediainfo=MediaInfo(type=MediaType.MOVIE, title="Movie", year="2026"),
target_storage="local",
target_path="/library",
transfer_type="copy",
)
def _settlement_writer(*, status: bool, history_id: int = 41):
"""返回执行 stage_history 并产出 task-aware 结果的 writer side effect。"""
staged_payloads = []
def write(**kwargs):
"""暂存历史后返回与当前结算修订一致的投影。"""
staging = Mock()
def add_force(**payload):
"""保存历史 payload 并返回 writer 所需的最小记录。"""
staged_payloads.append(payload)
return SimpleNamespace(
id=history_id,
status=bool(payload["status"]),
src=payload["src"],
src_storage=payload["src_storage"],
src_fileitem=payload["src_fileitem"],
)
staging.add_force.side_effect = add_force
staging.get_success_by_src.return_value = SimpleNamespace(
id=history_id,
status=True,
src="/downloads/Movie.2026.mkv",
src_storage="local",
src_fileitem=_fileitem().model_dump(mode="json"),
)
history = kwargs["stage_history"](staging)
assert bool(history.status) is status
return TransferSettlementResult(
history_id=history.id,
settlement_revision=1,
pending_deleted=status,
)
return write, staged_payloads
def test_legacy_failed_result_uses_atomic_terminal_writer() -> None:
"""失败结果也必须原子写失败历史并保留 pending,不得直接注销。"""
chain, executed = _compat_chain(lambda task: _result(task, success=False))
write, staged_payloads = _settlement_writer(status=False)
chain.durable_event_writer.transfer_result.side_effect = write
returned = _invoke(chain, _fileitem())
assert returned.success is False
assert returned.message == "copy failed"
assert executed == ["source-v1"]
assert staged_payloads[0]["status"] == 0
call = chain.durable_event_writer.transfer_result.call_args.kwargs
assert call["topic"] is None
assert call["publish"] is None
assert call["settlement"].outcome == "failed"
assert call["settlement"].error == "copy failed"
def test_legacy_settlement_response_loss_replays_receipt_by_same_task_id() -> None:
"""首次提交后响应丢失只能用同一 task_id 回读,不得重做外部步骤。"""
chain, executed = _compat_chain(lambda task: _result(task, success=True))
write, staged_payloads = _settlement_writer(status=True)
writer_calls = 0
def response_lost_then_receipt(**kwargs):
"""首次提交历史后模拟响应丢失,第二次只返回 immutable receipt。"""
nonlocal writer_calls
writer_calls += 1
if writer_calls == 1:
write(**kwargs)
raise RuntimeError("response lost after commit")
return TransferSettlementResult(
history_id=41,
settlement_revision=1,
pending_deleted=True,
already_settled=True,
)
chain.durable_event_writer.transfer_result.side_effect = response_lost_then_receipt
returned = _invoke(chain, _fileitem())
assert returned.success is True
assert executed == ["source-v1"]
assert writer_calls == 2
assert len(staged_payloads) == 1
first = chain.durable_event_writer.transfer_result.call_args_list[0].kwargs
second = chain.durable_event_writer.transfer_result.call_args_list[1].kwargs
assert second["settlement"] == first["settlement"]
def test_legacy_overwrite_skip_binds_existing_success_in_atomic_writer() -> None:
"""覆盖跳过复用既有成功历史并以 succeeded 终态结算。"""
chain, executed = _compat_chain(
lambda task: _result(task, success=False, overwrite_skipped=True)
)
write, staged_payloads = _settlement_writer(status=True)
chain.durable_event_writer.transfer_result.side_effect = write
success_history = SimpleNamespace(id=41, status=True)
history_port = SimpleNamespace(
get_by_src=lambda _src, storage=None: success_history,
)
with patch(
"app.chain.transfer.get_chain_transfer_history_port",
return_value=history_port,
):
returned = _invoke(chain, _fileitem())
assert returned.success is False
assert returned.overwrite_skipped is True
assert executed == ["source-v1"]
assert staged_payloads == []
settlement = chain.durable_event_writer.transfer_result.call_args.kwargs[
"settlement"
]
assert settlement.outcome == "succeeded"
assert settlement.error is None
def test_legacy_overwrite_skip_without_success_history_settles_failed() -> None:
"""兼容入口没有既有成功历史时按失败结算并保留 pending。"""
chain, executed = _compat_chain(
lambda task: _result(task, success=False, overwrite_skipped=True)
)
write, staged_payloads = _settlement_writer(status=False)
chain.durable_event_writer.transfer_result.side_effect = write
history_port = SimpleNamespace(
get_by_src=lambda _src, storage=None: None,
)
with patch(
"app.chain.transfer.get_chain_transfer_history_port",
return_value=history_port,
):
returned = _invoke(chain, _fileitem())
assert returned.success is False
assert returned.overwrite_skipped is True
assert executed == ["source-v1"]
assert len(staged_payloads) == 1
assert staged_payloads[0]["status"] == 0
settlement = chain.durable_event_writer.transfer_result.call_args.kwargs[
"settlement"
]
assert settlement.outcome == "failed"
assert settlement.error == "copy failed"
def test_legacy_same_path_changed_version_still_executes_new_task() -> None:
"""兼容入口不能仅凭同源历史吞掉 fileid/size/mtime 已变化的新版本。"""
chain, executed = _compat_chain(lambda task: _result(task, success=True))
write, _staged_payloads = _settlement_writer(status=True)
chain.durable_event_writer.transfer_result.side_effect = write
first = _invoke(chain, _fileitem(fileid="source-v1", size=1024))
second = _invoke(chain, _fileitem(fileid="source-v2", size=2048))
assert first.success is True
assert second.success is True
assert executed == ["source-v1", "source-v2"]
+201
View File
@@ -0,0 +1,201 @@
"""验证 durable 整理人工复核 API 的鉴权和公开契约。"""
from __future__ import annotations
import inspect
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from app.api.dependencies.auth import get_current_active_manage_user
from app.api.endpoints import transfer as transfer_endpoint
from app.application.transfer_execution import (
TransferExecutionConflictError,
TransferExecutionState,
TransferManualReviewDecision,
TransferManualReviewResult,
TransferStepResult,
)
from app.schemas.transfer import TransferManualReviewRequest
class _ManualReviewCommand:
"""记录人工复核调用并返回可控结果。"""
calls: list[tuple[object, dict]] = []
result: TransferManualReviewResult | None = None
error: Exception | None = None
def __init__(self, repository: object) -> None:
"""保存端点取得的仓储替身。"""
self._repository = repository
def resolve_manual_review(self, **kwargs) -> TransferManualReviewResult:
"""记录参数,并按测试配置返回或抛出结果。"""
self.calls.append((self._repository, kwargs))
if self.error:
raise self.error
assert self.result is not None
return self.result
def _install_command(monkeypatch) -> object:
"""安装不接触数据库的人工复核命令替身。"""
repository = object()
_ManualReviewCommand.calls = []
_ManualReviewCommand.error = None
monkeypatch.setattr(
transfer_endpoint,
"get_chain_transfer_execution_port",
lambda: repository,
)
monkeypatch.setattr(
transfer_endpoint,
"TransferExecutionCommand",
_ManualReviewCommand,
)
return repository
@pytest.mark.parametrize(
"endpoint",
[
transfer_endpoint.list_transfer_manual_reviews,
transfer_endpoint.get_transfer_manual_review,
transfer_endpoint.resolve_transfer_manual_review,
],
)
def test_manual_review_endpoints_require_manage_permission(endpoint) -> None:
"""人工复核发现、详情与判定必须复用全局 manage 权限依赖。"""
dependency = inspect.signature(
endpoint
).parameters["current_user"].default.dependency
assert dependency is get_current_active_manage_user
@pytest.mark.parametrize(
("payload", "message"),
[
(
{"operation_id": "op-1", "decision": "failed", "reason": "确认失败"},
"Input should be 'not_applied' or 'applied'",
),
(
{"operation_id": "op-1", "decision": "applied", "reason": "已完成"},
"必须提供 result_payload",
),
(
{"operation_id": "op-1", "decision": "not_applied", "reason": " "},
"String should have at least 1 character",
),
],
)
def test_manual_review_request_rejects_unsafe_decisions(
payload: dict,
message: str,
) -> None:
"""公开 schema 不允许 FAILED,且 APPLIED 必须携带结果证据。"""
with pytest.raises(ValidationError, match=message):
TransferManualReviewRequest.model_validate(payload)
def test_manual_review_applied_wraps_result_and_hides_internal_state(
monkeypatch,
) -> None:
"""APPLIED 应包装版本化结果,响应不得泄漏 lease 或 attempt。"""
repository = _install_command(monkeypatch)
_ManualReviewCommand.result = SimpleNamespace(
task_id="task-1",
operation_id="op-1",
decision=TransferManualReviewDecision.APPLIED,
state=TransferExecutionState.RETRY_WAIT,
review_revision=4,
)
response = transfer_endpoint.resolve_transfer_manual_review(
task_id="task-1",
review=TransferManualReviewRequest(
operation_id="op-1",
decision="applied",
reason="目标摘要匹配",
result_payload={"dest_exists": True, "hash_match": True},
),
current_user=SimpleNamespace(name=" admin ", username="other", id=7),
)
assert _ManualReviewCommand.calls == [
(
repository,
{
"task_id": "task-1",
"operation_id": "op-1",
"decision": TransferManualReviewDecision.APPLIED,
"actor": "admin",
"reason": "目标摘要匹配",
"result": TransferStepResult(
payload={"dest_exists": True, "hash_match": True}
),
},
)
]
assert response.data is not None
assert response.data.model_dump() == {
"task_id": "task-1",
"operation_id": "op-1",
"decision": "applied",
"state": "retry_wait",
"review_revision": 4,
}
assert "lease" not in response.model_dump_json()
assert "attempt" not in response.model_dump_json()
def test_manual_review_not_applied_uses_username_fallback(monkeypatch) -> None:
"""名称为空时应稳定回退到 username,并允许安全重新调度。"""
_install_command(monkeypatch)
_ManualReviewCommand.result = SimpleNamespace(
task_id="task-2",
operation_id="op-2",
decision=TransferManualReviewDecision.NOT_APPLIED,
state=TransferExecutionState.RETRY_WAIT,
review_revision=2,
)
response = transfer_endpoint.resolve_transfer_manual_review(
task_id="task-2",
review=TransferManualReviewRequest(
operation_id="op-2",
decision="not_applied",
reason="确认源文件仍存在",
),
current_user=SimpleNamespace(name="", username="reviewer", id=8),
)
assert _ManualReviewCommand.calls[0][1]["actor"] == "reviewer"
assert _ManualReviewCommand.calls[0][1]["result"] is None
assert response.data is not None
assert response.data.state == "retry_wait"
def test_manual_review_conflict_returns_http_409(monkeypatch) -> None:
"""重复或过期人工判定必须返回资源冲突而非伪成功。"""
_install_command(monkeypatch)
_ManualReviewCommand.error = TransferExecutionConflictError("步骤已被判定")
with pytest.raises(HTTPException) as error:
transfer_endpoint.resolve_transfer_manual_review(
task_id="task-1",
review=TransferManualReviewRequest(
operation_id="op-1",
decision="not_applied",
reason="重复判定",
),
current_user=SimpleNamespace(name=None, username=None, id=11),
)
assert error.value.status_code == 409
assert error.value.detail == "步骤已被判定"
assert _ManualReviewCommand.calls[0][1]["actor"] == "11"
@@ -0,0 +1,250 @@
"""验证 durable 人工复核从未知证据到调度恢复的可发现闭环。"""
from datetime import datetime, timezone
from types import SimpleNamespace
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.api.endpoints import transfer as transfer_endpoint
from app.application.transfer_execution import (
TransferExecutionCommand,
TransferExecutionState,
TransferManualReviewQuery,
TransferStepIntent,
TransferStepResult,
)
from app.db.adapters.transfer_execution import (
TransactionalTransferExecutionRepository,
)
from app.db.base import Base
from app.db.models.transferexecutionstep import TransferExecutionStep
from app.db.models.transferhistory import TransferHistory
from app.db.models.transferpending import TransferPending
from app.schemas.transfer import TransferManualReviewRequest
@pytest.fixture
def review_store():
"""构造隔离的 durable 整理人工复核数据库。"""
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(
engine,
tables=[
TransferPending.__table__,
TransferHistory.__table__,
TransferExecutionStep.__table__,
],
)
factory = sessionmaker(bind=engine, expire_on_commit=False)
try:
yield factory
finally:
engine.dispose()
def _repository(factory) -> TransactionalTransferExecutionRepository:
"""构造使用确定时钟的整理执行仓储。"""
return TransactionalTransferExecutionRepository(
factory,
local_clock=lambda: datetime(2026, 8, 27, 9, 30, 0),
lease_clock=lambda: datetime(2026, 8, 27, 1, 30, 0, tzinfo=timezone.utc),
)
def _put_in_manual_review(factory, *, task_id: str) -> tuple[
TransactionalTransferExecutionRepository,
str,
]:
"""建立一个外部结果 UNKNOWN 且已释放租约的人工复核任务。"""
with factory() as session:
session.add(TransferPending(
task_id=task_id,
storage="local",
src_path=f"/downloads/{task_id}.mkv",
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}",
checkpoint_version=1,
checkpoint_payload={"schema_version": 1, "task_id": task_id},
planned_at="2026-08-27 09:00:00",
lease_owner="worker-secret",
lease_token=f"lease-{task_id}",
lease_expires_at="2099-01-01 00:00:00.000000",
heartbeat_at="2026-08-27 01:00:00.000000",
attempt_count=1,
execution_state="not_started",
retry_generation=0,
retry_count=0,
settlement_revision=0,
))
session.commit()
repository = _repository(factory)
command = TransferExecutionCommand(
repository,
attempt_token_factory=lambda: f"attempt-{task_id}",
)
intent = TransferStepIntent.create(
task_id=task_id,
checkpoint_fingerprint=f"checkpoint-{task_id}",
ordinal=0,
phase="transfer",
kind="materialize_target",
payload={
"source": f"/downloads/{task_id}.mkv",
"target": f"/library/{task_id}.mkv",
},
)
prepared = command.prepare(
task_id=task_id,
lease_token=f"lease-{task_id}",
intent=intent,
)
started = command.begin(
task_id=task_id,
lease_token=f"lease-{task_id}",
operation_id=prepared.operation_id,
)
snapshot = command.manual_review(
task_id=task_id,
lease_token=f"lease-{task_id}",
step=started,
error="external result unknown",
evidence=TransferStepResult(payload={
"observation": "unknown",
"target_exists": True,
}),
)
assert snapshot.state is TransferExecutionState.MANUAL_REVIEW
return repository, started.operation_id
def test_manual_review_list_is_database_paginated(review_store) -> None:
"""待复核任务应按稳定顺序在数据库分页,不混入普通任务。"""
repository, _ = _put_in_manual_review(review_store, task_id="task-1")
_put_in_manual_review(review_store, task_id="task-2")
first = repository.list_manual_reviews(
state=TransferExecutionState.MANUAL_REVIEW,
page=1,
page_size=1,
)
second = repository.list_manual_reviews(
state=TransferExecutionState.MANUAL_REVIEW,
page=2,
page_size=1,
)
assert first.total == second.total == 2
assert len(first.items) == len(second.items) == 1
assert first.items[0].task_id != second.items[0].task_id
with pytest.raises(ValueError, match="不支持状态"):
TransferManualReviewQuery(repository).list(
state=TransferExecutionState.RUNNING,
)
@pytest.mark.parametrize(
("decision", "result_payload", "expected_step_state"),
[
("applied", {"target_exists": True, "hash_match": True}, "succeeded"),
("not_applied", None, "failed"),
],
)
def test_unknown_manual_review_is_discoverable_and_resumes_via_api(
monkeypatch,
review_store,
decision: str,
result_payload: dict[str, bool] | None,
expected_step_state: str,
) -> None:
"""UNKNOWN 任务应可发现,人工判定后进入唯一 retry_wait 恢复路径。"""
repository, operation_id = _put_in_manual_review(
review_store,
task_id=f"task-{decision}",
)
monkeypatch.setattr(
transfer_endpoint,
"get_chain_transfer_execution_port",
lambda: repository,
)
listed = transfer_endpoint.list_transfer_manual_reviews(
state_filter="manual_review",
page=1,
page_size=10,
current_user=object(),
)
assert listed.data is not None
assert listed.data.total == 1
discovered = listed.data.items[0]
assert discovered.task_id == f"task-{decision}"
assert discovered.source.model_dump() == {
"storage": "local",
"path": f"/downloads/task-{decision}.mkv",
}
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.evidence == {
"observation": "unknown",
"target_exists": True,
}
assert discovered.step.error == "external result unknown"
assert discovered.review_revision == 0
public_json = listed.model_dump_json()
assert "worker-secret" not in public_json
assert "lease-" not in public_json
assert "attempt-" not in public_json
detail = transfer_endpoint.get_transfer_manual_review(
task_id=f"task-{decision}",
current_user=object(),
)
assert detail.data == discovered
resolved = transfer_endpoint.resolve_transfer_manual_review(
task_id=f"task-{decision}",
review=TransferManualReviewRequest(
operation_id=operation_id,
decision=decision,
reason=f"reviewed-{decision}",
result_payload=result_payload,
),
current_user=SimpleNamespace(name="admin"),
)
assert resolved.data is not None
assert resolved.data.state == "retry_wait"
assert resolved.data.review_revision == 1
waiting = transfer_endpoint.get_transfer_manual_review(
task_id=f"task-{decision}",
current_user=object(),
)
assert waiting.data is not None
assert waiting.data.state == "retry_wait"
assert waiting.data.review_revision == 1
retry_page = transfer_endpoint.list_transfer_manual_reviews(
state_filter="retry_wait",
page=1,
page_size=10,
current_user=object(),
)
assert retry_page.data is not None
assert [item.task_id for item in retry_page.data.items] == [f"task-{decision}"]
snapshot = repository.get_snapshot(task_id=f"task-{decision}")
assert snapshot is not None
assert snapshot.state is TransferExecutionState.RETRY_WAIT
assert snapshot.retry_due_at is not None
assert snapshot.steps[0].state.value == expected_step_state
with review_store() as session:
pending = session.scalar(select(TransferPending))
assert pending is not None
assert pending.lease_owner is None
assert pending.lease_token is None
assert pending.retry_generation == 1
+117
View File
@@ -11,6 +11,10 @@ from unittest.mock import MagicMock, patch
import pytest
from app.application.transfer_execution import (
TransferExecutionCheckpoint,
TransferSettlementResult,
)
from app.chain.transfer import TransferChain
from app.schemas.transfer import TransferInfo
from app.schemas.types import EventType
@@ -121,6 +125,60 @@ def test_overwrite_declined_false_when_query_raises():
assert result is False
def test_overwrite_declined_uses_successful_durable_settlement():
"""覆盖拒绝保留旧成功历史时,durable 终态必须按成功结算。"""
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="目标已存在,按覆盖策略跳过覆盖",
)
settlement = TransferChain._TransferChain__build_transfer_result_settlement(
task,
transferinfo,
overwrite_declined=True,
)
assert settlement is not None
assert settlement.outcome == "succeeded"
assert settlement.error is None
def test_overwrite_skip_without_success_history_uses_failed_settlement():
"""未核实既有成功历史时,覆盖跳过标志不能伪造成功终态。"""
task = make_task(1)
task.bind_admission_task_id("task-overwrite-missing-history")
task.bind_execution_lease(owner_id="worker", lease_token="lease")
task.bind_execution_checkpoint(TransferExecutionCheckpoint.create(
payload={"outcome": "failed"},
operation_ids=(),
skip_reason="overwrite_without_history",
))
transferinfo = TransferInfo(
success=False,
overwrite_skipped=True,
message="目标已存在,按覆盖策略跳过覆盖",
)
settlement = TransferChain._TransferChain__build_transfer_result_settlement(
task,
transferinfo,
overwrite_declined=False,
)
assert settlement is not None
assert settlement.outcome == "failed"
assert settlement.error == transferinfo.message
# ---------------------------------------------------------------------------
# __default_callback 失败分支
# ---------------------------------------------------------------------------
@@ -230,6 +288,65 @@ def test_default_callback_keeps_original_failure_semantics_without_success_histo
assert len(transfer_failed_events) == 1
def test_durable_callback_settles_overwrite_skip_without_history_as_failed():
"""durable 回调没有既有成功历史时必须原子提交失败历史和失败终态。"""
chain = make_transfer_chain()
chain.eventmanager = MagicMock()
chain.post_message = MagicMock()
chain.durable_event_writer = MagicMock()
task = _make_failed_task()
task.bind_admission_task_id("task-overwrite-no-history")
task.bind_execution_lease(owner_id="worker", lease_token="lease")
task.bind_execution_checkpoint(TransferExecutionCheckpoint.create(
payload={"outcome": "failed"},
operation_ids=(),
skip_reason="overwrite_without_history",
))
add_fail_calls = []
transfer_history_oper = make_history_oper(history=None)
transferinfo = TransferInfo(
success=False,
fileitem=task.fileitem,
message="目标已存在,按覆盖策略跳过覆盖",
transfer_type="copy",
overwrite_skipped=True,
need_notify=False,
)
def durable_transfer_result(**kwargs):
"""执行失败历史暂存并返回 task-aware 结算回执。"""
history = kwargs["stage_history"](SimpleNamespace())
assert kwargs["settlement"].outcome == "failed"
return TransferSettlementResult(
history_id=history.id,
settlement_revision=1,
pending_deleted=False,
)
chain.durable_event_writer.transfer_result.side_effect = durable_transfer_result
with patch(
"app.chain.transfer.get_chain_transfer_history_port",
return_value=transfer_history_oper,
), patch(
"app.chain.transfer.add_transfer_fail",
make_fail_recorder(add_fail_calls),
), patch(
"app.runtime.config.settings.AI_AGENT_ENABLE", False
), patch(
"app.runtime.config.settings.AI_AGENT_RETRY_TRANSFER", False
):
state, errmsg = chain._TransferChain__default_callback(task, transferinfo)
assert state is False
assert errmsg == transferinfo.message
assert len(add_fail_calls) == 1
settlement = chain.durable_event_writer.transfer_result.call_args.kwargs[
"settlement"
]
assert settlement.outcome == "failed"
assert settlement.error == transferinfo.message
def test_default_callback_delegates_primary_failure_to_durable_writer():
"""正式上下文存在 writer 时,主要媒体失败历史和事件必须走同一事务端口。"""
chain = make_transfer_chain()
+284 -2
View File
@@ -1,14 +1,17 @@
"""旧待整理 Oper 的精确插件兼容与租约 fencing 测试。"""
import importlib
import inspect
from collections.abc import Callable
from typing import Any
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy import create_engine, select, update
from sqlalchemy.orm import Session, sessionmaker
from app.db import base as db_base
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 as CanonicalTransferPendingOper
from app.runtime.compat.manifest import MODULE_ALIASES
@@ -19,6 +22,8 @@ def legacy_session_factory(tmp_path, monkeypatch):
"""为无 Session 兼容 Oper 提供独占事务,并在提交后保留返回快照。"""
engine = create_engine(f"sqlite:///{tmp_path / 'legacy-transferpending.db'}")
TransferPending.__table__.create(engine)
TransferExecutionStep.__table__.create(engine)
TransferHistory.__table__.create(engine)
factory = sessionmaker(bind=engine)
def run_transaction(operation: Callable[[Session], Any]) -> Any:
@@ -49,6 +54,8 @@ def test_legacy_import_targets_private_sdk_facade() -> None:
assert legacy is importlib.import_module(alias.target)
assert legacy.__all__ == ["TransferPendingOper"]
assert not hasattr(legacy, "TransferPending")
assert not hasattr(legacy, "TransferExecutionStep")
assert not hasattr(legacy, "TransferExecutionState")
assert legacy.TransferPendingOper is not CanonicalTransferPendingOper
for internal_method in (
"stage_admit",
@@ -59,6 +66,55 @@ def test_legacy_import_targets_private_sdk_facade() -> None:
assert not hasattr(legacy.TransferPendingOper, internal_method)
def test_legacy_oper_preserves_exact_public_method_abi() -> None:
"""兼容门面只公开历史八方法,且位置参数与关键字参数边界保持不变。"""
legacy = importlib.import_module("app.db.transferpending_oper")
oper_type = legacy.TransferPendingOper
public_methods = {
name
for name, value in oper_type.__dict__.items()
if not name.startswith("_") and callable(value)
}
assert public_methods == {
"register",
"list_by_state",
"list_by_states",
"get_by_identity",
"get_by_task_id",
"discard",
"list_all",
"clear",
}
assert str(inspect.signature(oper_type.register)) == (
"(self, storage: str, src_path: str) -> "
"app.db.models.transferpending.TransferPending | None"
)
assert str(inspect.signature(oper_type.list_by_state)) == (
"(self, *, state: str, limit: int | None = 5000) -> "
"List[app.db.models.transferpending.TransferPending]"
)
assert str(inspect.signature(oper_type.list_by_states)) == (
"(self, *, states: tuple[str, ...], limit: int | None = 5000) -> "
"List[app.db.models.transferpending.TransferPending]"
)
assert str(inspect.signature(oper_type.get_by_identity)) == (
"(self, *, storage: str, src_path: str) -> "
"app.db.models.transferpending.TransferPending | None"
)
assert str(inspect.signature(oper_type.get_by_task_id)) == (
"(self, *, task_id: str) -> "
"app.db.models.transferpending.TransferPending | None"
)
assert str(inspect.signature(oper_type.discard)) == (
"(self, storage: str, src_path: str) -> int"
)
assert str(inspect.signature(oper_type.list_all)) == (
"(self, limit: int | None = 5000) -> List[Tuple[str, str]]"
)
assert str(inspect.signature(oper_type.clear)) == "(self) -> int"
def test_legacy_no_session_queries_preserve_historical_shapes(
legacy_session_factory,
) -> None:
@@ -96,10 +152,35 @@ def test_legacy_no_session_queries_preserve_historical_shapes(
assert oper.list_by_states(states=()) == []
def test_legacy_register_allows_new_task_for_previously_settled_source(
legacy_session_factory,
) -> None:
"""旧插件登记入口允许同源文件形成新的合法任务世代。"""
with legacy_session_factory() as session:
session.add(TransferHistory(
transfer_task_id="settled-task",
transfer_settlement_revision=1,
src="/downloads/settled.mkv",
src_storage="local",
status=True,
))
session.commit()
legacy = importlib.import_module("app.db.transferpending_oper")
pending = legacy.TransferPendingOper().register(
"local",
"/downloads/settled.mkv",
)
assert pending is not None
assert pending.task_id != "settled-task"
with legacy_session_factory() as session:
assert session.execute(select(TransferPending)).scalar_one().task_id == pending.task_id
def test_legacy_mutations_never_delete_claimed_rows(
legacy_session_factory,
) -> None:
"""旧 discard/clear 只处理未 claim 行,有效或过期 token 均受保护。"""
"""旧 discard/clear 只处理新鲜行,有效或过期 token 均受保护。"""
legacy = importlib.import_module("app.db.transferpending_oper")
oper = legacy.TransferPendingOper()
active = oper.register("local", "/downloads/active.mkv")
@@ -156,3 +237,204 @@ def test_legacy_mutations_never_delete_claimed_rows(
(active.task_id, "active-token"),
(expired.task_id, "expired-token"),
]
def test_legacy_mutations_protect_every_execution_and_terminal_state(
legacy_session_factory,
) -> None:
"""无租约的运行、等待、结算、失败和人工判定任务仍归状态机所有。"""
legacy = importlib.import_module("app.db.transferpending_oper")
oper = legacy.TransferPendingOper()
protected = {
state: oper.register("local", f"/downloads/{state}.mkv")
for state in (
"running",
"retry_wait",
"settling",
"failed",
"manual_review",
)
}
expired = oper.register("local", "/downloads/expired-running.mkv")
safe_discard = oper.register("local", "/downloads/safe-discard.mkv")
safe_clear = oper.register("local", "/downloads/safe-clear.mkv")
assert all(protected.values())
assert expired is not None
assert safe_discard is not None
assert safe_clear is not None
with legacy_session_factory() as session:
for state, row in protected.items():
assert row is not None
session.execute(
update(TransferPending)
.where(TransferPending.task_id == row.task_id)
.values(execution_state=state)
)
session.execute(
update(TransferPending)
.where(TransferPending.task_id == expired.task_id)
.values(
execution_state="running",
lease_owner="expired-worker",
lease_token="expired-token",
lease_expires_at="2026-08-27 09:59:00.000000",
heartbeat_at="2026-08-27 09:58:00.000000",
attempt_count=1,
)
)
session.commit()
assert oper.discard("local", safe_discard.src_path) == 1
for state, row in protected.items():
assert row is not None
assert oper.discard("local", row.src_path) == 0, state
assert oper.discard("local", expired.src_path) == 0
assert oper.clear() == 1
assert set(oper.list_all()) == {
("local", row.src_path)
for row in (*protected.values(), expired)
if row is not None
}
def test_legacy_mutations_protect_execution_evidence_even_if_not_started(
legacy_session_factory,
) -> None:
"""状态字段异常回退时,claim、步骤、重试和结算证据仍阻止旧接口删除。"""
legacy = importlib.import_module("app.db.transferpending_oper")
oper = legacy.TransferPendingOper()
evidence_rows = {
name: oper.register("local", f"/downloads/evidence-{name}.mkv")
for name in (
"claim",
"checkpoint",
"retry",
"settlement",
"error",
"step",
"history",
)
}
safe = oper.register("local", "/downloads/evidence-free.mkv")
assert all(evidence_rows.values())
assert safe is not None
with legacy_session_factory() as session:
rows = {
name: row
for name, row in evidence_rows.items()
if row is not None
}
session.execute(
update(TransferPending)
.where(TransferPending.task_id == rows["claim"].task_id)
.values(attempt_count=1)
)
session.execute(
update(TransferPending)
.where(TransferPending.task_id == rows["checkpoint"].task_id)
.values(
execution_version=1,
execution_payload={"operation_ids": ["op-checkpoint"]},
execution_fingerprint="f" * 64,
)
)
session.execute(
update(TransferPending)
.where(TransferPending.task_id == rows["retry"].task_id)
.values(
retry_generation=1,
retry_count=1,
retry_due_at="2026-08-27 12:00:00.000000",
)
)
session.execute(
update(TransferPending)
.where(TransferPending.task_id == rows["settlement"].task_id)
.values(settlement_revision=1, terminal_history_id=42)
)
session.execute(
update(TransferPending)
.where(TransferPending.task_id == rows["error"].task_id)
.values(last_error="execution outcome unknown")
)
session.add(
TransferExecutionStep(
task_id=rows["step"].task_id,
operation_id="op-step-evidence",
checkpoint_fingerprint="c" * 64,
ordinal=0,
phase="materialize",
kind="copy",
state="prepared",
attempt_count=0,
intent_version=1,
intent_payload={"src": rows["step"].src_path},
prepared_at="2026-08-27 10:00:00.000000",
updated_at="2026-08-27 10:00:00.000000",
)
)
session.add(
TransferHistory(
transfer_task_id=rows["history"].task_id,
transfer_settlement_revision=1,
src=rows["history"].src_path,
src_storage="local",
status=True,
)
)
session.commit()
for name, row in evidence_rows.items():
assert row is not None
assert oper.discard("local", row.src_path) == 0, name
assert oper.clear() == 1
assert set(oper.list_all()) == {
("local", row.src_path)
for row in evidence_rows.values()
if row is not None
}
def test_legacy_register_returns_existing_terminal_or_manual_review_task(
legacy_session_factory,
) -> None:
"""重复登记不得绕过失败或人工判定任务创建平行执行身份。"""
legacy = importlib.import_module("app.db.transferpending_oper")
oper = legacy.TransferPendingOper()
failed = oper.register("local", "/downloads/retry-failed.mkv")
manual = oper.register("local", "/downloads/retry-manual.mkv")
assert failed is not None
assert manual is not None
with legacy_session_factory() as session:
session.execute(
update(TransferPending)
.where(TransferPending.task_id == failed.task_id)
.values(
execution_state="failed",
settlement_revision=1,
terminal_history_id=81,
)
)
session.execute(
update(TransferPending)
.where(TransferPending.task_id == manual.task_id)
.values(
execution_state="manual_review",
last_error="provider outcome unknown",
)
)
session.commit()
repeated_failed = oper.register("local", failed.src_path)
repeated_manual = oper.register("local", manual.src_path)
assert repeated_failed is not None
assert repeated_failed.task_id == failed.task_id
assert repeated_failed.execution_state == "failed"
assert repeated_failed.terminal_history_id == 81
assert repeated_manual is not None
assert repeated_manual.task_id == manual.task_id
assert repeated_manual.execution_state == "manual_review"
assert repeated_manual.last_error == "provider outcome unknown"
+49 -11
View File
@@ -14,8 +14,13 @@ from sqlalchemy.orm import sessionmaker
from app.application import transfer as transfer_application
from app.application.transfer import TransferTask
from app.application.transfer_execution import (
TransferExecutionCheckpoint,
TransferSettlementResult,
)
from app.chain.transfer import TransferChain
from app.db.adapters.transfer import TransactionalTransferAdmissionRepository
from app.db.models.transferhistory import TransferHistory
from app.db.models.transferpending import TransferPending
from app.domain.context import MediaInfo
from app.domain.meta.metabase import MetaBase
@@ -831,7 +836,7 @@ def test_provider_pending_crash_replay_executes_snapshot_without_host_planning()
def test_legacy_transfer_command_uses_durable_pipeline_and_settles_pending():
"""旧同步调用必须经过 admission、checkpoint、执行和终态注销"""
"""旧同步调用必须经过 admission、checkpoint、执行和原子终态结算"""
calls = []
repository = Mock()
repository.admit.side_effect = (
@@ -845,18 +850,47 @@ def test_legacy_transfer_command_uses_durable_pipeline_and_settles_pending():
return SimpleNamespace(checkpoint=kwargs["checkpoint"])
repository.checkpoint_plan.side_effect = checkpoint_plan
repository.discard_claimed.side_effect = (
lambda **_kwargs: calls.append("discard") or 1
)
result = TransferInfo(
success=True,
fileitem=_task().fileitem,
transfer_type="copy",
)
chain = _chain(repository=repository, checkpoint=_checkpoint(), result=result)
chain.execute_transfer_plan.side_effect = (
lambda *_args, **_kwargs: calls.append("execute") or result
execution_checkpoint = TransferExecutionCheckpoint.create(
payload={
"outcome": "succeeded",
"transferinfo": result.model_dump(mode="json"),
},
operation_ids=("operation-legacy-command",),
)
step_runner = Mock()
step_runner.checkpoint.return_value = execution_checkpoint
chain._TransferChain__build_durable_step_runner = Mock(return_value=step_runner)
chain.run_module = Mock(
side_effect=lambda *_args, **_kwargs: calls.append("execute") or result
)
chain.durable_event_writer = Mock()
def settle_result(**kwargs):
"""执行历史暂存并模拟 writer 返回 task-aware 结算投影。"""
staging = Mock()
staging.add_force.return_value = SimpleNamespace(
id=31,
status=True,
src=result.fileitem.path,
src_storage=result.fileitem.storage,
src_fileitem=result.fileitem.model_dump(mode="json"),
)
history = kwargs["stage_history"](staging)
assert history.status is True
calls.append("settle")
return TransferSettlementResult(
history_id=history.id,
settlement_revision=1,
pending_deleted=True,
)
chain.durable_event_writer.transfer_result.side_effect = settle_result
meta = MetaBase("Movie.2026.mkv")
mediainfo = MediaInfo()
@@ -870,11 +904,13 @@ def test_legacy_transfer_command_uses_durable_pipeline_and_settles_pending():
)
assert returned is result
assert calls == ["admit", "checkpoint", "execute", "discard"]
repository.discard_claimed.assert_called_once_with(
task_id="task-legacy-command",
lease_token="lease-task-legacy-command",
)
assert calls == ["admit", "checkpoint", "execute", "settle"]
repository.discard_claimed.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
assert writer_call["settlement"].task_id == "task-legacy-command"
assert writer_call["settlement"].outcome == "succeeded"
def test_cleanup_destination_is_idempotent_and_uses_storage_safety_policy():
@@ -932,6 +968,7 @@ def test_planning_payload_round_trip_is_self_contained_and_stable():
def test_repository_rejects_checkpoint_with_mismatched_planning_fingerprint(tmp_path):
"""checkpoint 内嵌输入与 accepted 指纹不一致时必须拒绝状态跃迁。"""
engine = create_engine(f"sqlite:///{tmp_path / 'fingerprint.db'}")
TransferHistory.__table__.create(engine)
TransferPending.__table__.create(engine)
repository = TransactionalTransferAdmissionRepository(sessionmaker(bind=engine))
accepted_input = _planning_input(target_path="/library/A")
@@ -975,6 +1012,7 @@ def test_repository_rejects_checkpoint_with_mismatched_planning_fingerprint(tmp_
def test_repository_round_trips_accepted_and_planned_recovery_states(tmp_path):
"""仓储必须同时恢复 accepted 输入和 planned 自包含 checkpoint。"""
engine = create_engine(f"sqlite:///{tmp_path / 'recoverable.db'}")
TransferHistory.__table__.create(engine)
TransferPending.__table__.create(engine)
repository = TransactionalTransferAdmissionRepository(sessionmaker(bind=engine))
planning_input = _planning_input()
+28 -2
View File
@@ -26,6 +26,24 @@ except ModuleNotFoundError:
PLANNING_MIGRATION = "database.versions.c2f8a4d6e1b3_3_0_14"
LEASE_MIGRATION = "database.versions.d3a9e5f7b2c4_3_0_15"
POST_LEASE_EXECUTION_COLUMNS = {
"execution_state",
"execution_version",
"execution_payload",
"execution_fingerprint",
"retry_generation",
"retry_count",
"retry_due_at",
"retry_requested_by",
"retry_reason",
"settlement_revision",
"terminal_history_id",
"manual_review_revision",
"reviewed_at",
"reviewed_by",
"review_reason",
"review_decision",
}
def _bind_migration(monkeypatch, connection, module_name=PLANNING_MIGRATION):
@@ -104,7 +122,11 @@ def _assert_upgrade_downgrade_reupgrade(connection, monkeypatch) -> None:
assert {
column["name"]
for column in inspector.get_columns("transferpending")
} == {column.name for column in TransferPending.__table__.columns}
} == {
column.name
for column in TransferPending.__table__.columns
if column.name not in POST_LEASE_EXECUTION_COLUMNS
}
upgraded = _planning_row(connection)
planning_payload = upgraded["planning_input"]
if isinstance(planning_payload, str):
@@ -168,7 +190,11 @@ def _assert_upgrade_downgrade_reupgrade(connection, monkeypatch) -> None:
assert {
column["name"]
for column in sa.inspect(connection).get_columns("transferpending")
} == {column.name for column in TransferPending.__table__.columns}
} == {
column.name
for column in TransferPending.__table__.columns
if column.name not in POST_LEASE_EXECUTION_COLUMNS
}
def test_transfer_planning_upgrade_downgrade_reupgrade(monkeypatch) -> None:
@@ -20,6 +20,7 @@ from app.application.transfer import (
TransferProviderReference,
)
from app.db.adapters.transfer import TransactionalTransferAdmissionRepository
from app.db.models.transferhistory import TransferHistory
from app.db.models.transferpending import TransferPending
@@ -27,6 +28,7 @@ from app.db.models.transferpending import TransferPending
def repository(tmp_path):
"""创建只服务单个测试的 SQLite 整理计划仓储。"""
engine = create_engine(f"sqlite:///{tmp_path / 'transfer-planning.db'}")
TransferHistory.__table__.create(engine)
TransferPending.__table__.create(engine)
return TransactionalTransferAdmissionRepository(sessionmaker(bind=engine))
@@ -347,6 +349,30 @@ def test_admit_reuses_identical_input_and_rejects_conflict(repository) -> None:
)
def test_admit_allows_new_generation_when_history_has_previous_task(repository) -> None:
"""历史保留上一代任务投影时,同源新事实仍可形成新任务世代。"""
with repository._session_factory() as session: # noqa: SLF001
session.add(TransferHistory(
transfer_task_id="settled-task",
transfer_settlement_revision=1,
src="/downloads/Movie.2026.mkv",
src_storage="local",
status=True,
))
session.commit()
admission = repository.admit(
storage="local",
src_path="/downloads/Movie.2026.mkv",
planning_input=_planning_input(),
)
with repository._session_factory() as session: # noqa: SLF001
pending = session.execute(select(TransferPending)).scalar_one()
assert admission.task_id == pending.task_id
assert admission.task_id != "settled-task"
def test_checkpoint_atomically_advances_and_is_idempotent(repository) -> None:
"""完整计划和 planned 状态应同事务提交且允许相同检查点重试。"""
planning_input = _planning_input()
@@ -556,6 +582,7 @@ def test_projection_rejects_input_version_and_fingerprint_corruption(tmp_path) -
"""列版本、JSON 和指纹任一不一致时都不得返回伪冻结 DTO。"""
engine = create_engine(f"sqlite:///{tmp_path / 'input-corruption.db'}")
factory = sessionmaker(bind=engine)
TransferHistory.__table__.create(engine)
TransferPending.__table__.create(engine)
repository = TransactionalTransferAdmissionRepository(factory)
planning_input = _planning_input()
@@ -601,6 +628,7 @@ def test_projection_rejects_checkpoint_version_corruption(tmp_path) -> None:
"""planned 行的列版本与自包含 checkpoint JSON 必须严格一致。"""
engine = create_engine(f"sqlite:///{tmp_path / 'checkpoint-corruption.db'}")
factory = sessionmaker(bind=engine)
TransferHistory.__table__.create(engine)
TransferPending.__table__.create(engine)
repository = TransactionalTransferAdmissionRepository(factory)
planning_input = _planning_input()
+2
View File
@@ -7,6 +7,7 @@ from sqlalchemy.orm import sessionmaker
from app.application.transfer import TransferAdmission, TransferQueueService
from app.db.adapters.transfer import TransactionalTransferAdmissionRepository
from app.db.models.transferhistory import TransferHistory
from app.db.models.transferpending import TransferPending
from app.schemas.file import FileItem
from tests.test_transfer_job_manager import make_task, make_transfer_chain
@@ -117,6 +118,7 @@ def test_transfer_queue_service_cleans_up_when_batch_registration_fails():
def test_transfer_queue_service_commits_admission_before_failed_enqueue(tmp_path):
"""真实仓储已提交后即使内存入队失败,任务也必须带原因留待恢复。"""
engine = create_engine(f"sqlite:///{tmp_path / 'durable-admission.db'}")
TransferHistory.__table__.create(engine)
TransferPending.__table__.create(engine)
factory = sessionmaker(bind=engine)
repository = TransactionalTransferAdmissionRepository(factory)
+456
View File
@@ -0,0 +1,456 @@
"""验证 settling 终态在崩溃后只重放持久结算。"""
import threading
import time
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.application.transfer import (
TransferAdmission,
TransferPlanCheckpoint,
TransferPlanningInput,
TransferTask,
)
from app.application.transfer_execution import (
TransferExecutionCheckpoint,
TransferExecutionSnapshot,
TransferExecutionState,
)
from app.chain.transfer import TransferChain
from app.db.adapters.transfer import TransactionalTransferAdmissionRepository
from app.db.models.transferhistory import TransferHistory
from app.db.models.transferpending import TransferPending
from app.schemas.file import FileItem
from app.schemas.transfer import TransferInfo
def _planning_input(path: str) -> TransferPlanningInput:
"""构造保留源文件身份的最小持久规划输入。"""
return TransferPlanningInput(
source_fileitem={
"storage": "local",
"path": path,
"type": "file",
"name": Path(path).name,
"basename": Path(path).stem,
"extension": Path(path).suffix.lstrip("."),
"size": 1024,
},
meta=None,
mediainfo=None,
requested_transfer_type="move",
)
def _plan_checkpoint(
planning_input: TransferPlanningInput,
) -> TransferPlanCheckpoint:
"""构造外部步骤已经结束后可直接结算的冻结计划。"""
return TransferPlanCheckpoint(
planning_input=planning_input,
target_storage="local",
root_target_path="/library",
final_target_path="/library/Movie.mkv",
resolved_transfer_type="move",
items=(),
need_notify=False,
skip_reason="测试已完成外部步骤",
)
def _transfer_result(path: str, *, success: bool) -> TransferInfo:
"""构造可完整写入 execution checkpoint 的整理结果。"""
fileitem = FileItem(
storage="local",
path=path,
type="file",
name=Path(path).name,
basename=Path(path).stem,
extension=Path(path).suffix.lstrip("."),
size=1024,
)
return TransferInfo(
success=success,
fileitem=fileitem,
target_item=(
FileItem(
storage="local",
path="/library/Movie.mkv",
type="file",
name="Movie.mkv",
)
if success
else None
),
transfer_type="move",
fail_list=[] if success else [path],
message="整理完成" if success else "整理失败",
need_notify=False,
)
def _execution_checkpoint(
path: str,
*,
success: bool,
include_transferinfo: bool = True,
) -> TransferExecutionCheckpoint:
"""构造成功或确定失败的聚合执行检查点。"""
payload = {
"outcome": "succeeded" if success else "failed",
"error": None if success else "整理失败",
}
if include_transferinfo:
payload["transferinfo"] = _transfer_result(
path,
success=success,
).model_dump(mode="json")
return TransferExecutionCheckpoint.create(
payload=payload,
operation_ids=("operation-1",),
)
def _add_settling_pending(
factory,
*,
path: str,
lease_state: str,
) -> tuple[TransferPlanCheckpoint, TransferExecutionCheckpoint]:
"""写入带完整计划和执行检查点的 settling 任务。"""
planning_input = _planning_input(path)
plan_checkpoint = _plan_checkpoint(planning_input)
execution_checkpoint = _execution_checkpoint(path, success=True)
lease_values = {
"lease_owner": None,
"lease_token": None,
"lease_expires_at": None,
"heartbeat_at": None,
"attempt_count": 0,
}
if lease_state == "expired":
lease_values.update({
"lease_owner": "old-owner",
"lease_token": "old-token",
"lease_expires_at": "2000-01-01 00:00:00.000000",
"heartbeat_at": "1999-12-31 23:59:00.000000",
"attempt_count": 1,
})
with factory() as session:
session.add(TransferPending(
task_id="settling-task",
storage="local",
src_path=path,
created_at="2026-08-27 09:00:00",
state="planned",
updated_at="2026-08-27 09:00:00",
input_version=planning_input.schema_version,
planning_input=planning_input.to_payload(),
input_fingerprint=planning_input.fingerprint,
checkpoint_version=plan_checkpoint.schema_version,
checkpoint_payload=plan_checkpoint.to_payload(),
planned_at="2026-08-27 09:00:00",
execution_state="settling",
execution_version=execution_checkpoint.version,
execution_payload=execution_checkpoint.to_payload(),
execution_fingerprint=execution_checkpoint.fingerprint,
retry_generation=0,
retry_count=0,
settlement_revision=0,
**lease_values,
))
session.commit()
return plan_checkpoint, execution_checkpoint
@pytest.fixture
def admission_store(tmp_path):
"""创建独立 SQLite admission 仓储及其 Session 工厂。"""
engine = create_engine(f"sqlite:///{tmp_path / 'settling-recovery.db'}")
TransferHistory.__table__.create(engine)
TransferPending.__table__.create(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
try:
yield TransactionalTransferAdmissionRepository(factory), factory
finally:
engine.dispose()
def _snapshot(
checkpoint: TransferExecutionCheckpoint,
) -> TransferExecutionSnapshot:
"""构造 settling 状态的脱离 Session 执行投影。"""
return TransferExecutionSnapshot(
task_id="settling-task",
state=TransferExecutionState.SETTLING,
checkpoint=checkpoint,
retry_generation=0,
retry_count=0,
retry_due_at=None,
settlement_revision=0,
terminal_history_id=None,
last_error=None,
steps=(),
)
def _build_chain(admissions) -> TransferChain:
"""构造只允许执行 settling 终态恢复的 TransferChain 骨架。"""
chain = object.__new__(TransferChain)
chain._transfer_admissions = admissions
chain._worker_owner_id = "recovery-owner"
chain._owned_leases = {}
chain._queued_lease_tokens = set()
chain._worker_state_lock = threading.RLock()
chain._closing = False
chain._recovery_wakeup_event = threading.Event()
chain._replay_stop_event = threading.Event()
chain._lease_heartbeat_stop_event = threading.Event()
chain._lease_heartbeat_thread = None
chain._TransferChain__ensure_lease_heartbeat_owner = MagicMock()
chain._TransferChain__ensure_recovery_scheduler = MagicMock()
chain._TransferChain__restore_planned_task = MagicMock()
chain._TransferChain__select_storage_oper = MagicMock(
side_effect=AssertionError("settling 恢复不得选择存储适配器")
)
chain._plan_checkpoint_and_execute = MagicMock(
side_effect=AssertionError("settling 恢复不得重新执行计划")
)
chain.jobview = MagicMock()
return chain
def _recovered_task(
chain: TransferChain,
admission: TransferAdmission,
execution_checkpoint: TransferExecutionCheckpoint,
) -> TransferTask:
"""把 claim 投影绑定为只待终态 writer 处理的恢复任务。"""
assert admission.planning_input is not None
assert admission.checkpoint is not None
assert admission.lease_owner is not None
assert admission.lease_token is not None
task = TransferTask(
fileitem=FileItem.model_validate(
admission.planning_input.source_fileitem
)
)
task.bind_admission_task_id(admission.task_id)
task.bind_planning_input(admission.planning_input)
task.bind_plan_checkpoint(admission.checkpoint)
task.bind_execution_checkpoint(execution_checkpoint)
task.bind_execution_lease(
owner_id=admission.lease_owner,
lease_token=admission.lease_token,
)
chain._owned_leases[admission.task_id] = (
admission.lease_token,
time.monotonic() + 120,
)
return task
@pytest.mark.parametrize("lease_state", ["missing", "expired"])
def test_settling_task_can_be_claimed_by_only_one_owner(
admission_store,
lease_state,
) -> None:
"""空租约和过期租约的 settling 任务都只能由一个 worker 取得。"""
repository, factory = admission_store
_add_settling_pending(
factory,
path="/downloads/Movie.mkv",
lease_state=lease_state,
)
claimed = repository.claim_recoverable(
owner_id="first-owner",
limit=1,
lease_seconds=120,
)
competing = repository.claim_recoverable(
owner_id="second-owner",
limit=1,
lease_seconds=120,
)
assert len(claimed) == 1
assert claimed[0].lease_owner == "first-owner"
assert competing == []
with factory() as session:
pending = session.execute(select(TransferPending)).scalar_one()
assert pending.execution_state == "settling"
assert pending.lease_token == claimed[0].lease_token
@pytest.mark.parametrize(
("success", "include_transferinfo"),
[(True, True), (False, False)],
)
def test_settling_result_calls_only_task_aware_terminal_writer(
success,
include_transferinfo,
) -> None:
"""成功和确定失败都只从检查点恢复结果并调用携带 task 的 writer。"""
path = "/downloads/Movie.mkv"
planning_input = _planning_input(path)
plan_checkpoint = _plan_checkpoint(planning_input)
execution_checkpoint = _execution_checkpoint(
path,
success=success,
include_transferinfo=include_transferinfo,
)
admission = TransferAdmission(
task_id="settling-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="recovery-token",
)
chain = _build_chain(MagicMock())
task = _recovered_task(chain, admission, execution_checkpoint)
writer_calls = []
def terminal_writer(
callback_task: TransferTask,
transferinfo: TransferInfo,
) -> tuple[bool, str]:
"""记录 task-aware 终态 writer 收到的恢复事实。"""
writer_calls.append((callback_task, transferinfo))
return transferinfo.success, transferinfo.message or ""
result = chain._TransferChain__handle_planned_transfer(
task,
terminal_writer,
)
assert result[0] is success
assert len(writer_calls) == 1
assert writer_calls[0][0] is task
assert writer_calls[0][0].execution_checkpoint == execution_checkpoint
assert writer_calls[0][1].success is success
chain._TransferChain__select_storage_oper.assert_not_called()
chain._plan_checkpoint_and_execute.assert_not_called()
def test_replay_settling_uses_frozen_source_without_filesystem_probe(
monkeypatch,
) -> None:
"""move 后源文件已消失时,settling 回放仍应直接入队结算。"""
path = "/already-moved/Movie.mkv"
planning_input = _planning_input(path)
plan_checkpoint = _plan_checkpoint(planning_input)
execution_checkpoint = _execution_checkpoint(path, success=True)
admission = TransferAdmission(
task_id="settling-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="recovery-token",
)
admissions = MagicMock()
admissions.claim_recoverable.return_value = [admission]
executions = MagicMock()
executions.get_snapshot.return_value = _snapshot(execution_checkpoint)
chain = _build_chain(admissions)
chain._transfer_executions = executions
chain.put_to_queue = MagicMock(return_value=True)
def reject_stat(*_args, **_kwargs):
"""任何源文件探测都表示 settling 恢复走回了旧执行路径。"""
pytest.fail("settling 恢复不得探测已经移动的源文件")
monkeypatch.setattr(Path, "stat", reject_stat)
chain._TransferChain__replay_pending()
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.release_claim.assert_not_called()
chain._plan_checkpoint_and_execute.assert_not_called()
def test_writer_failure_releases_and_reclaims_same_settling_checkpoint(
admission_store,
) -> None:
"""writer 临时失败后释放租约,再次恢复不得重新执行外部步骤。"""
repository, factory = admission_store
_, execution_checkpoint = _add_settling_pending(
factory,
path="/downloads/Movie.mkv",
lease_state="missing",
)
first_admission = repository.claim_recoverable(
owner_id="recovery-owner",
limit=1,
lease_seconds=120,
)[0]
chain = _build_chain(repository)
first_task = _recovered_task(
chain,
first_admission,
execution_checkpoint,
)
def unavailable_writer(*_args, **_kwargs):
"""模拟历史与 pending 原子 writer 的暂时性数据库失败。"""
raise RuntimeError("writer temporarily unavailable")
with pytest.raises(RuntimeError, match="temporarily unavailable"):
chain._TransferChain__handle_planned_transfer(
first_task,
unavailable_writer,
)
assert chain._TransferChain__release_task_claim(
first_task,
error="writer temporarily unavailable",
)
chain._worker_owner_id = "second-recovery-owner"
second_admission = repository.claim_recoverable(
owner_id="second-recovery-owner",
limit=1,
lease_seconds=120,
)[0]
second_task = _recovered_task(
chain,
second_admission,
execution_checkpoint,
)
settled = []
def available_writer(
callback_task: TransferTask,
transferinfo: TransferInfo,
) -> tuple[bool, str]:
"""模拟下一轮恢复时恢复正常的 task-aware writer。"""
settled.append((callback_task, transferinfo))
return transferinfo.success, transferinfo.message or ""
result = chain._TransferChain__handle_planned_transfer(
second_task,
available_writer,
)
assert result == (True, "整理完成")
assert len(settled) == 1
assert settled[0][0].execution_checkpoint == execution_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()