Files

1528 lines
54 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
整理步骤执行检查点、人工判定与终态结算的应用契约。
"""
from __future__ import annotations
import hashlib
import json
from copy import deepcopy
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
from typing import Any, Callable, Mapping, Optional, Protocol
from uuid import uuid4
from app.schemas.exception import BusinessRejectedError
from app.schemas.types import MediaSource, MediaType
# 聚合执行检查点的当前持久格式版本
TRANSFER_EXECUTION_VERSION = 1
# 单步操作意图的当前持久格式版本
TRANSFER_STEP_INTENT_VERSION = 1
# 单步执行结果与探测证据的当前持久格式版本
TRANSFER_STEP_RESULT_VERSION = 1
class TransferExecutionState(StrEnum):
"""
描述与规划阶段相互独立的持久执行状态。
"""
# 尚未开始执行外部步骤
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 TransferExecutionOutcome(StrEnum):
"""
描述执行检查点可裁决的业务结果。
"""
# 文件整理已成功完成
SUCCEEDED = "succeeded"
# 文件整理明确失败
FAILED = "failed"
# 因覆盖策略跳过现有目标,没有执行覆盖
OVERWRITE_SKIPPED = "overwrite_skipped"
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):
"""
表示持久化写入时任务租约已失效或已被其他执行者接管。
"""
class TransferPlanningRejectedError(ValueError, BusinessRejectedError):
"""
规划输入缺少业务必需信息且继续自动重试不会改变结果。
"""
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_overwrite_paths(
*,
source: Mapping[str, Any],
target_storage: str,
target_path: str,
transfer_type: str,
) -> tuple[str, str]:
"""
从冻结叶操作推导同目录暂存和备份路径,保证执行与账本验证使用相同身份。
115 重命名会分割主文件名与扩展名,不能使用仅含点号前缀的无扩展名临时文件;
其临时名保留目标扩展名,其他存储沿用原路径以保持已有步骤的恢复身份。
"""
identity = _canonical_json({
"source": dict(source), "storage": target_storage,
"path": target_path, "type": transfer_type,
})
digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:24]
target = Path(target_path)
prefix = "mp" if target_storage == "u115" else ".mp"
suffix = target.suffix if target_storage == "u115" else ""
return (
target.with_name(f"{prefix}-stage-{digest}{suffix}").as_posix(),
target.with_name(f"{prefix}-backup-{digest}{suffix}").as_posix(),
)
def build_transfer_operation_id(
*,
task_id: str,
checkpoint_fingerprint: str,
ordinal: int,
phase: str,
kind: str,
intent_payload: Mapping[str, Any],
) -> str:
"""
由冻结计划和操作语义生成跨租约、执行尝试与重启稳定的身份。
: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:
"""
由版本化执行检查点数据快照生成稳定 SHA-256 指纹。
"""
return hashlib.sha256(
_canonical_json(checkpoint_payload).encode("utf-8")
).hexdigest()
@dataclass(frozen=True, slots=True)
class TransferStepIntent:
"""
保存一次可持久化外部操作的稳定意图。
:param operation_id: 在同一任务与计划内稳定标识外部操作的键
:param checkpoint_fingerprint: 所属规划检查点的内容指纹,防止旧步骤应用到新计划
:param ordinal: 步骤在计划中的顺序,从 0 开始
:param phase: 步骤所属的整理阶段
:param kind: 外部操作种类,例如文件转移、覆盖或下载器清理
:param payload: 外部操作所需的可序列化参数,构造时深复制
:param version: 该持久数据结构的版本,必须为当前受支持版本
"""
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":
"""
构造并绑定稳定操作标识的步骤意图。
"""
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:
"""
保存严格执行或探测后得到的版本化步骤证据。
:param payload: 执行或探测得到的可序列化证据,构造时深复制
:param version: 该持久数据结构的版本,必须为当前受支持版本
"""
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:
"""
保存外部操作探测结论及其版本化事实证据。
:param state: 外部操作是否已经发生的探测结论
:param evidence: 支持该探测结论的版本化事实证据
"""
state: TransferOperationObservationState
evidence: TransferStepResult
@dataclass(frozen=True, slots=True)
class TransferExecutionStep:
"""
提供脱离 ORM Session 的单步骤持久状态投影。
:param task_id: 持久整理任务的稳定标识
:param operation_id: 在同一任务与计划内稳定标识外部操作的键
:param checkpoint_fingerprint: 所属规划检查点的内容指纹,防止旧步骤应用到新计划
:param ordinal: 步骤在计划中的顺序,从 0 开始
:param phase: 步骤所属的整理阶段
:param kind: 外部操作种类,例如文件转移、覆盖或下载器清理
:param state: 该操作的持久步骤状态
:param attempt_token: 当前步骤尝试的唯一令牌;尚未开始时为 None
:param attempt_count: 该步骤已开始执行的尝试次数
:param intent: 执行前保存的稳定操作意图
:param result: 成功执行或恢复探测后保存的结果;尚无结果时为 None
:param last_error: 最近失败的可读原因;未失败时为 None
:param prepared_at: 步骤意图首次持久化的时间文本
:param started_at: 当前尝试开始时间;尚未开始时为 None
:param completed_at: 步骤成功完成时间;尚未完成时为 None
:param updated_at: 最近一次持久状态更新时间文本
"""
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:
"""
保存所有必要步骤完成后供终态结算重放的聚合结果。
:param fingerprint: 完整序列化检查点的 SHA-256 指纹
:param payload: 含明确业务结果与整理结果快照的可序列化内容
:param operation_ids: 本次聚合结果引用的已完成步骤标识,不允许重复
:param skip_reason: 未产生外部操作的明确原因;有步骤时可为 None
:param version: 该持久数据结构的版本,必须为当前受支持版本
"""
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")
raw_outcome = self.payload.get("outcome")
try:
outcome = TransferExecutionOutcome(
raw_outcome if isinstance(raw_outcome, str) else ""
)
except (TypeError, ValueError) as error:
raise ValueError("整理执行检查点 outcome 无效") from error
transferinfo = self.payload.get("transferinfo")
if transferinfo is not None:
if not isinstance(transferinfo, dict):
raise ValueError("整理执行检查点 TransferInfo 必须是 JSON 对象")
expected_success = outcome is TransferExecutionOutcome.SUCCEEDED
expected_overwrite_skip = (
outcome is TransferExecutionOutcome.OVERWRITE_SKIPPED
)
if (
bool(transferinfo.get("success")) != expected_success
or bool(transferinfo.get("overwrite_skipped"))
!= expected_overwrite_skip
):
raise ValueError("整理执行 checkpoint outcome 与 TransferInfo 不一致")
elif outcome is TransferExecutionOutcome.OVERWRITE_SKIPPED:
raise ValueError("覆盖跳过执行检查点缺少冻结 TransferInfo")
_canonical_json(self.payload)
if build_transfer_checkpoint_fingerprint(self.to_payload()) != self.fingerprint:
raise ValueError("整理执行检查点内容与指纹不一致")
@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,
}
def validate_settlement_outcome(
self,
settlement_outcome: str,
) -> TransferTerminalState:
"""
解析执行事实,并校验同事务历史裁决给出的结算方向。
"""
raw_outcome = self.payload.get("outcome")
try:
outcome = TransferExecutionOutcome(
raw_outcome if isinstance(raw_outcome, str) else ""
)
except (TypeError, ValueError) as error:
raise TransferExecutionConflictError(
"整理执行检查点 outcome 无效"
) from error
try:
terminal_state = TransferTerminalState(settlement_outcome)
except (TypeError, ValueError) as error:
raise TransferExecutionConflictError(
"整理结算 outcome 无效"
) from error
if (
outcome != TransferExecutionOutcome.OVERWRITE_SKIPPED
and outcome.value != terminal_state.value
):
raise TransferExecutionConflictError(
"整理结算 outcome 与执行检查点不一致"
)
return terminal_state
@classmethod
def from_payload(
cls,
payload: Mapping[str, Any],
*,
fingerprint: str,
) -> "TransferExecutionCheckpoint":
"""
解析并校验数据库中的执行检查点版本与稳定指纹。
"""
if not isinstance(payload, Mapping):
raise TransferExecutionConflictError("整理执行检查点 JSON 结构无效")
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("整理执行跳过原因类型无效")
try:
return cls(
fingerprint=fingerprint,
payload=result_payload,
operation_ids=tuple(operation_ids),
skip_reason=skip_reason,
version=version,
)
except ValueError as error:
raise TransferExecutionConflictError(
"整理执行检查点内容无效"
) from error
@dataclass(frozen=True, slots=True)
class TransferExecutionSnapshot:
"""
提供任务执行检查点、重试和人工状态的稳定投影。
:param task_id: 持久整理任务的稳定标识
:param state: 任务当前的持久执行状态,独立于规划状态
:param checkpoint: 供终态结算恢复使用的执行检查点;未生成时为 None
:param retry_generation: 人工重新请求重试后推进的代数,用于区分不同恢复轮次
:param retry_count: 当前恢复周期已经消耗的重试次数
:param retry_due_at: 自动重试最早可被认领的时间;未安排重试时为 None
:param settlement_revision: 终态结算版本,用于识别重复结算和拒绝旧操作
:param terminal_history_id: 终态回执关联的整理历史主键;未结算时为 None
:param last_error: 最近失败的可读原因;未失败时为 None
:param steps: 按稳定顺序保存的全部执行步骤快照
"""
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:
"""
描述已确定结果对应的历史写入与待处理记录终态。
:param terminal_state: 允许写入历史的明确成功或失败终态
:param history_payload: 终态历史的可序列化字段,必须包含源路径
"""
terminal_state: TransferTerminalState
history_payload: dict[str, Any]
def __post_init__(self) -> None:
"""
确保历史数据快照可持久化且包含稳定源身份。
"""
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:
"""
描述终态历史是否新提交以及待处理记录是否已删除。
:param history_id: 关联整理历史的数据库主键
:param settlement_revision: 终态结算版本,用于识别重复结算和拒绝旧操作
:param pending_deleted: 本次结算是否已经删除待处理任务记录
:param already_settled: 是否复用已存在的结算回执,默认 False
"""
history_id: int
settlement_revision: int
pending_deleted: bool
already_settled: bool = False
@dataclass(frozen=True, slots=True)
class TransferRetryRequestResult:
"""
描述用户重试请求是否被持久调度器接受。
:param accepted: 人工重试请求是否被接受
:param state: 处理重试请求后任务的执行状态
:param retry_generation: 人工重新请求重试后推进的代数,用于区分不同恢复轮次
:param message: 操作结果或拒绝原因的可读说明
"""
accepted: bool
state: TransferExecutionState
retry_generation: int
message: str
@dataclass(frozen=True, slots=True)
class TransferFailureDiscardResult:
"""
描述终态失败任务是否已解除历史绑定并删除执行证据。
:param discarded: 是否成功放弃失败任务并解除历史绑定
:param state: 处理后的任务执行状态;任务不存在时为 None
:param message: 操作结果或拒绝原因的可读说明
"""
discarded: bool
state: Optional[TransferExecutionState]
message: str
@dataclass(frozen=True, slots=True)
class TransferManualReviewResult:
"""
描述一次已持久审计的人工判定及后续调度状态。
:param task_id: 持久整理任务的稳定标识
:param operation_id: 在同一任务与计划内稳定标识外部操作的键
:param decision: 本次已接受的人工判定
:param state: 人工判定后的任务执行状态
:param review_revision: 人工复核审计版本,随已接受的判定递增
:param step: 判定完成后保存的步骤快照
"""
task_id: str
operation_id: str
decision: TransferManualReviewDecision
state: TransferExecutionState
review_revision: int
step: TransferExecutionStep
@dataclass(frozen=True, slots=True)
class TransferManualReviewDiscardResult:
"""描述人工明确放弃一个不确定整理任务的结果。
该操作只解除 durable task 及其步骤证据;绝不执行、回滚或删除任何媒体文件。
"""
task_id: str
operation_id: str
discarded: bool
message: str
@dataclass(frozen=True, slots=True)
class TransferManualReviewSource:
"""
提供人工复核任务的最小源文件身份。
:param storage: 源文件所在的存储配置标识
:param path: 源文件在存储中的路径
"""
storage: str
path: str
@dataclass(frozen=True, slots=True)
class TransferManualReviewStepView:
"""
提供人工复核所需且不含租约或尝试令牌的步骤证据。
:param operation_id: 在同一任务与计划内稳定标识外部操作的键
:param kind: 外部操作种类,例如文件转移、覆盖或下载器清理
:param intent: 供管理员检查的操作意图,不包含租约或尝试令牌
:param evidence: 外部结果的已有探测证据;尚未取得时为 None
:param error: 操作失败的可读原因;无错误时为 None
"""
operation_id: str
kind: str
intent: dict[str, Any]
evidence: Optional[dict[str, Any]]
error: Optional[str]
@dataclass(frozen=True, slots=True)
class TransferManualReviewTaskView:
"""
提供管理员发现与复核持久化整理任务的公开投影。
:param task_id: 持久整理任务的稳定标识
:param source: 复核目标的源存储与路径
:param state: 当前需要人工关注的执行状态
:param step: 本次需要复核的步骤及证据
:param review_revision: 人工复核审计版本,随已接受的判定递增
"""
task_id: str
source: TransferManualReviewSource
state: TransferExecutionState
step: TransferManualReviewStepView
review_revision: int
@dataclass(frozen=True, slots=True)
class TransferManualReviewPage:
"""
提供稳定分页的人工复核任务结果。
:param items: 当前页的人工复核任务
:param total: 满足查询状态的任务总数
:param page: 当前页码,从 1 开始
:param page_size: 每页最多返回的任务数量
"""
items: tuple[TransferManualReviewTaskView, ...]
total: int
page: int
page_size: int
class TransferExecutionRepository(Protocol):
"""
定义步骤状态、执行检查点与终态历史的持久化端口。
写操作以独立短事务保存状态,外部副作用由调用方在事务外执行。
需要租约的状态推进同时核对步骤尝试身份,冲突不得覆盖既有证据。
"""
def get_snapshot(self, *, task_id: str) -> Optional[TransferExecutionSnapshot]:
"""
读取任务执行快照。
:param task_id: 目标持久整理任务的稳定标识
:return: 当前执行快照;任务不存在时为 None
"""
def isolate_unhandled_failure(
self, *, task_id: str, lease_token: str, error: str,
) -> bool:
"""
将步骤账本之外的异常计入失败次数并隔离复核,不推测外部操作结果。
:param task_id: 目标持久整理任务的稳定标识
:param lease_token: 当前认领获得的有效租约令牌,用于拒绝过期执行者
:param error: 需要持久记录的可读失败原因
:return: 有效租约下成功隔离时为 True;任务或租约条件不匹配时为 False
"""
async def async_get_snapshot(
self,
*,
task_id: str,
) -> Optional[TransferExecutionSnapshot]:
"""
异步读取任务执行快照,供事件循环内状态观察使用。
:param task_id: 目标持久整理任务的稳定标识
:return: 当前执行快照;任务不存在时为 None
"""
def list_manual_reviews(
self,
*,
state: TransferExecutionState,
page: int,
page_size: int,
) -> TransferManualReviewPage:
"""
按严格公开状态分页读取人工复核任务。
:param state: 待查询的公开执行状态,限定需要人工关注的任务
:param page: 页码,从 1 开始
:param page_size: 每页最多返回的任务数量
:return: 满足状态条件的任务分页及总数
"""
def get_manual_review(
self,
*,
task_id: str,
) -> Optional[TransferManualReviewTaskView]:
"""
按任务标识读取人工复核公开详情。
:param task_id: 目标持久整理任务的稳定标识
:return: 任务的公开复核详情;不存在或不在可复核范围时为 None
"""
def prepare_step(
self,
*,
task_id: str,
lease_token: str,
intent: TransferStepIntent,
) -> TransferExecutionStep:
"""
在外部副作用前持久化稳定步骤意图。
:param task_id: 目标持久整理任务的稳定标识
:param lease_token: 当前认领获得的有效租约令牌,用于拒绝过期执行者
:param intent: 准备写入的稳定操作意图,必须属于当前计划
:return: 已持久化的准备步骤;相同意图可复用原步骤
"""
def start_step(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
) -> TransferExecutionStep:
"""
以当前租约和新尝试令牌标记步骤开始。
:param task_id: 目标持久整理任务的稳定标识
:param lease_token: 当前认领获得的有效租约令牌,用于拒绝过期执行者
:param operation_id: 目标步骤的稳定操作标识
:param attempt_token: 本次步骤执行的唯一尝试令牌
:return: 写入本次尝试令牌后的已开始步骤
"""
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:
"""
严格探测为未发生后,以新尝试令牌安全重启遗留步骤。
:param task_id: 目标持久整理任务的稳定标识
:param lease_token: 当前认领获得的有效租约令牌,用于拒绝过期执行者
:param operation_id: 目标步骤的稳定操作标识
:param previous_attempt_token: 待恢复的旧尝试令牌,必须仍与持久步骤匹配
:param attempt_token: 本次步骤执行的唯一尝试令牌
:param evidence: 证明旧尝试尚未产生外部副作用的探测证据
:return: 根据未发生证据启用新尝试后的步骤
"""
def resume_failed_step(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
) -> TransferExecutionStep:
"""
任务到期并重新认领后,以新尝试令牌重试 FAILED 步骤。
:param task_id: 目标持久整理任务的稳定标识
:param lease_token: 当前认领获得的有效租约令牌,用于拒绝过期执行者
:param operation_id: 目标步骤的稳定操作标识
:param attempt_token: 本次步骤执行的唯一尝试令牌
:return: 重新认领并开始新尝试后的步骤
"""
def complete_step(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
result: TransferStepResult,
) -> TransferExecutionStep:
"""
以租约和 attempt 双 CAS 提交成功证据。
:param task_id: 目标持久整理任务的稳定标识
:param lease_token: 当前认领获得的有效租约令牌,用于拒绝过期执行者
:param operation_id: 目标步骤的稳定操作标识
:param attempt_token: 本次步骤执行的唯一尝试令牌
:param result: 本次操作已经完成的版本化事实证据
:return: 已保存成功证据的步骤快照
"""
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:
"""
持久化已知失败、到期时间并原子释放当前租约。
:param task_id: 目标持久整理任务的稳定标识
:param lease_token: 当前认领获得的有效租约令牌,用于拒绝过期执行者
:param operation_id: 目标步骤的稳定操作标识
:param attempt_token: 本次步骤执行的唯一尝试令牌
:param error: 需要持久记录的可读失败原因
:param retry_due_at: 下一次自动认领的最早时间文本
:param evidence: 支持失败或人工隔离判断的探测证据;None 表示无额外证据
:return: 已记录失败和重试时间、释放租约后的任务快照
"""
def exhaust_step(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
error: str,
evidence: Optional[TransferStepResult] = None,
) -> TransferExecutionSnapshot:
"""
重试预算耗尽时保留租约,并建立可持久化失败结算的检查点。
:param task_id: 目标持久整理任务的稳定标识
:param lease_token: 当前认领获得的有效租约令牌,用于拒绝过期执行者
:param operation_id: 目标步骤的稳定操作标识
:param attempt_token: 本次步骤执行的唯一尝试令牌
:param error: 需要持久记录的可读失败原因
:param evidence: 支持失败或人工隔离判断的探测证据;None 表示无额外证据
:return: 保存失败结算检查点后的任务快照,租约仍供当前执行者结算使用
"""
def request_retry(
self,
*,
task_id: str,
reason: str,
requested_by: str,
) -> TransferRetryRequestResult:
"""
仅把 FAILED 终态转入到期可认领的 retry_wait。
:param task_id: 目标持久整理任务的稳定标识
:param reason: 发起恢复操作的可读理由,用于审计
:param requested_by: 发起人工重试的用户或系统身份
:return: 是否接受重试、当前执行状态与重试代数
"""
def request_replan(
self, *, history_id: int, task_id: str, settlement_revision: int,
media_source: str, media_id: str,
media_type: str, season: Optional[int], episode: Optional[int],
requested_by: str,
) -> TransferRetryRequestResult:
"""
为尚未发生文件副作用的识别拒绝更新规划输入,并保留原拒绝证据。
:param history_id: 当前失败终态关联的历史标识
:param task_id: 分析开始时历史关联的持久任务标识
:param settlement_revision: 分析开始时的结算版本,用于拒绝陈旧修正
:param media_source: 已核实的媒体来源
:param media_id: 已核实的来源原生标识
:param media_type: 已核实的电影或电视剧类型
:param season: 修正后的季号;None 保留原值
:param episode: 修正后的单集集号;None 保留原值
:param requested_by: 提交修正的身份,用于审计
:return: 是否接纳重新规划及持久调度状态
"""
def discard_corrupt_task(
self,
*,
task_id: str,
lease_token: str,
error: str,
) -> bool:
"""
在当前租约下原子清除无法继续执行的损坏任务及其恢复证据。
:param task_id: 目标持久整理任务的稳定标识
:param lease_token: 当前认领获得的有效租约令牌,用于拒绝过期执行者
:param error: 需要持久记录的可读失败原因
:return: 完成损坏任务清理时为 True;必需身份或原因为空时为 False
"""
def discard_corrupt_by_history(
self,
*,
task_id: str,
history_id: int,
) -> TransferFailureDiscardResult:
"""
清理无活动租约的损坏任务,并解除对应历史绑定。
:param task_id: 目标持久整理任务的稳定标识
:param history_id: 当前任务绑定的失败历史主键
:return: 任务清理结果及拒绝原因;目标已不存在时也视为已清理
"""
def discard_failed(
self,
*,
task_id: str,
history_id: int,
settlement_revision: int,
) -> TransferFailureDiscardResult:
"""
仅放弃与指定失败回执完全匹配的无租约 FAILED 任务。
:param task_id: 目标持久整理任务的稳定标识
:param history_id: 当前任务绑定的失败历史主键
:param settlement_revision: 调用方读取的结算版本,必须与当前失败回执匹配
:return: 是否解除匹配的失败任务、当前状态及结果说明
"""
def resolve_manual_review(
self,
*,
task_id: str,
operation_id: str,
decision: TransferManualReviewDecision,
actor: str,
reason: str,
result: Optional[TransferStepResult] = None,
) -> TransferManualReviewResult:
"""
无租约地原子提交人工判定审计并交回唯一调度器。
:param task_id: 目标持久整理任务的稳定标识
:param operation_id: 目标步骤的稳定操作标识
:param decision: 管理员对外部操作结果的明确判定
:param actor: 作出人工判定的管理员身份
:param reason: 发起恢复操作的可读理由,用于审计
:param result: 人工确认操作已发生时提供的结果证据;其他判定可为 None
:return: 已持久审计的判定、复核版本及后续步骤状态
"""
def discard_manual_review(
self,
*,
task_id: str,
operation_id: str,
actor: str,
reason: str,
) -> TransferManualReviewDiscardResult:
"""放弃无租约人工复核任务,且不得触碰源文件或目标文件。"""
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:
"""
持久化不可判定结果并从自动调度中隔离任务。
:param task_id: 目标持久整理任务的稳定标识
:param lease_token: 当前认领获得的有效租约令牌,用于拒绝过期执行者
:param operation_id: 目标步骤的稳定操作标识
:param error: 需要持久记录的可读失败原因
:param evidence: 支持失败或人工隔离判断的探测证据;None 表示无额外证据
:param attempt_token: 需要核对的当前步骤尝试令牌;None 表示该调用不指定尝试
:return: 已从自动调度隔离的任务快照
"""
def checkpoint_execution(
self,
*,
task_id: str,
lease_token: str,
checkpoint: TransferExecutionCheckpoint,
) -> TransferExecutionSnapshot:
"""
确认所有引用步骤成功后提交聚合执行检查点。
:param task_id: 目标持久整理任务的稳定标识
:param lease_token: 当前认领获得的有效租约令牌,用于拒绝过期执行者
:param checkpoint: 引用已完成步骤的完整执行结果检查点
:return: 保存完整执行检查点后的任务快照
"""
class TransferStepRunner(Protocol):
"""
定义文件执行方可注入的单步骤持久执行边界。
"""
def run(
self,
*,
phase: str,
kind: str,
payload: Mapping[str, Any],
execute: Callable[[], TransferStepResult],
observe: Callable[[], TransferOperationObservation],
) -> TransferStepResult:
"""
持久编排一次外部副作用,并在遗留尝试时先严格探测。
:param phase: 本步骤所属的执行阶段
:param kind: 本步骤执行的外部操作种类
:param payload: 生成稳定操作意图的可序列化参数
:param execute: 真正执行外部副作用并返回证据的回调,在事务外运行
:param observe: 遇到未确认旧尝试时探测外部结果的回调
:return: 新执行或已确认旧执行产生的稳定结果证据
"""
class TransferExecutionCommand:
"""
以类型化命令收口步骤状态机,外部 I/O 由调用方在事务外执行。
"""
def __init__(
self,
repository: TransferExecutionRepository,
*,
attempt_token_factory: Callable[[], str] = lambda: uuid4().hex,
) -> None:
"""
保存持久化端口和可替换的尝试令牌工厂。
"""
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 = 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 证据成立时轮换尝试令牌并继续执行。
"""
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:
"""
使用重试调度取得的新租约为 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:
"""
使用已开始步骤携带的尝试令牌提交成功证据。
"""
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:
"""
提交达到预算的确定失败,并把任务交给唯一持久化终态 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:
"""
登记用户重试意图,不直接认领或执行任务。
"""
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 request_replan(
self, *, history_id: int, task_id: str, settlement_revision: int,
media_source: str, media_id: str,
media_type: str, season: Optional[int] = None, episode: Optional[int] = None,
requested_by: str,
) -> TransferRetryRequestResult:
"""
登记已核实的媒体和季集修正,具体安全校验及原子更新由仓储执行。
:param history_id: 当前失败终态关联的历史标识
:param task_id: 分析开始时历史关联的持久任务标识
:param settlement_revision: 分析开始时的结算版本,用于拒绝陈旧修正
:param media_source: 已核实的媒体来源
:param media_id: 已核实的来源原生标识
:param media_type: 已核实的电影或电视剧类型
:param season: 修正后的季号;None 保留原值
:param episode: 修正后的单集集号;None 保留原值
:param requested_by: 提交修正的身份,用于审计
:return: 是否接纳重新规划及持久调度状态
"""
if (history_id <= 0 or settlement_revision <= 0
or not all((task_id, media_source, media_id, media_type, requested_by))):
raise ValueError("重新规划缺少历史、媒体身份或请求身份")
normalized_type = MediaType.from_agent(media_type)
if normalized_type is None or normalized_type not in {MediaType.MOVIE, MediaType.TV}:
raise ValueError("识别修正只支持 movie 或 tv")
if not media_id.strip() or media_id.strip() == "0":
raise ValueError("media_id 必须是非零的来源原生 ID")
if (season is not None and season < 0) or (episode is not None and episode < 1):
raise ValueError("季集修正超出有效范围")
if normalized_type is MediaType.MOVIE and (season is not None or episode is not None):
raise ValueError("电影识别修正不能指定季集")
return self._repository.request_replan(
history_id=history_id, task_id=task_id, settlement_revision=settlement_revision,
media_source=MediaSource(media_source).value, media_id=media_id.strip(),
media_type=normalized_type.value, season=season, episode=episode, 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 仍须经租约持久化结算。
"""
if not all((task_id, operation_id, actor)):
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 discard_manual_review(
self,
*,
task_id: str,
operation_id: str,
actor: str,
reason: str,
) -> TransferManualReviewDiscardResult:
"""显式解除卡住的人工复核任务;文件状态仍由用户自行处理。"""
if not all((task_id, operation_id, actor)):
raise ValueError("放弃人工复核缺少任务、步骤或操作者")
return self._repository.discard_manual_review(
task_id=task_id,
operation_id=operation_id,
actor=actor,
reason=reason,
)
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",
"TransferFailureDiscardResult",
"TransferExecutionLeaseLostError",
"TransferOperationObservation",
"TransferOperationObservationState",
"TransferRetryRequestResult",
"TransferExecutionRepository",
"TransferExecutionSnapshot",
"TransferExecutionState",
"TransferExecutionStep",
"TransferManualReviewDecision",
"TransferManualReviewDiscardResult",
"TransferManualReviewPage",
"TransferManualReviewQuery",
"TransferManualReviewResult",
"TransferManualReviewSource",
"TransferManualReviewStepView",
"TransferManualReviewTaskView",
"TransferSettlementIntent",
"TransferSettlementResult",
"TransferStepIntent",
"TransferStepResult",
"TransferStepRunner",
"TransferStepState",
"TransferTerminalState",
"build_transfer_checkpoint_fingerprint",
"build_transfer_operation_id",
]