mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-31 13:07:56 +08:00
refactor: complete durable transfer execution settlement
This commit is contained in:
@@ -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
@@ -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],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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="查询整理后的名称",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""返回当前领域上下文是否来自持久快照。"""
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+336
-24
@@ -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")
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -100,6 +100,8 @@
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"当前管理用户缺少可审计身份": "当前管理用户缺少可审计身份",
|
||||
"人工复核任务不存在": "人工复核任务不存在",
|
||||
"媒体来源和媒体 ID 必须同时提供": "媒体来源和媒体 ID 必须同时提供",
|
||||
"media_source 和 media_id 必须同时提供": "media_source 和 media_id 必须同时提供",
|
||||
"模块不支持测试": "模块不支持测试",
|
||||
|
||||
@@ -112,6 +112,8 @@
|
||||
"会话不存在或无权访问": "會話不存在或無權存取",
|
||||
"会话保存失败": "會話儲存失敗",
|
||||
"后台服务不存在": "背景服務不存在",
|
||||
"当前管理用户缺少可审计身份": "目前管理使用者缺少可稽核身分",
|
||||
"人工复核任务不存在": "人工複核任務不存在",
|
||||
"任务添加失败": "任務新增失敗",
|
||||
"无法识别媒体信息": "無法識別媒體資訊",
|
||||
"未识别到媒体信息": "未識別到媒體資訊",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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'],
|
||||
}
|
||||
|
||||
@@ -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
@@ -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):
|
||||
"""
|
||||
剧集自定义识别格式
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
与可选 outbox;revision 和确定性 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 \
|
||||
|
||||
@@ -704,8 +704,8 @@ flowchart LR
|
||||
|
||||
| 指标 | 当前值 |
|
||||
|---|---:|
|
||||
| Python 模块 | 837 |
|
||||
| 内部导入边 | 6,834 |
|
||||
| Python 模块 | 843 |
|
||||
| 内部导入边 | 6,883 |
|
||||
| 非平凡 SCC | 2(`ARCH-107` 临时 Chain 包根环;精确 containment 的 TMDB 移植包环) |
|
||||
| Direct egress | 66(12 条待迁移债务,54 条精确 containment) |
|
||||
| Module Contract V2 spec | 217(其中 215 个进入 `run_module` 观察面) |
|
||||
|
||||
@@ -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`/ORM,Session 内投影 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 | 高风险包纳入 coverage;raw concurrency 分类清零;Module Quality 有真实 evidence test |
|
||||
|
||||
### S5:Plugin、Agent、Domain、Startup 与最终收口
|
||||
|
||||
@@ -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 结算写入;响应仅返回任务、操作、决策、后续状态和复核修订号 |
|
||||
|
||||
#### 站点
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
},
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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``)。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 风格对象。"""
|
||||
|
||||
|
||||
@@ -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 == "人工复核任务必须先完成专门判定"
|
||||
@@ -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}
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user