Files
MoviePilot/app/db/adapters/transfer/execution.py

2143 lines
94 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.
"""
整理步骤执行与终态结算端口的 SQLAlchemy 短事务适配器。
"""
from __future__ import annotations
from collections.abc import Callable
from contextlib import AbstractAsyncContextManager
from dataclasses import asdict, replace
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from uuid import uuid4
from sqlalchemy import and_, func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session, load_only
from app.application.history.retry import max_failed_retries
from app.application.transfer.execution import (
TransferExecutionCheckpoint,
TransferExecutionConflictError,
TransferExecutionLeaseLostError,
TransferExecutionSnapshot,
TransferExecutionState,
TransferExecutionStep,
TransferFailureDiscardResult,
TransferManualReviewDecision,
TransferManualReviewDiscardResult,
TransferManualReviewPage,
TransferManualReviewResult,
TransferManualReviewSource,
TransferManualReviewStepView,
TransferManualReviewTaskView,
TransferRetryRequestResult,
TransferStepIntent,
TransferStepResult,
TransferStepState,
build_transfer_operation_id,
build_transfer_overwrite_paths,
)
from app.application.transfer.feedback import TransferFailureStage, classify_transfer_failure
from app.application.transfer.models import JSONValue
from app.application.transfer.recovery import music_replanning_input
from app.application.transfer.workflow import (
TRANSFER_ADMISSION_ACCEPTED,
TRANSFER_ADMISSION_PLANNED,
TRANSFER_ADMISSION_PROVIDER_PENDING,
TRANSFER_PLAN_CHECKPOINT_LEGACY_VERSION,
TRANSFER_PLAN_CHECKPOINT_VERSION,
TransferPlanCheckpoint,
TransferPlanItem,
TransferPlanningInput,
TransferProviderInvocationSnapshot,
TransferProviderReference,
)
from app.db.models.transferexecutionstep import (
TransferExecutionStep as TransferExecutionStepModel,
)
from app.db.models.transferhistory import TransferHistory
from app.db.models.transferpending import TransferPending
from app.db.oper.transferexecutionstep import TransferExecutionStepOper
from app.db.oper.transferhistory import TransferHistoryOper
from app.db.oper.transferpending import TransferPendingOper
from app.db.uow import SqlAlchemyUnitOfWork
_LEGACY_REVIEW_STEP_KIND = "legacy_execution_review"
_UNHANDLED_REVIEW_STEP_KIND = "unhandled_execution_review"
_REVIEW_STEP_KINDS = {_LEGACY_REVIEW_STEP_KIND, _UNHANDLED_REVIEW_STEP_KIND}
# 与迁移保留的最大序号相邻,复核凭据不占用从零连续递增的真实执行序号。
_UNHANDLED_REVIEW_STEP_ORDINAL = 2_147_483_646
def _local_now() -> datetime:
"""
返回业务审计字段使用的宿主本地时间。
"""
return datetime.now()
def _utc_now() -> datetime:
"""
返回租约 fencing 使用的 UTC 时间。
"""
return datetime.now(timezone.utc)
class TransactionalTransferExecutionRepository:
"""
以操作级短 Session 实现整理执行持久化端口。
"""
def __init__(
self,
session_factory: Callable[[], Session],
*,
async_session: Optional[
Callable[[], AbstractAsyncContextManager[AsyncSession]]
] = None,
local_clock: Callable[[], datetime] = _local_now,
lease_clock: Callable[[], datetime] = _utc_now,
) -> None:
"""
保存 Session 工厂及可测试时钟,不持有跨外部 I/O 事务。
"""
self._session_factory = session_factory
self._async_session = async_session
self._local_clock = local_clock
self._lease_clock = lease_clock
@staticmethod
def _format_local(value: datetime) -> str:
"""
编码与既有业务审计列一致的本地时间。
"""
return value.strftime("%Y-%m-%d %H:%M:%S")
@staticmethod
def _format_utc(value: datetime) -> str:
"""
编码可按字典序比较的固定宽度 UTC 租约时间。
"""
return value.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
@staticmethod
def _execution_steps(
steps: list[TransferExecutionStepModel],
) -> list[TransferExecutionStepModel]:
"""
排除只用于人工审计、不得参与真实执行的 synthetic 步骤。
"""
return [step for step in steps if step.kind not in _REVIEW_STEP_KINDS]
def isolate_unhandled_failure(
self, *, task_id: str, lease_token: str, error: str,
) -> bool:
"""
原子隔离未记账异常并保存可发现的复核凭据,保留所有真实步骤证据。
"""
now_utc, updated_at = self._times()
with self._session_factory() as session:
transaction = SqlAlchemyUnitOfWork(session)
try:
pending_oper = TransferPendingOper(session)
updated = pending_oper.stage_isolate_unhandled_failure(
task_id=task_id, lease_token=lease_token, error=error,
now_utc=now_utc, updated_at=updated_at,
)
if updated != 1:
transaction.commit()
return False
session.expire_all()
pending = pending_oper.get_by_task_id(task_id=task_id)
if pending is None:
raise TransferExecutionConflictError("异常隔离后未找到整理任务")
self._stage_unhandled_review(session, pending=pending, error=error, updated_at=updated_at)
transaction.commit()
return True
except Exception:
self._rollback(transaction)
raise
@staticmethod
def _stage_unhandled_review(
session: Session, *, pending: TransferPending, error: str, updated_at: str,
) -> None:
"""
创建或更新独立复核凭据;同一任务再次异常时复用凭据并保留复核审计。
"""
oper = TransferExecutionStepOper(session)
step = next((item for item in oper.list_by_task_id(task_id=pending.task_id)
if item.kind == _UNHANDLED_REVIEW_STEP_KIND), None)
if step is None:
intent = TransferStepIntent.create(
task_id=pending.task_id,
checkpoint_fingerprint=pending.input_fingerprint,
ordinal=_UNHANDLED_REVIEW_STEP_ORDINAL,
phase="review", kind=_UNHANDLED_REVIEW_STEP_KIND,
payload={"source": {"storage": pending.storage, "path": pending.src_path}},
)
step = oper.stage_prepare(
task_id=pending.task_id, operation_id=intent.operation_id,
checkpoint_fingerprint=intent.checkpoint_fingerprint,
ordinal=intent.ordinal, phase=intent.phase, kind=intent.kind,
intent_version=intent.version, intent_payload=dict(intent.payload), now_time=updated_at,
)
step.state = TransferStepState.MANUAL_REVIEW.value
step.last_error = error
step.updated_at = updated_at
@staticmethod
def _project_step(step: TransferExecutionStepModel) -> TransferExecutionStep:
"""
在 Session 有效期内冻结 ORM 步骤为 Application DTO。
"""
if (step.result_version is None) != (step.result_payload is None):
raise TransferExecutionConflictError("整理步骤记录不完整,请重新识别文件后再整理")
result = (
TransferStepResult(
version=step.result_version,
payload=dict(step.result_payload),
)
if step.result_version is not None and step.result_payload is not None
else None
)
intent = TransferStepIntent(
operation_id=step.operation_id,
checkpoint_fingerprint=step.checkpoint_fingerprint,
ordinal=step.ordinal,
phase=step.phase,
kind=step.kind,
payload=dict(step.intent_payload),
version=step.intent_version,
)
return TransferExecutionStep(
task_id=step.task_id,
operation_id=step.operation_id,
checkpoint_fingerprint=step.checkpoint_fingerprint,
ordinal=step.ordinal,
phase=step.phase,
kind=step.kind,
state=TransferStepState(step.state),
attempt_token=step.attempt_token,
attempt_count=step.attempt_count,
intent=intent,
result=result,
last_error=step.last_error,
prepared_at=step.prepared_at,
started_at=step.started_at,
completed_at=step.completed_at,
updated_at=step.updated_at,
)
@classmethod
def _project_snapshot(
cls,
pending: TransferPending,
steps: list[TransferExecutionStepModel],
) -> TransferExecutionSnapshot:
"""
在 Session 有效期内冻结 pending 与步骤聚合状态。
"""
has_checkpoint_column = pending.execution_payload is not None
has_checkpoint_identity = (
pending.execution_version is not None
and pending.execution_fingerprint is not None
)
if has_checkpoint_column != has_checkpoint_identity:
raise TransferExecutionConflictError("整理任务执行检查点字段不一致,请重新识别文件后再整理")
checkpoint = None
if pending.execution_payload is not None:
checkpoint = TransferExecutionCheckpoint.from_payload(
pending.execution_payload,
fingerprint=pending.execution_fingerprint or "",
)
if checkpoint.version != pending.execution_version:
raise TransferExecutionConflictError("整理任务记录版本不一致,请重新识别文件后再整理")
return TransferExecutionSnapshot(
task_id=pending.task_id,
state=TransferExecutionState(pending.execution_state),
checkpoint=checkpoint,
retry_generation=pending.retry_generation,
retry_count=pending.retry_count,
retry_due_at=pending.retry_due_at,
settlement_revision=pending.settlement_revision,
terminal_history_id=pending.terminal_history_id,
last_error=pending.last_error,
steps=tuple(
cls._project_step(step)
for step in cls._execution_steps(steps)
),
)
@classmethod
def _project_manual_review(
cls,
pending: TransferPending,
steps: list[TransferExecutionStepModel],
) -> TransferManualReviewTaskView:
"""
冻结人工复核详情,并只选择当前或最近一次复核步骤。
"""
state = TransferExecutionState(pending.execution_state)
if state is TransferExecutionState.MANUAL_REVIEW:
candidates = [
step
for step in steps
if step.state == TransferStepState.MANUAL_REVIEW.value
]
elif (
state is TransferExecutionState.RETRY_WAIT
and pending.manual_review_revision > 0
):
candidates = [
step
for step in steps
if step.state in {
TransferStepState.FAILED.value,
TransferStepState.SUCCEEDED.value,
}
and step.updated_at == pending.reviewed_at
]
else:
raise TransferExecutionConflictError("该整理任务当前不需要人工复核")
if not candidates:
raise TransferExecutionConflictError("人工复核信息不完整,请重新打开整理记录")
step = max(candidates, key=lambda item: (item.ordinal, item.id))
evidence = dict(step.result_payload) if step.result_payload is not None else None
return TransferManualReviewTaskView(
task_id=pending.task_id,
source=TransferManualReviewSource(
storage=pending.storage,
path=pending.src_path,
),
state=state,
step=TransferManualReviewStepView(
operation_id=step.operation_id,
kind=step.kind,
intent=dict(step.intent_payload),
evidence=evidence,
error=step.last_error,
),
review_revision=pending.manual_review_revision,
)
@staticmethod
def _intent_matches(
step: TransferExecutionStepModel,
*,
task_id: str,
intent: TransferStepIntent,
) -> bool:
"""
判断同 operation ID 的既有步骤是否为完全相同的冻结意图。
"""
return all((
step.task_id == task_id,
step.checkpoint_fingerprint == intent.checkpoint_fingerprint,
step.ordinal == intent.ordinal,
step.phase == intent.phase,
step.kind == intent.kind,
step.intent_version == intent.version,
step.intent_payload == intent.payload,
))
@staticmethod
def _plan_identity(
pending: TransferPending,
) -> tuple[TransferPlanCheckpoint, str]:
"""
恢复完整冻结计划,并返回其规范 payload 的稳定指纹。
"""
if pending.state not in {
TRANSFER_ADMISSION_PLANNED,
TRANSFER_ADMISSION_PROVIDER_PENDING,
}:
raise TransferExecutionConflictError("整理任务尚未准备完成,请稍后再试")
if (
pending.checkpoint_version is None
or pending.checkpoint_payload is None
or pending.planned_at is None
):
raise TransferExecutionConflictError("整理任务计划检查点字段不完整,请重新识别文件后再整理")
try:
checkpoint = TransferPlanCheckpoint.from_payload(
pending.checkpoint_payload
)
except (TypeError, ValueError) as error:
raise TransferExecutionConflictError(
"整理任务记录已损坏,请重新识别文件后再整理"
) from error
if pending.checkpoint_version != checkpoint.schema_version:
raise TransferExecutionConflictError("整理任务记录版本不一致,请重新识别文件后再整理")
if checkpoint.planning_input.fingerprint != pending.input_fingerprint:
raise TransferExecutionConflictError("整理任务状态已发生变化,请刷新整理历史后再试")
expected_state = (
TRANSFER_ADMISSION_PROVIDER_PENDING
if checkpoint.is_provider_pending
else TRANSFER_ADMISSION_PLANNED
)
if pending.state != expected_state:
raise TransferExecutionConflictError("整理任务状态已发生变化,请刷新整理历史后再试")
return checkpoint, checkpoint.fingerprint
@staticmethod
def _provider_predecessor_checkpoint(
checkpoint: TransferPlanCheckpoint,
step: TransferExecutionStepModel,
) -> Optional[TransferPlanCheckpoint]:
"""
重建 provider 回退前的冻结计划,证明序号零步骤的历史归属。
"""
if (
not checkpoint.pre_execution_cleanup_completed
or step.ordinal != 0
or step.phase != "provider"
or step.kind != "legacy_transfer_provider_sequence"
):
return None
providers_payload = step.intent_payload.get("providers")
invocation_payload = step.intent_payload.get("invocation")
if (
not isinstance(providers_payload, list)
or not providers_payload
or not all(isinstance(item, dict) for item in providers_payload)
or not isinstance(invocation_payload, dict)
):
return None
try:
providers = tuple(
TransferProviderReference.from_payload(item)
for item in providers_payload
)
invocation = TransferProviderInvocationSnapshot.from_payload(
invocation_payload
)
if invocation.fileitem != checkpoint.planning_input.source_fileitem:
return None
for schema_version in (
TRANSFER_PLAN_CHECKPOINT_VERSION,
TRANSFER_PLAN_CHECKPOINT_LEGACY_VERSION,
):
predecessor = TransferPlanCheckpoint(
planning_input=checkpoint.planning_input,
target_storage="",
root_target_path="",
final_target_path="",
resolved_transfer_type="",
items=(),
classification_snapshot=checkpoint.classification_snapshot,
resolved_meta=invocation.meta,
resolved_meta_kind=invocation.meta_kind,
resolved_mediainfo=invocation.mediainfo,
resolved_mediainfo_kind=invocation.mediainfo_kind,
resolved_episodes_info=invocation.episodes_info,
legacy_transfer_providers=providers,
provider_invocation=invocation,
preview=invocation.preview,
schema_version=schema_version,
)
if predecessor.fingerprint == step.checkpoint_fingerprint:
return predecessor
except (TypeError, ValueError):
return None
return None
@staticmethod
def _safe_overwrite_intents(
checkpoint: TransferPlanCheckpoint,
item: TransferPlanItem,
) -> tuple[tuple[str, str, dict[str, Any]], ...]:
"""
仅从冻结叶计划推导安全覆盖的完整步骤及确定路径。
"""
stage, backup = build_transfer_overwrite_paths(
source=item.source_fileitem, target_storage=item.target_storage,
target_path=item.target_path, transfer_type=checkpoint.resolved_transfer_type,
)
intents: list[tuple[str, str, dict[str, Any]]] = [
("transfer", "stage_overwrite_target", {
"source": item.source_fileitem, "target_storage": item.target_storage,
"target_path": stage, "transfer_type": (
"copy" if checkpoint.resolved_transfer_type == "move"
else checkpoint.resolved_transfer_type
),
}),
("transfer", "backup_overwrite_target", {
"storage": item.target_storage, "source_path": item.target_path,
"target_path": backup,
}),
("transfer", "promote_overwrite_target", {
"storage": item.target_storage, "source_path": stage,
"target_path": item.target_path,
}),
]
if checkpoint.resolved_transfer_type == "move":
intents.append(("cleanup", "delete_overwrite_source", {
"storage": item.source_fileitem["storage"], "path": item.source_fileitem["path"],
}))
intents.append(("cleanup", "delete_overwrite_backup", {
"storage": item.target_storage, "path": backup,
}))
return tuple(intents)
@staticmethod
def _overwrite_decision_succeeded(
item: TransferPlanItem,
previous_steps: list[TransferExecutionStepModel],
) -> bool:
"""
暂存只能在同一源和最终目标的覆盖裁决明确成功后开始。
"""
return any(
step.phase == "decision" and step.kind in {"resolve_overwrite", "resolve_leaf_overwrite"}
and step.state == TransferStepState.SUCCEEDED.value
and step.intent_payload.get("source") == item.source_fileitem
and step.intent_payload.get("target_storage") == item.target_storage
and step.intent_payload.get("target_path") == item.target_path
and isinstance(step.result_payload, dict)
and step.result_payload.get("over_flag") is True
for step in previous_steps
)
@classmethod
def _safe_overwrite_intent_belongs_to_checkpoint(
cls,
checkpoint: TransferPlanCheckpoint,
*,
phase: str,
kind: str,
payload: dict[str, Any],
previous_steps: list[TransferExecutionStepModel],
) -> bool:
"""
安全覆盖步骤必须精确匹配冻结计划且其前驱已经持久成功。
"""
if phase == "decision" and kind == "resolve_leaf_overwrite":
return checkpoint.planning_input.source_fileitem.get("type") == "dir" and any(
payload == {"source": item.source_fileitem, "target_storage": item.target_storage,
"target_path": item.target_path, "transfer_type": checkpoint.resolved_transfer_type}
for item in checkpoint.items
)
requested = (phase, kind, payload)
for item in checkpoint.items:
intents = cls._safe_overwrite_intents(checkpoint, item)
if requested not in intents:
continue
index = intents.index(requested)
if index == 0:
return cls._overwrite_decision_succeeded(item, previous_steps)
previous_phase, previous_kind, previous_payload = intents[index - 1]
return any(
step.phase == previous_phase and step.kind == previous_kind
and step.intent_payload == previous_payload
and step.state == TransferStepState.SUCCEEDED.value
for step in previous_steps
)
return False
@staticmethod
def _version_delete_intent_belongs_to_checkpoint(
checkpoint: TransferPlanCheckpoint,
payload: dict[str, Any],
previous_steps: list[TransferExecutionStepModel],
) -> bool:
"""
版本删除仅消费已持久成功的发现清单,不能自由指定删除路径。
"""
candidate = payload.get("item")
if (set(payload) != {"storage", "item"}
or payload.get("storage") != checkpoint.target_storage
or not isinstance(candidate, dict)):
return False
return any(
step.phase == "prepare" and step.kind == "discover_version_targets"
and step.state == TransferStepState.SUCCEEDED.value
and isinstance(step.result_payload, dict)
and candidate in step.result_payload.get("items", [])
for step in previous_steps
)
@classmethod
def _intent_belongs_to_checkpoint(
cls,
checkpoint: TransferPlanCheckpoint,
*,
phase: str,
kind: str,
payload: dict[str, Any],
previous_steps: list[TransferExecutionStepModel],
) -> bool:
"""
校验步骤类型及稳定参数由冻结计划或其已提交发现证据导出。
"""
if kind in {
"stage_overwrite_target", "backup_overwrite_target", "promote_overwrite_target",
"delete_overwrite_source", "delete_overwrite_backup", "resolve_leaf_overwrite",
}:
return cls._safe_overwrite_intent_belongs_to_checkpoint(
checkpoint, phase=phase, kind=kind, payload=payload, previous_steps=previous_steps,
)
if checkpoint.rejection_error:
return (
phase == "planning"
and kind == "reject"
and payload == {"error": checkpoint.rejection_error}
)
if checkpoint.is_provider_pending:
invocation = checkpoint.provider_invocation
if invocation is None:
return False
return (
phase == "provider"
and kind == "legacy_transfer_provider_sequence"
and payload == {
"providers": [
provider.to_payload()
for provider in checkpoint.legacy_transfer_providers
],
"invocation": invocation.to_payload(),
}
)
plan_items = tuple(checkpoint.items)
if phase == "transfer" and kind == checkpoint.resolved_transfer_type:
return any(payload == {
"source": item.source_fileitem.get("path"),
"target": item.target_path,
} for item in plan_items)
if phase == "transfer" and kind == "materialize_target":
return any(payload == {
"source": item.source_fileitem,
"target_storage": item.target_storage,
"target_path": item.target_path,
"transfer_type": (
"copy"
if (
checkpoint.resolved_transfer_type == "move"
and item.source_fileitem.get("storage")
!= item.target_storage
)
else checkpoint.resolved_transfer_type
),
} for item in plan_items)
if phase == "transfer" and kind == "delete_move_source":
return (
checkpoint.resolved_transfer_type == "move"
and any(
item.source_fileitem.get("storage") != item.target_storage
and payload == {
"source": item.source_fileitem,
"target_storage": item.target_storage,
"target_path": item.target_path,
}
for item in plan_items
)
)
source = checkpoint.planning_input.source_fileitem
if phase == "prepare" and kind == "cleanup_previous_destination":
return payload == {"source_path": source.get("path")}
if phase == "prepare" and kind == "ensure_target_directory":
target_path = Path(checkpoint.final_target_path)
directory_path = (
target_path
if source.get("type") == "dir"
else target_path.parent
)
return payload == {
"storage": checkpoint.target_storage,
"path": directory_path.as_posix(),
}
if phase == "prepare" and kind == "delete_overwrite_target":
return payload == {
"storage": checkpoint.target_storage,
"path": checkpoint.final_target_path,
}
if phase == "decision" and kind == "resolve_overwrite":
return payload == {
"source": source,
"target_storage": checkpoint.target_storage,
"target_path": checkpoint.final_target_path,
"transfer_type": checkpoint.resolved_transfer_type,
"overwrite_mode": checkpoint.overwrite_mode,
"need_notify": checkpoint.need_notify,
}
if phase == "decision" and kind == "plugin_transfer_intercept":
stable_payload = {
"source": source,
"target_storage": checkpoint.target_storage,
"target_path": checkpoint.final_target_path,
"transfer_type": checkpoint.resolved_transfer_type,
}
return payload == stable_payload or (
set(payload) == {*stable_payload, "over_flag"}
and all(payload[key] == value for key, value in stable_payload.items())
and isinstance(payload.get("over_flag"), bool)
)
if phase == "prepare" and kind == "discover_version_targets":
return payload == {
"storage": checkpoint.target_storage,
"path": checkpoint.final_target_path,
}
if phase == "prepare" and kind == "delete_version_target":
return cls._version_delete_intent_belongs_to_checkpoint(
checkpoint, payload, previous_steps,
)
return False
@classmethod
def _validate_plan_steps(
cls,
*,
task_id: str,
checkpoint: TransferPlanCheckpoint,
checkpoint_fingerprint: str,
steps: list[TransferExecutionStepModel],
) -> None:
"""
验证全部持久步骤属于冻结计划演进且身份与全局顺序未被篡改。
"""
if tuple(step.ordinal for step in steps) != tuple(range(len(steps))):
raise TransferExecutionConflictError("整理任务正在被其他操作处理,请稍后再试")
for index, step in enumerate(steps):
if step.task_id != task_id:
raise TransferExecutionConflictError("整理任务状态已发生变化,请刷新整理历史后再试")
step_checkpoint = checkpoint
if step.checkpoint_fingerprint != checkpoint_fingerprint:
predecessor = cls._provider_predecessor_checkpoint(
checkpoint,
step,
)
if (
predecessor is None
or step.checkpoint_fingerprint != predecessor.fingerprint
):
raise TransferExecutionConflictError(
"整理任务记录已失效,请重新识别文件后再整理"
)
step_checkpoint = predecessor
if not cls._intent_belongs_to_checkpoint(
step_checkpoint,
phase=step.phase,
kind=step.kind,
payload=step.intent_payload,
previous_steps=steps[:index],
):
raise TransferExecutionConflictError(
"整理任务记录已失效,请重新识别文件后再整理"
)
expected_operation_id = build_transfer_operation_id(
task_id=task_id,
checkpoint_fingerprint=step.checkpoint_fingerprint,
ordinal=step.ordinal,
phase=step.phase,
kind=step.kind,
intent_payload=step.intent_payload,
)
if step.operation_id != expected_operation_id:
raise TransferExecutionConflictError("整理任务记录已失效,请重新识别文件后再整理")
@classmethod
def _validate_new_intent(
cls,
*,
task_id: str,
checkpoint: TransferPlanCheckpoint,
checkpoint_fingerprint: str,
steps: list[TransferExecutionStepModel],
intent: TransferStepIntent,
) -> None:
"""
验证待准备意图属于当前冻结计划,并且只追加或幂等重放既有序号。
"""
cls._validate_plan_steps(
task_id=task_id,
checkpoint=checkpoint,
checkpoint_fingerprint=checkpoint_fingerprint,
steps=steps,
)
if intent.checkpoint_fingerprint != checkpoint_fingerprint:
raise TransferExecutionConflictError("整理任务记录已失效,请重新识别文件后再整理")
if not cls._intent_belongs_to_checkpoint(
checkpoint,
phase=intent.phase,
kind=intent.kind,
payload=intent.payload,
previous_steps=steps,
):
raise TransferExecutionConflictError(
"整理任务记录已失效,请重新识别文件后再整理"
)
expected_operation_id = build_transfer_operation_id(
task_id=task_id,
checkpoint_fingerprint=intent.checkpoint_fingerprint,
ordinal=intent.ordinal,
phase=intent.phase,
kind=intent.kind,
intent_payload=intent.payload,
)
if intent.operation_id != expected_operation_id:
raise TransferExecutionConflictError("整理任务记录已失效,请重新识别文件后再整理")
existing = next(
(step for step in steps if step.operation_id == intent.operation_id),
None,
)
if existing is not None:
if not cls._intent_matches(existing, task_id=task_id, intent=intent):
raise TransferExecutionConflictError(
"整理任务正在被其他操作处理,请稍后再试"
)
return
if intent.ordinal != len(steps):
raise TransferExecutionConflictError("整理任务记录已失效,请重新识别文件后再整理")
@staticmethod
def _require_active_lease(
pending: Optional[TransferPending],
*,
lease_token: str,
now_utc: str,
) -> TransferPending:
"""
返回仍由调用方持有的任务,并优先报告 lease fencing 失败。
"""
if (
pending is None
or pending.lease_token != lease_token
or pending.lease_expires_at is None
or pending.lease_expires_at <= now_utc
):
raise TransferExecutionLeaseLostError("整理任务租约已失效或被接管")
return pending
@classmethod
def _raise_fenced_failure(
cls,
pending: Optional[TransferPending],
*,
lease_token: str,
now_utc: str,
detail: str,
) -> None:
"""
区分租约丢失与同租约内的状态或 attempt 冲突。
"""
cls._require_active_lease(
pending,
lease_token=lease_token,
now_utc=now_utc,
)
raise TransferExecutionConflictError(detail)
def _times(self) -> tuple[str, str]:
"""
生成一次事务内共享的 UTC fencing 与本地审计时间。
"""
return (
self._format_utc(self._lease_clock()),
self._format_local(self._local_clock()),
)
@staticmethod
def _rollback(transaction: SqlAlchemyUnitOfWork) -> None:
"""
统一回滚失败的操作级事务。
"""
transaction.rollback()
def get_snapshot(self, *, task_id: str) -> Optional[TransferExecutionSnapshot]:
"""
使用一次只读短 Session 获取任务执行快照。
"""
with self._session_factory() as session:
pending = TransferPendingOper(session).get_by_task_id(task_id=task_id)
if pending is None:
return None
steps = TransferExecutionStepOper(session).list_by_task_id(task_id=task_id)
return self._project_snapshot(pending, steps)
async def async_get_snapshot(
self,
*,
task_id: str,
) -> Optional[TransferExecutionSnapshot]:
"""
使用异步只读短 Session 获取任务执行快照。
"""
if self._async_session is None:
raise RuntimeError("整理执行仓储未配置异步 Session 工厂")
async with self._async_session() as session:
pending = (
await session.scalars(
select(TransferPending).where(TransferPending.task_id == task_id)
)
).first()
if pending is None:
return None
steps = list(
(
await session.scalars(
select(TransferExecutionStepModel)
.where(TransferExecutionStepModel.task_id == task_id)
.order_by(TransferExecutionStepModel.ordinal.asc())
)
).all()
)
return self._project_snapshot(pending, steps)
def list_manual_reviews(
self,
*,
state: TransferExecutionState,
page: int,
page_size: int,
) -> TransferManualReviewPage:
"""
在数据库内过滤和分页人工复核任务,再冻结步骤证据。
"""
if state not in {
TransferExecutionState.MANUAL_REVIEW,
TransferExecutionState.RETRY_WAIT,
}:
raise ValueError(f"人工复核查询不支持状态:{state.value}")
state_predicate = TransferPending.execution_state == state.value
if state is TransferExecutionState.RETRY_WAIT:
state_predicate = and_(
state_predicate,
TransferPending.manual_review_revision > 0,
)
with self._session_factory() as session:
total = int(session.scalar(
select(func.count()).select_from(TransferPending).where(state_predicate)
) or 0)
pendings = list(session.scalars(
select(TransferPending)
.where(state_predicate)
.order_by(TransferPending.updated_at.desc(), TransferPending.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
).all())
step_oper = TransferExecutionStepOper(session)
items = tuple(
self._project_manual_review(
pending,
step_oper.list_by_task_id(task_id=pending.task_id),
)
for pending in pendings
)
return TransferManualReviewPage(
items=items,
total=total,
page=page,
page_size=page_size,
)
def get_manual_review(
self,
*,
task_id: str,
) -> Optional[TransferManualReviewTaskView]:
"""
读取待人工复核或已判定等待恢复的单个任务。
"""
with self._session_factory() as session:
pending = session.scalar(
select(TransferPending).where(
TransferPending.task_id == task_id,
or_(
TransferPending.execution_state
== TransferExecutionState.MANUAL_REVIEW.value,
and_(
TransferPending.execution_state
== TransferExecutionState.RETRY_WAIT.value,
TransferPending.manual_review_revision > 0,
),
),
)
)
if pending is None:
return None
return self._project_manual_review(
pending,
TransferExecutionStepOper(session).list_by_task_id(task_id=task_id),
)
def prepare_step(
self,
*,
task_id: str,
lease_token: str,
intent: TransferStepIntent,
) -> TransferExecutionStep:
"""
在外部副作用前以有效 lease 幂等提交稳定步骤意图。
"""
now_utc, updated_at = self._times()
with self._session_factory() as session:
transaction = SqlAlchemyUnitOfWork(session)
try:
pending_oper = TransferPendingOper(session)
pending = pending_oper.get_by_task_id(task_id=task_id)
pending = self._require_active_lease(
pending,
lease_token=lease_token,
now_utc=now_utc,
)
checkpoint, checkpoint_fingerprint = self._plan_identity(pending)
oper = TransferExecutionStepOper(session)
steps = self._execution_steps(
oper.list_by_task_id(task_id=task_id)
)
self._validate_new_intent(
task_id=task_id,
checkpoint=checkpoint,
checkpoint_fingerprint=checkpoint_fingerprint,
steps=steps,
intent=intent,
)
updated = pending_oper.stage_execution_running(
task_id=task_id,
lease_token=lease_token,
admission_state=pending.state,
checkpoint_version=checkpoint.schema_version,
checkpoint_payload=checkpoint.to_payload(),
now_utc=now_utc,
updated_at=updated_at,
)
if updated != 1:
self._raise_fenced_failure(
pending,
lease_token=lease_token,
now_utc=now_utc,
detail="整理任务当前状态不能准备外部步骤",
)
step = oper.get_by_operation_id(operation_id=intent.operation_id)
if step is None:
step = oper.stage_prepare(
task_id=task_id,
operation_id=intent.operation_id,
checkpoint_fingerprint=intent.checkpoint_fingerprint,
ordinal=intent.ordinal,
phase=intent.phase,
kind=intent.kind,
intent_version=intent.version,
intent_payload=dict(intent.payload),
now_time=updated_at,
)
session.flush()
elif not self._intent_matches(step, task_id=task_id, intent=intent):
raise TransferExecutionConflictError(
"整理任务正在被其他操作处理,请稍后再试"
)
projected = self._project_step(step)
transaction.commit()
return projected
except IntegrityError as error:
self._rollback(transaction)
raise TransferExecutionConflictError(
"整理任务正在被其他操作处理,请稍后再试"
) from error
except Exception:
self._rollback(transaction)
raise
def start_step(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
) -> TransferExecutionStep:
"""
以当前 lease 把 PREPARED 步骤推进为 STARTED。
"""
now_utc, updated_at = self._times()
with self._session_factory() as session:
transaction = SqlAlchemyUnitOfWork(session)
try:
oper = TransferExecutionStepOper(session)
updated = oper.stage_start_attempt(
task_id=task_id,
lease_token=lease_token,
operation_id=operation_id,
attempt_token=attempt_token,
now_utc=now_utc,
updated_at=updated_at,
)
if updated != 1:
self._raise_fenced_failure(
TransferPendingOper(session).get_by_task_id(task_id=task_id),
lease_token=lease_token,
now_utc=now_utc,
detail="整理步骤不是可开始的 PREPARED 状态",
)
session.expire_all()
step = oper.get_by_operation_id(operation_id=operation_id)
if step is None:
raise TransferExecutionConflictError("整理任务执行状态暂时无法确认,请刷新整理历史后再试")
projected = self._project_step(step)
transaction.commit()
return projected
except Exception:
self._rollback(transaction)
raise
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:
"""
以严格 NOT_APPLIED 证据和旧 attempt CAS 重启遗留步骤。
"""
now_utc, updated_at = self._times()
with self._session_factory() as session:
transaction = SqlAlchemyUnitOfWork(session)
try:
oper = TransferExecutionStepOper(session)
updated = oper.stage_restart_after_not_applied(
task_id=task_id,
lease_token=lease_token,
operation_id=operation_id,
previous_attempt_token=previous_attempt_token,
attempt_token=attempt_token,
result_version=evidence.version,
result_payload=dict(evidence.payload),
now_utc=now_utc,
updated_at=updated_at,
)
if updated != 1:
self._raise_fenced_failure(
TransferPendingOper(session).get_by_task_id(task_id=task_id),
lease_token=lease_token,
now_utc=now_utc,
detail="遗留步骤状态或旧 attempt token 已变化",
)
session.expire_all()
step = oper.get_by_operation_id(operation_id=operation_id)
if step is None:
raise TransferExecutionConflictError("整理任务执行状态暂时无法确认,请刷新整理历史后再试")
projected = self._project_step(step)
transaction.commit()
return projected
except Exception:
self._rollback(transaction)
raise
def resume_failed_step(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
) -> TransferExecutionStep:
"""
在 retry_wait 到期并重新 claim 后恢复同一 FAILED 操作。
"""
now_utc, updated_at = self._times()
with self._session_factory() as session:
transaction = SqlAlchemyUnitOfWork(session)
try:
pending_oper = TransferPendingOper(session)
pending = pending_oper.get_by_task_id(task_id=task_id)
pending = self._require_active_lease(
pending,
lease_token=lease_token,
now_utc=now_utc,
)
checkpoint, checkpoint_fingerprint = self._plan_identity(pending)
oper = TransferExecutionStepOper(session)
steps = self._execution_steps(
oper.list_by_task_id(task_id=task_id)
)
self._validate_plan_steps(
task_id=task_id,
checkpoint=checkpoint,
checkpoint_fingerprint=checkpoint_fingerprint,
steps=steps,
)
if operation_id not in {step.operation_id for step in steps}:
raise TransferExecutionConflictError(
"整理任务记录已失效,请重新识别文件后再整理"
)
pending_updated = pending_oper.stage_execution_running(
task_id=task_id,
lease_token=lease_token,
admission_state=pending.state,
checkpoint_version=checkpoint.schema_version,
checkpoint_payload=checkpoint.to_payload(),
now_utc=now_utc,
updated_at=updated_at,
)
if pending_updated != 1:
self._raise_fenced_failure(
pending,
lease_token=lease_token,
now_utc=now_utc,
detail="重试任务未到期或当前状态不能恢复",
)
updated = oper.stage_resume_failed_attempt(
task_id=task_id,
lease_token=lease_token,
operation_id=operation_id,
attempt_token=attempt_token,
now_utc=now_utc,
updated_at=updated_at,
)
if updated != 1:
self._raise_fenced_failure(
pending,
lease_token=lease_token,
now_utc=now_utc,
detail="整理步骤不是可恢复的 FAILED 状态",
)
session.expire_all()
step = oper.get_by_operation_id(operation_id=operation_id)
if step is None:
raise TransferExecutionConflictError("整理任务执行状态暂时无法确认,请刷新整理历史后再试")
projected = self._project_step(step)
transaction.commit()
return projected
except Exception:
self._rollback(transaction)
raise
def complete_step(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
result: TransferStepResult,
) -> TransferExecutionStep:
"""
以 lease 与 attempt 双 CAS 提交外部操作成功证据。
"""
now_utc, updated_at = self._times()
with self._session_factory() as session:
transaction = SqlAlchemyUnitOfWork(session)
try:
oper = TransferExecutionStepOper(session)
updated = oper.stage_complete_attempt(
task_id=task_id,
lease_token=lease_token,
operation_id=operation_id,
attempt_token=attempt_token,
result_version=result.version,
result_payload=dict(result.payload),
now_utc=now_utc,
updated_at=updated_at,
)
if updated != 1:
self._raise_fenced_failure(
TransferPendingOper(session).get_by_task_id(task_id=task_id),
lease_token=lease_token,
now_utc=now_utc,
detail="整理步骤 attempt token 已变化或状态不是 STARTED",
)
session.expire_all()
step = oper.get_by_operation_id(operation_id=operation_id)
if step is None:
raise TransferExecutionConflictError("整理任务执行状态暂时无法确认,请刷新整理历史后再试")
projected = self._project_step(step)
transaction.commit()
return projected
except Exception:
self._rollback(transaction)
raise
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。
"""
now_utc, updated_at = self._times()
with self._session_factory() as session:
transaction = SqlAlchemyUnitOfWork(session)
try:
step_oper = TransferExecutionStepOper(session)
updated = step_oper.stage_fail_attempt(
task_id=task_id,
lease_token=lease_token,
operation_id=operation_id,
attempt_token=attempt_token,
error=error,
result_version=evidence.version if evidence else None,
result_payload=dict(evidence.payload) if evidence else None,
now_utc=now_utc,
updated_at=updated_at,
)
pending_oper = TransferPendingOper(session)
pending = pending_oper.get_by_task_id(task_id=task_id)
if updated != 1:
self._raise_fenced_failure(
pending,
lease_token=lease_token,
now_utc=now_utc,
detail="整理步骤已知失败证据与当前 attempt 冲突",
)
pending_updated = pending_oper.stage_defer_execution(
task_id=task_id,
lease_token=lease_token,
error=error,
retry_due_at=retry_due_at,
now_utc=now_utc,
updated_at=updated_at,
)
if pending_updated != 1:
self._raise_fenced_failure(
pending,
lease_token=lease_token,
now_utc=now_utc,
detail="整理任务不能进入重试等待",
)
session.flush()
session.expire_all()
pending = pending_oper.get_by_task_id(task_id=task_id)
if pending is None:
raise TransferExecutionConflictError("整理任务执行状态暂时无法确认,请刷新整理历史后再试")
snapshot = self._project_snapshot(
pending,
step_oper.list_by_task_id(task_id=task_id),
)
transaction.commit()
return snapshot
except Exception:
self._rollback(transaction)
raise
def exhaust_step(
self,
*,
task_id: str,
lease_token: str,
operation_id: str,
attempt_token: str,
error: str,
evidence: Optional[TransferStepResult] = None,
) -> TransferExecutionSnapshot:
"""
原子提交预算耗尽步骤失败,并保留 lease 建立失败结算检查点。
"""
now_utc, updated_at = self._times()
with self._session_factory() as session:
transaction = SqlAlchemyUnitOfWork(session)
try:
step_oper = TransferExecutionStepOper(session)
updated = step_oper.stage_fail_attempt(
task_id=task_id,
lease_token=lease_token,
operation_id=operation_id,
attempt_token=attempt_token,
error=error,
result_version=evidence.version if evidence else None,
result_payload=dict(evidence.payload) if evidence else None,
now_utc=now_utc,
updated_at=updated_at,
)
pending_oper = TransferPendingOper(session)
pending = pending_oper.get_by_task_id(task_id=task_id)
if updated != 1:
self._raise_fenced_failure(
pending,
lease_token=lease_token,
now_utc=now_utc,
detail="预算耗尽失败证据与当前 attempt 冲突",
)
session.flush()
steps = self._execution_steps(
step_oper.list_by_task_id(task_id=task_id)
)
checkpoint = TransferExecutionCheckpoint.create(
payload={
"outcome": "failed",
"failed_operation_id": operation_id,
"error": error,
"evidence": (
{
"schema_version": evidence.version,
"payload": dict(evidence.payload),
}
if evidence
else None
),
},
operation_ids=tuple(step.operation_id for step in steps),
)
pending_updated = pending_oper.stage_checkpoint_exhausted_failure(
task_id=task_id,
lease_token=lease_token,
execution_version=checkpoint.version,
execution_payload=checkpoint.to_payload(),
execution_fingerprint=checkpoint.fingerprint,
error=error,
now_utc=now_utc,
updated_at=updated_at,
)
if pending_updated != 1:
self._raise_fenced_failure(
pending,
lease_token=lease_token,
now_utc=now_utc,
detail="整理任务不能进入失败结算状态",
)
session.flush()
session.expire_all()
pending = pending_oper.get_by_task_id(task_id=task_id)
if pending is None:
raise TransferExecutionConflictError("整理失败状态暂时无法确认,请刷新整理历史后再试")
snapshot = self._project_snapshot(pending, steps)
transaction.commit()
return snapshot
except Exception:
self._rollback(transaction)
raise
def request_retry(
self,
*,
task_id: str,
reason: str,
requested_by: str,
) -> TransferRetryRequestResult:
"""
以短事务登记用户重试意图,不 claim、执行或删除既有证据。
"""
retry_due_at, updated_at = self._times()
with self._session_factory() as session:
transaction = SqlAlchemyUnitOfWork(session)
try:
oper = TransferPendingOper(session)
pending = oper.get_by_task_id(task_id=task_id)
if pending is None:
raise TransferExecutionConflictError("这条整理任务已不存在或已经处理完成")
state = TransferExecutionState(pending.execution_state)
if state is TransferExecutionState.RETRY_WAIT:
return TransferRetryRequestResult(
accepted=True,
state=state,
retry_generation=pending.retry_generation,
message="整理任务已在等待重新处理",
)
if state is not TransferExecutionState.FAILED:
message = (
"这条整理任务需要先完成人工确认,再重试"
if state is TransferExecutionState.MANUAL_REVIEW
else "这条整理任务当前无法重试,请刷新后再试"
)
return TransferRetryRequestResult(
accepted=False,
state=state,
retry_generation=pending.retry_generation,
message=message,
)
if not self._clear_inactive_claim(session, pending=pending, now_utc=retry_due_at):
return TransferRetryRequestResult(
accepted=False,
state=state,
retry_generation=pending.retry_generation,
message="整理任务正在处理中或状态已变化,请刷新后重试",
)
replanned = self._replan_music_rejection(session, pending=pending, updated_at=updated_at)
if replanned is not None:
transaction.commit()
return replanned
updated = oper.stage_request_execution_retry(
task_id=task_id,
reason=reason,
requested_by=requested_by,
retry_due_at=retry_due_at,
updated_at=updated_at,
)
if updated != 1:
session.expire_all()
pending = oper.get_by_task_id(task_id=task_id)
if pending is None:
raise TransferExecutionConflictError("这条整理任务已被其他操作处理,请刷新整理历史确认最新状态")
state = TransferExecutionState(pending.execution_state)
if state is TransferExecutionState.RETRY_WAIT:
return TransferRetryRequestResult(
accepted=True,
state=state,
retry_generation=pending.retry_generation,
message="整理任务已提交重试,请勿重复操作",
)
return TransferRetryRequestResult(
accepted=False,
state=state,
retry_generation=pending.retry_generation,
message="整理任务状态已变化,请刷新后重试",
)
session.flush()
session.expire_all()
pending = oper.get_by_task_id(task_id=task_id)
if pending is None:
raise TransferExecutionConflictError("整理重试状态暂时无法确认,请刷新整理历史后再试")
result = TransferRetryRequestResult(
accepted=True,
state=TransferExecutionState.RETRY_WAIT,
retry_generation=pending.retry_generation,
message="已提交重新整理,后台将自动处理",
)
transaction.commit()
return result
except Exception:
self._rollback(transaction)
raise
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: 调度受理结果;不安全或过期请求抛出状态冲突
"""
now_utc, updated_at = self._times()
with self._session_factory() as session:
transaction = SqlAlchemyUnitOfWork(session)
try:
history = TransferHistoryOper(session).get(history_id)
if (history is None or history.transfer_task_id != task_id
or history.transfer_settlement_revision != settlement_revision):
raise TransferExecutionConflictError("分析期间整理历史或结算版本已变化,请重新分析")
pending = TransferPendingOper(session).get_by_task_id(task_id=task_id)
if pending is None:
raise TransferExecutionConflictError("没有找到对应的失败整理任务,请刷新后重试")
if not self._clear_inactive_claim(session, pending=pending, now_utc=now_utc):
raise TransferExecutionConflictError("整理任务正在处理中,暂时无法重新规划")
checkpoint, fingerprint = self._plan_identity(pending)
steps = TransferExecutionStepOper(session).list_by_task_id(task_id=pending.task_id)
self._validate_recognition_replan(pending, history, checkpoint, steps)
self._validate_plan_steps(task_id=pending.task_id, checkpoint=checkpoint,
checkpoint_fingerprint=fingerprint, steps=steps)
snapshot = self._project_snapshot(pending, steps)
planning = checkpoint.planning_input
meta = {**(planning.meta or {}), "type": media_type}
if season is not None:
meta.update(begin_season=season, end_season=None, total_season=1)
if episode is not None:
meta.update(begin_episode=episode, end_episode=None, total_episode=1)
evidence: dict[str, JSONValue] = {
"plan": checkpoint.to_payload(), "execution": asdict(snapshot),
"requested_by": requested_by, "requested_at": updated_at,
}
planning = replace(
planning, media_source=media_source, media_id=media_id, media_type=media_type,
meta=meta, mediainfo=None, episodes_info=(),
options={**planning.options, "_mediainfo_kind": None, "ai_replan_evidence": evidence},
)
# 唯一步骤是内部 reject;原内容已随新输入持久归档,外部操作步骤始终禁止替换。
TransferExecutionStepOper(session).stage_delete_task(task_id=pending.task_id)
pending.state = TRANSFER_ADMISSION_ACCEPTED
pending.planning_input = planning.to_payload()
pending.input_fingerprint = planning.fingerprint
pending.checkpoint_version = pending.checkpoint_payload = pending.planned_at = None
pending.execution_version = pending.execution_payload = pending.execution_fingerprint = None
pending.execution_state = TransferExecutionState.RETRY_WAIT.value
pending.retry_generation += 1
pending.retry_count += 1
pending.retry_due_at = now_utc
pending.retry_requested_by = requested_by
pending.retry_reason = "智能助手核实媒体身份及季集后重新规划"
pending.updated_at = updated_at
transaction.commit()
return TransferRetryRequestResult(True, TransferExecutionState.RETRY_WAIT,
pending.retry_generation, "已提交识别修正,等待后台重新规划整理")
except Exception:
transaction.rollback()
raise
@staticmethod
def _validate_recognition_replan(
pending: TransferPending, history: TransferHistory, checkpoint: TransferPlanCheckpoint,
steps: list[TransferExecutionStepModel],
) -> None:
"""
拒绝复核、过期回执、预算耗尽或任何可能含文件副作用的重新规划。
:param pending: 事务内已取得恢复权的任务
:param history: 当前读取的失败历史
:param checkpoint: 原始冻结计划
:param steps: 原始持久执行步骤
"""
if pending.execution_state != TransferExecutionState.FAILED.value:
raise TransferExecutionConflictError("任务需要人工确认或正在处理,不能自动重新规划")
if (history.status or pending.terminal_history_id != history.id
or pending.settlement_revision != history.transfer_settlement_revision):
raise TransferExecutionConflictError("整理历史与任务结算版本已变化,请刷新后重试")
if history.auto_paused or max(history.retry_count or 0, pending.retry_count) >= max_failed_retries():
raise TransferExecutionConflictError("整理任务自动重试次数已耗尽,请人工处理")
stage = history.failure_stage or classify_transfer_failure(checkpoint.rejection_error).stage
if stage not in {TransferFailureStage.RECOGNITION, TransferFailureStage.PLANNING}:
raise TransferExecutionConflictError("当前失败需要先修复文件或存储配置,不能自动修正识别")
planning = TransferPlanningInput.from_payload(pending.planning_input)
if (planning.fingerprint != pending.input_fingerprint or pending.input_version != planning.schema_version
or not planning.meta):
raise TransferExecutionConflictError("原始规划输入不完整或已变化,不能自动重新规划")
if (not checkpoint.rejection_error or checkpoint.items or checkpoint.provider_invocation
or checkpoint.legacy_transfer_providers or len(steps) != 1):
raise TransferExecutionConflictError("已有文件操作计划或证据,不能自动替换,请人工处理")
step = steps[0]
if step.phase != "planning" or step.kind != "reject" or step.state != TransferStepState.SUCCEEDED.value:
raise TransferExecutionConflictError("存在文件操作或不确定执行证据,不能自动重新规划")
def _replan_music_rejection(
self, session: Session, *, pending: TransferPending, updated_at: str,
) -> Optional[TransferRetryRequestResult]:
"""
只替换已结算且没有外部步骤的音乐拒绝;保留旧历史,不碰任何文件操作记录。
"""
if not pending.checkpoint_payload or not pending.terminal_history_id or pending.settlement_revision < 1:
return None
checkpoint, fingerprint = self._plan_identity(pending)
planning = music_replanning_input(checkpoint)
if planning is None:
return None
step_oper = TransferExecutionStepOper(session)
steps = step_oper.list_by_task_id(task_id=pending.task_id)
if len(steps) != 1 or any(step.phase != "planning" or step.kind != "reject"
or step.state != TransferStepState.SUCCEEDED.value for step in steps):
return None
self._validate_plan_steps(task_id=pending.task_id, checkpoint=checkpoint,
checkpoint_fingerprint=fingerprint, steps=steps)
# 唯一步骤是无外部副作用的 reject。删除内部拒绝步骤不会清除文件操作证据。
step_oper.stage_delete_task(task_id=pending.task_id)
oper = TransferPendingOper(session)
removed = oper.stage_delete_terminal_failure(task_id=pending.task_id, history_id=pending.terminal_history_id,
settlement_revision=pending.settlement_revision)
if removed != 1:
raise TransferExecutionConflictError("音乐重新识别的原任务状态已改变")
detached = TransferHistoryOper(session).stage_detach_failed_transfer_task(
history_id=pending.terminal_history_id, task_id=pending.task_id,
settlement_revision=pending.settlement_revision,
)
if detached != 1:
raise TransferExecutionConflictError("音乐重新识别的历史绑定已改变")
session.expunge(pending)
options = {**planning.options, "music_replanned_from": pending.task_id}
planning = type(planning).from_payload({**planning.to_payload(), "options": options})
created = oper.stage_admit(task_id=uuid4().hex, storage=pending.storage, src_path=pending.src_path,
state=TRANSFER_ADMISSION_ACCEPTED, now_time=updated_at,
input_version=planning.schema_version, planning_input=planning.to_payload(),
input_fingerprint=planning.fingerprint)
if created is None:
raise TransferExecutionConflictError("音乐重新识别任务未能登记")
return TransferRetryRequestResult(accepted=True, state=TransferExecutionState.NOT_STARTED,
retry_generation=0, message="已重新生成音乐识别任务,后台将重新识别后整理")
def discard_corrupt_task(
self,
*,
task_id: str,
lease_token: str,
error: str,
) -> bool:
"""
在当前有效租约下清理损坏 pending、步骤及历史任务映射。
"""
if not task_id or not lease_token or not error:
return False
now_utc, updated_at = self._times()
with self._session_factory() as session:
transaction = SqlAlchemyUnitOfWork(session)
try:
pending = TransferPendingOper(session).get_by_task_id(task_id=task_id)
self._require_active_lease(pending, lease_token=lease_token, now_utc=now_utc)
history = TransferHistoryOper(session).get_by_transfer_task_id(task_id=task_id)
if history is not None:
history.transfer_task_id = None
history.transfer_settlement_revision = None
TransferExecutionStepOper(session).stage_delete_task(task_id=task_id)
deleted = session.query(TransferPending).filter(
TransferPending.task_id == task_id,
TransferPending.lease_token == lease_token,
).delete(synchronize_session=False)
if deleted != 1:
raise TransferExecutionConflictError("损坏整理任务清理未完成,请刷新后重试")
transaction.commit()
return True
except Exception:
self._rollback(transaction)
raise
def discard_corrupt_by_history(
self,
*,
task_id: str,
history_id: int,
) -> TransferFailureDiscardResult:
"""
在无活动租约时删除损坏任务、步骤并解除历史绑定。
"""
now_utc, _updated_at = self._times()
with self._session_factory() as session:
transaction = SqlAlchemyUnitOfWork(session)
try:
history_oper = TransferHistoryOper(session)
# 放弃旧任务只读取身份与状态,损坏 JSON 不能挡住显式重新规划。
pending = session.scalar(select(TransferPending).where(
TransferPending.task_id == task_id,
).options(load_only(TransferPending.task_id, TransferPending.execution_state)))
history = history_oper.get(history_id)
if pending is None:
return TransferFailureDiscardResult(True, None, "整理任务已被清理")
# 显式放弃必须能处理损坏的状态字段,仍由原始状态和租约 CAS 保护。
try:
state: Optional[TransferExecutionState] = TransferExecutionState(pending.execution_state)
except ValueError:
state = None
if history is None or history.transfer_task_id != task_id:
return TransferFailureDiscardResult(False, state, "整理历史与任务绑定已变化,请刷新后重试")
if not self._clear_inactive_claim(session, pending=pending, now_utc=now_utc):
return TransferFailureDiscardResult(False, state, "整理任务正在处理中或状态已变化,暂时无法放弃")
history.transfer_task_id = None
history.transfer_settlement_revision = None
TransferExecutionStepOper(session).stage_delete_task(task_id=task_id)
session.delete(pending)
transaction.commit()
return TransferFailureDiscardResult(True, state, "已放弃损坏整理任务")
except Exception:
self._rollback(transaction)
raise
def discard_failed(
self,
*,
task_id: str,
history_id: int,
settlement_revision: int,
) -> TransferFailureDiscardResult:
"""
原子删除指定 FAILED pending、步骤证据并解除历史回执映射。
"""
now_utc, _updated_at = self._times()
with self._session_factory() as session:
transaction = SqlAlchemyUnitOfWork(session)
try:
pending_oper = TransferPendingOper(session)
history_oper = TransferHistoryOper(session)
pending = pending_oper.get_by_task_id(task_id=task_id)
if pending is None:
history = history_oper.get(history_id)
if history is None or history.transfer_task_id is None:
return TransferFailureDiscardResult(
discarded=True,
state=None,
message="整理任务已被其他操作放弃",
)
return TransferFailureDiscardResult(
discarded=False,
state=None,
message="没有找到对应的整理任务,请刷新后重试",
)
state = TransferExecutionState(pending.execution_state)
if state is not TransferExecutionState.FAILED:
message = (
"这条整理任务需要先完成人工确认,再重试"
if state is TransferExecutionState.MANUAL_REVIEW
else "这条整理任务当前无法放弃,请刷新后重试"
)
return TransferFailureDiscardResult(
discarded=False,
state=state,
message=message,
)
if (
pending.terminal_history_id != history_id
or pending.settlement_revision != settlement_revision
):
return TransferFailureDiscardResult(
discarded=False,
state=state,
message="整理任务状态已变化,请刷新后重试",
)
if not self._clear_inactive_claim(session, pending=pending, now_utc=now_utc):
return TransferFailureDiscardResult(
discarded=False,
state=state,
message="整理任务正在处理中或状态已变化,暂时无法放弃,请稍后重试",
)
deleted = pending_oper.stage_delete_terminal_failure(
task_id=task_id,
history_id=history_id,
settlement_revision=settlement_revision,
)
if deleted != 1:
session.expire_all()
pending = pending_oper.get_by_task_id(task_id=task_id)
history = history_oper.get(history_id)
if pending is None and (
history is None or history.transfer_task_id is None
):
return TransferFailureDiscardResult(
discarded=True,
state=None,
message="整理任务已被其他操作放弃",
)
return TransferFailureDiscardResult(
discarded=False,
state=(
TransferExecutionState(pending.execution_state)
if pending is not None
else None
),
message="整理任务状态已变化,请刷新后重试",
)
# PostgreSQL/启用外键的 SQLite 会级联删除;显式清理兼容独立测试库。
TransferExecutionStepOper(session).stage_delete_task(task_id=task_id)
detached = history_oper.stage_detach_failed_transfer_task(
history_id=history_id,
task_id=task_id,
settlement_revision=settlement_revision,
)
if detached != 1:
raise TransferExecutionConflictError(
"整理失败记录清理未完成,请刷新整理历史后再试"
)
transaction.commit()
return TransferFailureDiscardResult(
discarded=True,
state=TransferExecutionState.FAILED,
message="已放弃这条失败的整理任务",
)
except Exception:
self._rollback(transaction)
raise
@staticmethod
def _clear_inactive_claim(
session: Session,
*,
pending: TransferPending,
now_utc: str,
) -> bool:
"""
以状态和期限 CAS 取得用户恢复权,持有行写锁直到重试或清理提交。
"""
updated = session.query(TransferPending).filter(
TransferPending.task_id == pending.task_id,
TransferPending.execution_state == pending.execution_state,
or_(
TransferPending.lease_token.is_(None),
TransferPending.lease_expires_at.is_(None),
TransferPending.lease_expires_at <= now_utc,
),
).update(
{
"lease_owner": None,
"lease_token": None,
"lease_expires_at": None,
"heartbeat_at": None,
},
synchronize_session=False,
)
return bool(updated == 1)
def resolve_manual_review(
self,
*,
task_id: str,
operation_id: str,
decision: TransferManualReviewDecision,
actor: str,
reason: str,
result: Optional[TransferStepResult] = None,
) -> TransferManualReviewResult:
"""
在无 lease 短事务中原子提交步骤与 pending 的人工判定。
"""
if decision is TransferManualReviewDecision.FAILED:
raise TransferExecutionConflictError(
"该整理任务需要通过正常流程完成结算,请重新整理"
)
if decision is TransferManualReviewDecision.APPLIED and result is None:
raise ValueError("人工判定已发生时必须提供结果证据")
retry_due_at, updated_at = self._times()
target_state = (
TransferStepState.SUCCEEDED.value
if decision is TransferManualReviewDecision.APPLIED
else TransferStepState.FAILED.value
)
with self._session_factory() as session:
transaction = SqlAlchemyUnitOfWork(session)
try:
step_oper = TransferExecutionStepOper(session)
step = step_oper.get_by_operation_id(operation_id=operation_id)
if (
decision is TransferManualReviewDecision.APPLIED
and step is not None
and step.task_id == task_id
and step.kind in _REVIEW_STEP_KINDS
):
raise TransferExecutionConflictError(
"这条整理步骤无法确认是否已经执行,请人工确认文件状态后再继续;"
"请先人工回滚,或确认未发生后选择 NOT_APPLIED"
)
step_updated = step_oper.stage_resolve_manual_review(
task_id=task_id,
operation_id=operation_id,
target_state=target_state,
reason=reason,
result_version=result.version if result else None,
result_payload=dict(result.payload) if result else None,
updated_at=updated_at,
)
if step_updated != 1:
raise TransferExecutionConflictError(
"该步骤当前无法进行人工确认,请刷新整理历史后再试"
)
pending_oper = TransferPendingOper(session)
pending_updated = pending_oper.stage_resolve_manual_review(
task_id=task_id,
decision=decision.value,
actor=actor,
reason=reason,
retry_due_at=retry_due_at,
updated_at=updated_at,
)
if pending_updated != 1:
raise TransferExecutionConflictError(
"该整理任务当前无法进行人工确认,请刷新整理历史后再试"
)
session.flush()
session.expire_all()
pending = pending_oper.get_by_task_id(task_id=task_id)
step = step_oper.get_by_operation_id(operation_id=operation_id)
if pending is None or step is None:
raise TransferExecutionConflictError("人工确认结果暂时无法保存,请刷新整理历史后再试")
resolved = TransferManualReviewResult(
task_id=task_id,
operation_id=operation_id,
decision=decision,
state=TransferExecutionState(pending.execution_state),
review_revision=pending.manual_review_revision,
step=self._project_step(step),
)
transaction.commit()
return resolved
except Exception:
self._rollback(transaction)
raise
def discard_manual_review(
self,
*,
task_id: str,
operation_id: str,
actor: str,
reason: str,
) -> TransferManualReviewDiscardResult:
"""原子解除人工复核占用;不会执行任何文件系统或 provider 操作。"""
now_utc, _updated_at = self._times()
with self._session_factory() as session:
transaction = SqlAlchemyUnitOfWork(session)
try:
pending_oper = TransferPendingOper(session)
pending = pending_oper.get_by_task_id(task_id=task_id)
if pending is None:
return TransferManualReviewDiscardResult(task_id, operation_id, True, "整理任务已被清理")
if TransferExecutionState(pending.execution_state) is not TransferExecutionState.MANUAL_REVIEW:
return TransferManualReviewDiscardResult(task_id, operation_id, False, "整理任务状态已变化,请刷新后重试")
if not self._clear_inactive_claim(session, pending=pending, now_utc=now_utc):
return TransferManualReviewDiscardResult(task_id, operation_id, False, "整理任务正在处理中,暂时无法放弃")
step = TransferExecutionStepOper(session).get_by_operation_id(operation_id=operation_id)
if (
step is None
or step.task_id != task_id
or TransferStepState(step.state) is not TransferStepState.MANUAL_REVIEW
):
return TransferManualReviewDiscardResult(task_id, operation_id, False, "复核步骤已变化,请刷新后重试")
TransferExecutionStepOper(session).stage_delete_task(task_id=task_id)
session.delete(pending)
transaction.commit()
return TransferManualReviewDiscardResult(task_id, operation_id, True, "已放弃整理任务;未修改任何文件")
except Exception:
self._rollback(transaction)
raise
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:
"""
原子隔离外部结果未知的步骤与任务,并释放自动调度 lease。
"""
now_utc, updated_at = self._times()
with self._session_factory() as session:
transaction = SqlAlchemyUnitOfWork(session)
try:
step_oper = TransferExecutionStepOper(session)
updated = step_oper.stage_manual_review(
task_id=task_id,
lease_token=lease_token,
operation_id=operation_id,
attempt_token=attempt_token,
error=error,
result_version=evidence.version if evidence else None,
result_payload=dict(evidence.payload) if evidence else None,
now_utc=now_utc,
updated_at=updated_at,
)
pending_oper = TransferPendingOper(session)
pending = pending_oper.get_by_task_id(task_id=task_id)
if updated != 1:
self._raise_fenced_failure(
pending,
lease_token=lease_token,
now_utc=now_utc,
detail="人工复核证据与当前步骤状态冲突",
)
pending_updated = pending_oper.stage_mark_execution_manual_review(
task_id=task_id,
lease_token=lease_token,
error=error,
now_utc=now_utc,
updated_at=updated_at,
)
if pending_updated != 1:
self._raise_fenced_failure(
pending,
lease_token=lease_token,
now_utc=now_utc,
detail="整理任务不能进入人工复核",
)
session.flush()
session.expire_all()
pending = pending_oper.get_by_task_id(task_id=task_id)
if pending is None:
raise TransferExecutionConflictError("人工复核状态暂时无法确认,请刷新整理历史后再试")
snapshot = self._project_snapshot(
pending,
step_oper.list_by_task_id(task_id=task_id),
)
transaction.commit()
return snapshot
except Exception:
self._rollback(transaction)
raise
def checkpoint_execution(
self,
*,
task_id: str,
lease_token: str,
checkpoint: TransferExecutionCheckpoint,
) -> TransferExecutionSnapshot:
"""
仅在完整步骤集合均成功时提交可重放终态执行检查点。
"""
now_utc, updated_at = self._times()
with self._session_factory() as session:
transaction = SqlAlchemyUnitOfWork(session)
try:
pending_oper = TransferPendingOper(session)
pending = pending_oper.get_by_task_id(task_id=task_id)
pending = self._require_active_lease(
pending,
lease_token=lease_token,
now_utc=now_utc,
)
plan_checkpoint, plan_fingerprint = self._plan_identity(pending)
step_oper = TransferExecutionStepOper(session)
steps = self._execution_steps(
step_oper.list_by_task_id(task_id=task_id)
)
self._validate_plan_steps(
task_id=task_id,
checkpoint=plan_checkpoint,
checkpoint_fingerprint=plan_fingerprint,
steps=steps,
)
step_ids = tuple(step.operation_id for step in steps)
if step_ids != checkpoint.operation_ids:
raise TransferExecutionConflictError(
"整理任务状态已发生变化,请刷新整理历史后再试"
)
if any(step.state != TransferStepState.SUCCEEDED.value for step in steps):
raise TransferExecutionConflictError(
"整理任务仍有步骤未完成,请先完成前置步骤"
)
running = pending_oper.stage_execution_running(
task_id=task_id,
lease_token=lease_token,
admission_state=pending.state,
checkpoint_version=plan_checkpoint.schema_version,
checkpoint_payload=plan_checkpoint.to_payload(),
now_utc=now_utc,
updated_at=updated_at,
)
if running != 1:
self._raise_fenced_failure(
pending,
lease_token=lease_token,
now_utc=now_utc,
detail="整理任务当前状态不能建立执行检查点",
)
updated = pending_oper.stage_checkpoint_execution(
task_id=task_id,
lease_token=lease_token,
execution_version=checkpoint.version,
execution_payload=checkpoint.to_payload(),
execution_fingerprint=checkpoint.fingerprint,
now_utc=now_utc,
updated_at=updated_at,
)
if updated != 1:
self._raise_fenced_failure(
pending,
lease_token=lease_token,
now_utc=now_utc,
detail="整理任务当前状态不能提交执行检查点",
)
session.flush()
session.expire_all()
pending = pending_oper.get_by_task_id(task_id=task_id)
if pending is None:
raise TransferExecutionConflictError("整理任务执行状态暂时无法确认,请刷新整理历史后再试")
snapshot = self._project_snapshot(pending, steps)
transaction.commit()
return snapshot
except Exception:
self._rollback(transaction)
raise