refactor: complete durable transfer execution settlement

This commit is contained in:
jxxghp
2026-08-27 20:27:42 +08:00
parent 8e7a553c1e
commit e82ce8447c
69 changed files with 11948 additions and 275 deletions
+9
View File
@@ -11,10 +11,12 @@ from dataclasses import dataclass
from typing import Any, Optional
from app.application.transfer import TransferAdmissionRepository
from app.application.transfer_execution import TransferExecutionRepository
OperFactory = Callable[[], Any]
TransferAdmissionRepositoryFactory = Callable[[], TransferAdmissionRepository]
TransferExecutionRepositoryFactory = Callable[[], TransferExecutionRepository]
@dataclass(frozen=True, slots=True)
@@ -27,6 +29,7 @@ class ChainDataPorts:
download_history: OperFactory
transfer_history: OperFactory
transfer_pending: TransferAdmissionRepositoryFactory
transfer_execution: TransferExecutionRepositoryFactory
media_server: OperFactory
download_failure: OperFactory
user: OperFactory
@@ -110,6 +113,7 @@ def configure_chain_data_ports(**factories: OperFactory) -> None:
"download_history",
"transfer_history",
"transfer_pending",
"transfer_execution",
"media_server",
"download_failure",
"user",
@@ -158,6 +162,11 @@ def get_chain_transfer_pending_port() -> TransferAdmissionRepository:
return get_chain_data_ports().transfer_pending()
def get_chain_transfer_execution_port() -> TransferExecutionRepository:
"""创建类型化的整理步骤执行与终态结算仓储。"""
return get_chain_data_ports().transfer_execution()
def get_chain_media_server_port() -> Any:
"""创建媒体服务器数据端口实例。"""
return get_chain_data_ports().media_server()
+43 -6
View File
@@ -11,6 +11,7 @@ from typing import Any, Protocol, cast
from uuid import uuid4
from app.application.history import TransferHistoryRecord, TransferHistoryWriter
from app.application.transfer_execution import TransferSettlementResult
from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic
@@ -37,12 +38,33 @@ class ChainDurableEventWriter(Protocol):
def transfer_result(
self,
*,
topic: str,
topic: str | None,
stage_history: Callable[[TransferHistoryWriter], TransferHistoryRecord | None],
event_payload: dict[str, Any],
publish: Callable[[dict[str, Any]], None],
) -> TransferHistoryRecord | None:
"""提交整理历史与结果 intent,并在提交后广播兼容事件。"""
publish: Callable[[dict[str, Any]], None] | None,
settlement: "TransferResultSettlement | None" = None,
) -> TransferHistoryRecord | TransferSettlementResult | None:
"""提交历史、可选任务终态和结果 intent,再按 topic 广播事件。"""
@dataclass(frozen=True, slots=True)
class TransferResultSettlement:
"""描述一次受 lease fencing 保护的整理任务终态结算。"""
task_id: str
lease_token: str
execution_fingerprint: str
outcome: str
error: str | None = None
def __post_init__(self) -> None:
"""拒绝缺少稳定身份、非法结果或不可诊断的失败终态。"""
if not self.task_id or not self.lease_token or not self.execution_fingerprint:
raise ValueError("整理终态结算缺少任务、租约或执行检查点身份")
if self.outcome not in {"succeeded", "failed"}:
raise ValueError(f"不支持的整理终态:{self.outcome}")
if self.outcome == "failed" and not self.error:
raise ValueError("整理失败终态必须包含可诊断原因")
@dataclass(frozen=True, slots=True)
@@ -61,8 +83,23 @@ def download_added_event_key(history_id: int) -> str:
return f"download.added:{history_id}:{uuid4().hex}:v1"
def transfer_result_event_key(topic: str, history_id: int) -> str:
"""由结果 topic、整理历史 ID 与本次事实标识构造幂等键。"""
def transfer_result_event_key(
topic: str,
history_id: int,
*,
settlement: TransferResultSettlement | None = None,
settlement_revision: int | None = None,
) -> str:
"""为旧结果事实生成 occurrence key,为任务结算生成确定性幂等键。"""
if settlement is not None:
if settlement_revision is None or settlement_revision <= 0:
raise ValueError("整理终态事件键缺少有效结算修订号")
return (
f"transfer.result:{settlement.task_id}:{settlement_revision}:"
f"{settlement.outcome}:v1"
)
if settlement_revision is not None:
raise ValueError("旧整理结果事件键不能单独指定结算修订号")
return f"{topic}:{history_id}:{uuid4().hex}:v1"
+8 -2
View File
@@ -40,6 +40,7 @@ class TransferHistoryRecord(Protocol):
src: Optional[str]
src_storage: Optional[str]
src_fileitem: Optional[dict]
transfer_task_id: Optional[str]
class TransferHistoryWriter(Protocol):
@@ -414,6 +415,11 @@ class TransferHistoryMutationCommand:
history = self._repository.get(history_id)
if not history:
return HistoryMutationResult(False, "记录不存在")
if getattr(history, "transfer_task_id", None):
return HistoryMutationResult(
False,
"持久整理失败记录不可删除,请使用重试或人工复核入口",
)
if delete_destination and history.dest_fileitem:
destination = self._file_item_factory(history.dest_fileitem)
@@ -440,10 +446,10 @@ class TransferHistoryMutationCommand:
return HistoryMutationResult(True)
def truncate(self) -> HistoryMutationResult:
"""在单一事务中清空全部整理历史"""
"""在单一事务中清空旧历史,并保留当前失败任务记录"""
self._repository.stage_truncate()
self._commit()
return HistoryMutationResult(True)
return HistoryMutationResult(True, "已清空旧整理记录,失败任务记录已保留")
def _commit(self) -> None:
"""提交历史事务,失败时回滚且不发布事件或清缓存。"""
+14 -10
View File
@@ -167,16 +167,18 @@ class DurableEventCommand:
def execute(
self,
*,
intent: OutboxIntent | Callable[[T], OutboxIntent],
intent: OutboxIntent | Callable[[T], OutboxIntent] | None,
stage_business: Callable[[], T],
publish: Callable[[], None],
publish: Callable[[], None] | None,
after_commit: Callable[[], None] | None = None,
) -> T:
"""原子提交业务与 intent,再保持原顺序执行提交后动作和即时广播。"""
"""原子提交业务与可选 intent,再执行可选提交后动作和广播。"""
resolved_intent: OutboxIntent | None = None
try:
result = stage_business()
resolved_intent = intent(result) if callable(intent) else intent
self._outbox.stage(resolved_intent, datetime.now(timezone.utc))
if intent is not None:
resolved_intent = intent(result) if callable(intent) else intent
self._outbox.stage(resolved_intent, datetime.now(timezone.utc))
self._unit_of_work.commit()
except Exception:
self._unit_of_work.rollback()
@@ -184,11 +186,13 @@ class DurableEventCommand:
if after_commit:
after_commit()
publish()
self._outbox.complete_by_event_key(
resolved_intent.event_key,
datetime.now(timezone.utc),
)
if publish:
publish()
if resolved_intent is not None and publish is not None:
self._outbox.complete_by_event_key(
resolved_intent.event_key,
datetime.now(timezone.utc),
)
return result
+23
View File
@@ -37,6 +37,7 @@ from pydantic import BaseModel, ConfigDict, PrivateAttr
from app.adapters.system.host import SystemUtils
from app.application.agent import get_prompt_manager, get_running_agent_manager
from app.application.transfer_execution import TransferExecutionCheckpoint
from app.domain.context import MediaInfo, MusicInfo
from app.domain.media import normalize_music_type
from app.domain.meta.metabase import MetaBase
@@ -668,6 +669,8 @@ class TransferTask(OptionalMediaIdentityMixin, BaseModel):
_admission_task_id: Optional[str] = PrivateAttr(default=None)
_planning_input: Optional[TransferPlanningInput] = PrivateAttr(default=None)
_plan_checkpoint: Optional[TransferPlanCheckpoint] = PrivateAttr(default=None)
_execution_checkpoint: Optional[TransferExecutionCheckpoint] = PrivateAttr(default=None)
_terminal_settled: bool = PrivateAttr(default=False)
_planning_context_restored: bool = PrivateAttr(default=False)
_lease_owner: Optional[str] = PrivateAttr(default=None)
_lease_token: Optional[str] = PrivateAttr(default=None)
@@ -699,6 +702,26 @@ class TransferTask(OptionalMediaIdentityMixin, BaseModel):
"""绑定持久执行检查点,不改变插件可见序列化字段。"""
self._plan_checkpoint = checkpoint
@property
def execution_checkpoint(self) -> Optional[TransferExecutionCheckpoint]:
"""返回仅供宿主终态结算使用的内部执行检查点。"""
return self._execution_checkpoint
def bind_execution_checkpoint(
self, checkpoint: TransferExecutionCheckpoint
) -> None:
"""绑定执行结果检查点,不改变插件可见的旧任务序列化字段。"""
self._execution_checkpoint = checkpoint
@property
def terminal_settled(self) -> bool:
"""返回历史、事件与 pending 是否已由同一 UoW 提交。"""
return self._terminal_settled
def mark_terminal_settled(self) -> None:
"""仅在 task-aware writer 成功返回后标记终态已经提交。"""
self._terminal_settled = True
@property
def planning_context_restored(self) -> bool:
"""返回当前领域上下文是否来自持久快照。"""
+884
View File
@@ -0,0 +1,884 @@
"""整理步骤执行检查点、人工判定与终态结算的应用契约。"""
from __future__ import annotations
import hashlib
import json
from copy import deepcopy
from dataclasses import dataclass
from enum import StrEnum
from typing import Any, Callable, Mapping, Optional, Protocol
from uuid import uuid4
TRANSFER_EXECUTION_VERSION = 1
TRANSFER_STEP_INTENT_VERSION = 1
TRANSFER_STEP_RESULT_VERSION = 1
class TransferExecutionState(StrEnum):
"""描述 planning phase 正交的持久执行状态。"""
NOT_STARTED = "not_started"
RUNNING = "running"
RETRY_WAIT = "retry_wait"
SETTLING = "settling"
FAILED = "failed"
MANUAL_REVIEW = "manual_review"
class TransferStepState(StrEnum):
"""描述单个稳定外部操作的持久执行状态。"""
PREPARED = "prepared"
STARTED = "started"
SUCCEEDED = "succeeded"
FAILED = "failed"
MANUAL_REVIEW = "manual_review"
class TransferTerminalState(StrEnum):
"""描述允许写入整理历史的确定终态。"""
SUCCEEDED = "succeeded"
FAILED = "failed"
class TransferOperationObservationState(StrEnum):
"""描述重启后对遗留 STARTED 外部操作的严格探测结论。"""
APPLIED = "applied"
NOT_APPLIED = "not_applied"
UNKNOWN = "unknown"
CONFLICT = "conflict"
class TransferManualReviewDecision(StrEnum):
"""描述人工对外部结果不确定步骤作出的显式判定。"""
NOT_APPLIED = "not_applied"
APPLIED = "applied"
FAILED = "failed"
class TransferExecutionError(RuntimeError):
"""表示整理执行持久化状态无法按请求推进。"""
class TransferExecutionConflictError(TransferExecutionError):
"""表示稳定操作身份、尝试身份或检查点证据发生冲突。"""
class TransferExecutionLeaseLostError(TransferExecutionError):
"""表示持久化写入时任务租约已失效或已被其他 worker 接管。"""
def _canonical_json(payload: Mapping[str, Any]) -> str:
"""生成稳定操作身份使用的规范 JSON。"""
return json.dumps(
payload,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
)
def build_transfer_operation_id(
*,
task_id: str,
checkpoint_fingerprint: str,
ordinal: int,
phase: str,
kind: str,
intent_payload: Mapping[str, Any],
) -> str:
"""
由冻结计划和操作语义生成跨 lease、attempt 与重启稳定的身份。
:param task_id: 稳定任务标识
:param checkpoint_fingerprint: 冻结计划指纹
:param ordinal: 全局执行序号
:param phase: 执行阶段
:param kind: 操作类型
:param intent_payload: 冻结操作参数
:return: SHA-256 操作标识
"""
if not task_id or not checkpoint_fingerprint or ordinal < 0 or not phase or not kind:
raise ValueError("整理操作身份缺少稳定任务、计划或步骤信息")
canonical = _canonical_json({
"schema_version": TRANSFER_STEP_INTENT_VERSION,
"task_id": task_id,
"checkpoint_fingerprint": checkpoint_fingerprint,
"ordinal": ordinal,
"phase": phase,
"kind": kind,
"intent_payload": dict(intent_payload),
})
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def build_transfer_checkpoint_fingerprint(
checkpoint_payload: Mapping[str, Any],
) -> str:
"""由版本化执行检查点 payload 生成稳定 SHA-256 指纹。"""
return hashlib.sha256(
_canonical_json(checkpoint_payload).encode("utf-8")
).hexdigest()
@dataclass(frozen=True, slots=True)
class TransferStepIntent:
"""保存一次可持久化外部操作的稳定意图。"""
operation_id: str
checkpoint_fingerprint: str
ordinal: int
phase: str
kind: str
payload: dict[str, Any]
version: int = TRANSFER_STEP_INTENT_VERSION
def __post_init__(self) -> None:
"""拒绝版本未知、身份不完整或不可 JSON 序列化的操作意图。"""
object.__setattr__(self, "payload", deepcopy(self.payload))
if self.version != TRANSFER_STEP_INTENT_VERSION:
raise ValueError(f"不支持的整理步骤意图版本:{self.version}")
if (
not self.operation_id
or not self.checkpoint_fingerprint
or self.ordinal < 0
or not self.phase
or not self.kind
):
raise ValueError("整理步骤意图缺少稳定身份或顺序")
_canonical_json(self.payload)
@classmethod
def create(
cls,
*,
task_id: str,
checkpoint_fingerprint: str,
ordinal: int,
phase: str,
kind: str,
payload: Mapping[str, Any],
) -> "TransferStepIntent":
"""构造并绑定稳定 operation ID 的步骤意图。"""
frozen_payload = dict(payload)
return cls(
operation_id=build_transfer_operation_id(
task_id=task_id,
checkpoint_fingerprint=checkpoint_fingerprint,
ordinal=ordinal,
phase=phase,
kind=kind,
intent_payload=frozen_payload,
),
checkpoint_fingerprint=checkpoint_fingerprint,
ordinal=ordinal,
phase=phase,
kind=kind,
payload=frozen_payload,
)
@dataclass(frozen=True, slots=True)
class TransferStepResult:
"""保存严格执行或探测后得到的版本化步骤证据。"""
payload: dict[str, Any]
version: int = TRANSFER_STEP_RESULT_VERSION
def __post_init__(self) -> None:
"""拒绝未知版本或不可 JSON 序列化的步骤结果。"""
object.__setattr__(self, "payload", deepcopy(self.payload))
if self.version != TRANSFER_STEP_RESULT_VERSION:
raise ValueError(f"不支持的整理步骤结果版本:{self.version}")
_canonical_json(self.payload)
@dataclass(frozen=True, slots=True)
class TransferOperationObservation:
"""保存外部操作探测结论及其版本化事实证据。"""
state: TransferOperationObservationState
evidence: TransferStepResult
@dataclass(frozen=True, slots=True)
class TransferExecutionStep:
"""提供脱离 ORM Session 的单步骤持久状态投影。"""
task_id: str
operation_id: str
checkpoint_fingerprint: str
ordinal: int
phase: str
kind: str
state: TransferStepState
attempt_token: Optional[str]
attempt_count: int
intent: TransferStepIntent
result: Optional[TransferStepResult]
last_error: Optional[str]
prepared_at: str
started_at: Optional[str]
completed_at: Optional[str]
updated_at: str
@dataclass(frozen=True, slots=True)
class TransferExecutionCheckpoint:
"""保存所有必要步骤完成后供终态结算重放的聚合结果。"""
fingerprint: str
payload: dict[str, Any]
operation_ids: tuple[str, ...]
skip_reason: Optional[str] = None
version: int = TRANSFER_EXECUTION_VERSION
def __post_init__(self) -> None:
"""拒绝重复步骤、空指纹和不可序列化的执行检查点。"""
object.__setattr__(self, "payload", deepcopy(self.payload))
if self.version != TRANSFER_EXECUTION_VERSION or not self.fingerprint:
raise ValueError("整理执行检查点版本或指纹无效")
if len(set(self.operation_ids)) != len(self.operation_ids):
raise ValueError("整理执行检查点不能引用重复操作")
if not all(isinstance(item, str) and item for item in self.operation_ids):
raise ValueError("整理执行检查点包含无效操作标识")
if not self.operation_ids and not self.skip_reason:
raise ValueError("零副作用整理执行检查点必须记录 skip_reason")
_canonical_json(self.payload)
if build_transfer_checkpoint_fingerprint(self.to_payload()) != self.fingerprint:
raise ValueError("整理执行检查点内容与指纹不一致")
@classmethod
def create(
cls,
*,
payload: Mapping[str, Any],
operation_ids: tuple[str, ...],
skip_reason: Optional[str] = None,
) -> "TransferExecutionCheckpoint":
"""构造并绑定完整序列化内容指纹的执行检查点。"""
frozen_payload = dict(payload)
serialized = {
"schema_version": TRANSFER_EXECUTION_VERSION,
"payload": frozen_payload,
"operation_ids": list(operation_ids),
"skip_reason": skip_reason,
}
return cls(
fingerprint=build_transfer_checkpoint_fingerprint(serialized),
payload=frozen_payload,
operation_ids=operation_ids,
skip_reason=skip_reason,
)
def to_payload(self) -> dict[str, Any]:
"""编码可跨重启恢复的完整版本化执行检查点。"""
return {
"schema_version": self.version,
"payload": dict(self.payload),
"operation_ids": list(self.operation_ids),
"skip_reason": self.skip_reason,
}
@classmethod
def from_payload(
cls,
payload: Mapping[str, Any],
*,
fingerprint: str,
) -> "TransferExecutionCheckpoint":
"""解析并校验数据库中的执行检查点版本与稳定指纹。"""
serialized = dict(payload)
if build_transfer_checkpoint_fingerprint(serialized) != fingerprint:
raise TransferExecutionConflictError("整理执行检查点 JSON 与指纹不一致")
version = serialized.get("schema_version")
if version != TRANSFER_EXECUTION_VERSION:
raise TransferExecutionConflictError("整理执行检查点版本不受支持")
result_payload = serialized.get("payload")
operation_ids = serialized.get("operation_ids")
if not isinstance(result_payload, dict) or not isinstance(operation_ids, list):
raise TransferExecutionConflictError("整理执行检查点结构无效")
if not all(isinstance(item, str) and item for item in operation_ids):
raise TransferExecutionConflictError("整理执行检查点包含无效操作标识")
skip_reason = serialized.get("skip_reason")
if skip_reason is not None and not isinstance(skip_reason, str):
raise TransferExecutionConflictError("整理执行跳过原因类型无效")
return cls(
fingerprint=fingerprint,
payload=result_payload,
operation_ids=tuple(operation_ids),
skip_reason=skip_reason,
version=version,
)
@dataclass(frozen=True, slots=True)
class TransferExecutionSnapshot:
"""提供任务执行 checkpoint、重试和人工状态的稳定投影。"""
task_id: str
state: TransferExecutionState
checkpoint: Optional[TransferExecutionCheckpoint]
retry_generation: int
retry_count: int
retry_due_at: Optional[str]
settlement_revision: int
terminal_history_id: Optional[int]
last_error: Optional[str]
steps: tuple[TransferExecutionStep, ...]
@dataclass(frozen=True, slots=True)
class TransferSettlementIntent:
"""描述已确定结果对应的历史写入与 pending 终态。"""
terminal_state: TransferTerminalState
history_payload: dict[str, Any]
def __post_init__(self) -> None:
"""确保历史 payload 可持久化且包含稳定源身份。"""
object.__setattr__(self, "history_payload", deepcopy(self.history_payload))
if not self.history_payload.get("src"):
raise ValueError("整理终态历史缺少源路径")
_canonical_json(self.history_payload)
@dataclass(frozen=True, slots=True)
class TransferSettlementResult:
"""描述终态历史是否新提交以及 pending 是否已删除。"""
history_id: int
settlement_revision: int
pending_deleted: bool
already_settled: bool = False
@dataclass(frozen=True, slots=True)
class TransferRetryRequestResult:
"""描述用户重试请求是否被持久调度器接受。"""
accepted: bool
state: TransferExecutionState
retry_generation: int
message: str
@dataclass(frozen=True, slots=True)
class TransferManualReviewResult:
"""描述一次已持久审计的人工判定及后续调度状态。"""
task_id: str
operation_id: str
decision: TransferManualReviewDecision
state: TransferExecutionState
review_revision: int
step: TransferExecutionStep
@dataclass(frozen=True, slots=True)
class TransferManualReviewSource:
"""提供人工复核任务的最小源文件身份。"""
storage: str
path: str
@dataclass(frozen=True, slots=True)
class TransferManualReviewStepView:
"""提供人工复核所需且不含租约或尝试令牌的步骤证据。"""
operation_id: str
kind: str
intent: dict[str, Any]
evidence: Optional[dict[str, Any]]
error: Optional[str]
@dataclass(frozen=True, slots=True)
class TransferManualReviewTaskView:
"""提供管理员发现与复核 durable 整理任务的公开投影。"""
task_id: str
source: TransferManualReviewSource
state: TransferExecutionState
step: TransferManualReviewStepView
review_revision: int
@dataclass(frozen=True, slots=True)
class TransferManualReviewPage:
"""提供稳定分页的人工复核任务结果。"""
items: tuple[TransferManualReviewTaskView, ...]
total: int
page: int
page_size: int
class TransferExecutionRepository(Protocol):
"""定义步骤状态、执行 checkpoint 与终态历史的持久化端口。"""
def get_snapshot(self, *, task_id: str) -> Optional[TransferExecutionSnapshot]:
"""读取任务执行快照。"""
def list_manual_reviews(
self,
*,
state: TransferExecutionState,
page: int,
page_size: int,
) -> TransferManualReviewPage:
"""按严格公开状态分页读取人工复核任务。"""
def get_manual_review(
self,
*,
task_id: str,
) -> Optional[TransferManualReviewTaskView]:
"""按任务标识读取人工复核公开详情。"""
def prepare_step(
self,
*,
task_id: str,
lease_token: str,
intent: TransferStepIntent,
) -> TransferExecutionStep:
"""在外部副作用前持久化稳定步骤意图。"""
def start_step(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
) -> TransferExecutionStep:
"""以当前 lease 和新 attempt token 标记步骤开始。"""
def restart_after_not_applied(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
previous_attempt_token: str,
attempt_token: str,
evidence: TransferStepResult,
) -> TransferExecutionStep:
"""严格探测为未发生后,以新 attempt token 安全重启遗留步骤。"""
def resume_failed_step(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
) -> TransferExecutionStep:
"""任务到期并重新 claim 后,以新 attempt token 重试 FAILED 步骤。"""
def complete_step(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
result: TransferStepResult,
) -> TransferExecutionStep:
"""以 lease 和 attempt 双 CAS 提交成功证据。"""
def defer_step(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
error: str,
retry_due_at: str,
evidence: Optional[TransferStepResult] = None,
) -> TransferExecutionSnapshot:
"""持久化已知失败、到期时间并原子释放当前 lease。"""
def exhaust_step(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
error: str,
evidence: Optional[TransferStepResult] = None,
) -> TransferExecutionSnapshot:
"""重试预算耗尽时保留 lease,并建立可 durable 失败结算的检查点。"""
def request_retry(
self,
*,
task_id: str,
reason: str,
requested_by: str,
) -> TransferRetryRequestResult:
"""仅把 FAILED 终态转入到期可 claim 的 retry_wait。"""
def resolve_manual_review(
self,
*,
task_id: str,
operation_id: str,
decision: TransferManualReviewDecision,
actor: str,
reason: str,
result: Optional[TransferStepResult] = None,
) -> TransferManualReviewResult:
"""无 lease 地原子提交人工判定审计并交回唯一调度器。"""
def mark_manual_review(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
error: str,
evidence: Optional[TransferStepResult] = None,
attempt_token: Optional[str] = None,
) -> TransferExecutionSnapshot:
"""持久化不可判定结果并从自动调度中隔离任务。"""
def checkpoint_execution(
self,
*,
task_id: str,
lease_token: str,
checkpoint: TransferExecutionCheckpoint,
) -> TransferExecutionSnapshot:
"""确认所有引用步骤成功后提交聚合执行检查点。"""
class TransferStepRunner(Protocol):
"""定义文件执行方可注入的单步骤持久执行边界。"""
def run(
self,
*,
phase: str,
kind: str,
payload: Mapping[str, Any],
execute: Callable[[], TransferStepResult],
observe: Callable[[], TransferOperationObservation],
) -> TransferStepResult:
"""持久编排一次外部副作用,并在遗留尝试时先严格探测。"""
class TransferExecutionCommand:
"""以类型化命令收口步骤状态机,外部 I/O 由调用方在事务外执行。"""
def __init__(
self,
repository: TransferExecutionRepository,
*,
attempt_token_factory: Callable[[], str] = lambda: uuid4().hex,
) -> None:
"""保存持久化端口和可替换的 attempt token 工厂。"""
self._repository = repository
self._attempt_token_factory = attempt_token_factory
def prepare(
self,
*,
task_id: str,
lease_token: str,
intent: TransferStepIntent,
) -> TransferExecutionStep:
"""在外部调用前提交步骤意图。"""
return self._repository.prepare_step(
task_id=task_id,
lease_token=lease_token,
intent=intent,
)
def begin(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
) -> TransferExecutionStep:
"""生成单次 attempt token 并持久化开始状态。"""
attempt_token = self._attempt_token_factory()
if not attempt_token:
raise ValueError("整理步骤 attempt token 不能为空")
return self._repository.start_step(
task_id=task_id,
lease_token=lease_token,
operation_id=operation_id,
attempt_token=attempt_token,
)
def restart_after_not_applied(
self,
*,
task_id: str,
lease_token: str,
step: TransferExecutionStep,
evidence: TransferStepResult,
) -> TransferExecutionStep:
"""在严格 NOT_APPLIED 证据成立时轮换 attempt token 并继续执行。"""
if step.state is not TransferStepState.STARTED or not step.attempt_token:
raise ValueError("只有遗留 STARTED 步骤可按未发生证据安全重启")
attempt_token = self._attempt_token_factory()
if not attempt_token or attempt_token == step.attempt_token:
raise ValueError("安全重启必须生成不同的 attempt token")
return self._repository.restart_after_not_applied(
task_id=task_id,
lease_token=lease_token,
operation_id=step.operation_id,
previous_attempt_token=step.attempt_token,
attempt_token=attempt_token,
evidence=evidence,
)
def resume_failed(
self,
*,
task_id: str,
lease_token: str,
step: TransferExecutionStep,
) -> TransferExecutionStep:
"""使用重试调度取得的新 lease 为 FAILED 步骤创建下一次尝试。"""
if step.state is not TransferStepState.FAILED or step.attempt_token is not None:
raise ValueError("只有已释放 attempt 的 FAILED 步骤可以恢复重试")
attempt_token = self._attempt_token_factory()
if not attempt_token:
raise ValueError("恢复重试的 attempt token 不能为空")
return self._repository.resume_failed_step(
task_id=task_id,
lease_token=lease_token,
operation_id=step.operation_id,
attempt_token=attempt_token,
)
def complete(
self,
*,
task_id: str,
lease_token: str,
step: TransferExecutionStep,
result: TransferStepResult,
) -> TransferExecutionStep:
"""使用已开始步骤携带的 attempt token 提交成功证据。"""
if not step.attempt_token:
raise ValueError("尚未开始的整理步骤不能提交成功")
return self._repository.complete_step(
task_id=task_id,
lease_token=lease_token,
operation_id=step.operation_id,
attempt_token=step.attempt_token,
result=result,
)
def defer(
self,
*,
task_id: str,
lease_token: str,
step: TransferExecutionStep,
error: str,
retry_due_at: str,
evidence: Optional[TransferStepResult] = None,
) -> TransferExecutionSnapshot:
"""记录已知失败并把唯一重试权交回持久调度器。"""
if not step.attempt_token:
raise ValueError("尚未开始的整理步骤不能进入重试等待")
return self._repository.defer_step(
task_id=task_id,
lease_token=lease_token,
operation_id=step.operation_id,
attempt_token=step.attempt_token,
error=error,
retry_due_at=retry_due_at,
evidence=evidence,
)
def exhaust(
self,
*,
task_id: str,
lease_token: str,
step: TransferExecutionStep,
error: str,
evidence: Optional[TransferStepResult] = None,
) -> TransferExecutionSnapshot:
"""提交达到预算的确定失败,并把任务交给唯一 durable 终态 writer。"""
if step.state is not TransferStepState.STARTED or not step.attempt_token:
raise ValueError("只有 STARTED 步骤可提交预算耗尽失败")
return self._repository.exhaust_step(
task_id=task_id,
lease_token=lease_token,
operation_id=step.operation_id,
attempt_token=step.attempt_token,
error=error,
evidence=evidence,
)
def request_retry(
self,
*,
task_id: str,
reason: str,
requested_by: str,
) -> TransferRetryRequestResult:
"""登记用户重试意图,不直接 claim 或执行任务。"""
if not task_id or not reason or not requested_by:
raise ValueError("用户重试请求缺少任务、原因或请求身份")
return self._repository.request_retry(
task_id=task_id,
reason=reason,
requested_by=requested_by,
)
def resolve_manual_review(
self,
*,
task_id: str,
operation_id: str,
decision: TransferManualReviewDecision,
actor: str,
reason: str,
result: Optional[TransferStepResult] = None,
) -> TransferManualReviewResult:
"""提交人工判定;FAILED 在无 lease durable 结算落地前明确拒绝。"""
if not all((task_id, operation_id, actor, reason)):
raise ValueError("人工判定缺少任务、步骤、操作者或原因")
if decision is TransferManualReviewDecision.APPLIED and result is None:
raise ValueError("人工判定已发生时必须提供结果证据")
if decision is TransferManualReviewDecision.FAILED:
raise TransferExecutionConflictError(
"人工失败终态尚不能绕过 lease durable 结算"
)
return self._repository.resolve_manual_review(
task_id=task_id,
operation_id=operation_id,
decision=decision,
actor=actor,
reason=reason,
result=result,
)
def manual_review(
self,
*,
task_id: str,
lease_token: str,
step: TransferExecutionStep,
error: str,
evidence: Optional[TransferStepResult] = None,
) -> TransferExecutionSnapshot:
"""隔离无法严格判断外部结果的步骤,禁止自动再次执行。"""
return self._repository.mark_manual_review(
task_id=task_id,
lease_token=lease_token,
operation_id=step.operation_id,
attempt_token=step.attempt_token,
error=error,
evidence=evidence,
)
def checkpoint(
self,
*,
task_id: str,
lease_token: str,
checkpoint: TransferExecutionCheckpoint,
) -> TransferExecutionSnapshot:
"""提交可独立重放终态结算的聚合执行结果。"""
return self._repository.checkpoint_execution(
task_id=task_id,
lease_token=lease_token,
checkpoint=checkpoint,
)
class TransferManualReviewQuery:
"""收口管理员可发现的人工复核只读用例。"""
_visible_states = frozenset({
TransferExecutionState.MANUAL_REVIEW,
TransferExecutionState.RETRY_WAIT,
})
def __init__(self, repository: TransferExecutionRepository) -> None:
"""保存整理执行查询端口。"""
self._repository = repository
@classmethod
def _validate_state(cls, state: TransferExecutionState) -> None:
"""拒绝把内部执行状态扩展到人工复核查询面。"""
if state not in cls._visible_states:
raise ValueError(f"人工复核查询不支持状态:{state.value}")
def list(
self,
*,
state: TransferExecutionState = TransferExecutionState.MANUAL_REVIEW,
page: int = 1,
page_size: int = 30,
) -> TransferManualReviewPage:
"""分页返回待复核或刚完成复核并等待恢复的任务。"""
self._validate_state(state)
if page < 1 or not 1 <= page_size <= 100:
raise ValueError("人工复核分页参数超出允许范围")
return self._repository.list_manual_reviews(
state=state,
page=page,
page_size=page_size,
)
def get(self, *, task_id: str) -> Optional[TransferManualReviewTaskView]:
"""读取一条人工复核详情,内部状态任务按不存在处理。"""
if not task_id:
raise ValueError("人工复核详情缺少任务标识")
result = self._repository.get_manual_review(task_id=task_id)
if result is not None:
self._validate_state(result.state)
return result
__all__ = [
"TRANSFER_EXECUTION_VERSION",
"TRANSFER_STEP_INTENT_VERSION",
"TRANSFER_STEP_RESULT_VERSION",
"TransferExecutionCheckpoint",
"TransferExecutionCommand",
"TransferExecutionConflictError",
"TransferExecutionError",
"TransferExecutionLeaseLostError",
"TransferOperationObservation",
"TransferOperationObservationState",
"TransferRetryRequestResult",
"TransferExecutionRepository",
"TransferExecutionSnapshot",
"TransferExecutionState",
"TransferExecutionStep",
"TransferManualReviewDecision",
"TransferManualReviewPage",
"TransferManualReviewQuery",
"TransferManualReviewResult",
"TransferManualReviewSource",
"TransferManualReviewStepView",
"TransferManualReviewTaskView",
"TransferSettlementIntent",
"TransferSettlementResult",
"TransferStepIntent",
"TransferStepResult",
"TransferStepRunner",
"TransferStepState",
"TransferTerminalState",
"build_transfer_checkpoint_fingerprint",
"build_transfer_operation_id",
]