mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
refactor: complete durable transfer execution settlement
This commit is contained in:
+336
-24
@@ -3,13 +3,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.chain.durable_events import (
|
||||
ChainDurableEventWriter,
|
||||
TransferHistoryRef,
|
||||
TransferResultSettlement,
|
||||
download_added_event_key,
|
||||
snapshot_download_added,
|
||||
snapshot_transfer_result,
|
||||
@@ -17,22 +21,40 @@ from app.application.chain.durable_events import (
|
||||
)
|
||||
from app.application.history import TransferHistoryRecord, TransferHistoryWriter
|
||||
from app.application.outbox import (
|
||||
DurableEventCommand,
|
||||
DOWNLOAD_ADDED_TOPIC,
|
||||
DurableEventCommand,
|
||||
OutboxIntent,
|
||||
)
|
||||
from app.application.transfer_execution import (
|
||||
TransferExecutionConflictError,
|
||||
TransferExecutionLeaseLostError,
|
||||
TransferExecutionState,
|
||||
TransferSettlementResult,
|
||||
)
|
||||
from app.db.adapters.outbox import SqlAlchemyOutboxRepository
|
||||
from app.db.models.transfersettlementreceipt import TransferSettlementReceipt
|
||||
from app.db.oper.downloadhistory import DownloadHistoryOper
|
||||
from app.db.oper.transferexecutionstep import TransferExecutionStepOper
|
||||
from app.db.oper.transferhistory import TransferHistoryOper
|
||||
from app.db.oper.transferpending import TransferPendingOper
|
||||
from app.db.oper.transfersettlementreceipt import TransferSettlementReceiptOper
|
||||
from app.db.uow import SqlAlchemyUnitOfWork
|
||||
|
||||
|
||||
class _StagingTransferHistoryWriter:
|
||||
"""让既有历史字段映射复用无提交的 replace 适配器。"""
|
||||
|
||||
def __init__(self, repository: TransferHistoryOper) -> None:
|
||||
"""保存绑定调用方 Session 的整理历史仓储。"""
|
||||
def __init__(
|
||||
self,
|
||||
repository: TransferHistoryOper,
|
||||
*,
|
||||
settlement: TransferResultSettlement | None = None,
|
||||
settlement_revision: int | None = None,
|
||||
) -> None:
|
||||
"""保存仓储,并让历史继续表达同源最新业务投影。"""
|
||||
self._repository = repository
|
||||
self._settlement = settlement
|
||||
self._settlement_revision = settlement_revision
|
||||
|
||||
def get_by_src(
|
||||
self,
|
||||
@@ -47,14 +69,40 @@ class _StagingTransferHistoryWriter:
|
||||
src: str,
|
||||
storage: str | None = None,
|
||||
) -> TransferHistoryRecord | None:
|
||||
"""转发按源路径读取成功记录。"""
|
||||
"""读取成功记录;任务结算时绑定当前任务投影。"""
|
||||
if self._settlement is not None:
|
||||
if self._settlement_revision is None:
|
||||
raise RuntimeError("整理任务结算缺少事务内修订号")
|
||||
return self._repository.stage_bind_settlement(
|
||||
task_id=self._settlement.task_id,
|
||||
settlement_revision=self._settlement_revision,
|
||||
src=src,
|
||||
storage=storage,
|
||||
)
|
||||
return self._repository.get_success_by_src(src, storage)
|
||||
|
||||
def add_force(self, **payload: Any) -> TransferHistoryRecord:
|
||||
"""保持应用层旧端口名,但只暂存替换而不自行提交。"""
|
||||
"""保持旧端口名,并按是否存在任务身份选择暂存策略。"""
|
||||
if self._settlement is not None:
|
||||
if self._settlement_revision is None:
|
||||
raise RuntimeError("整理任务结算缺少事务内修订号")
|
||||
return self._repository.stage_upsert_by_transfer_task_id(
|
||||
task_id=self._settlement.task_id,
|
||||
settlement_revision=self._settlement_revision,
|
||||
retain_task_mapping=self._settlement.outcome == "failed",
|
||||
payload=payload,
|
||||
)
|
||||
return self._repository.stage_replace_by_src(**payload)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _StagedTransferResult:
|
||||
"""保存构造 outbox 所需历史投影及可选任务结算结果。"""
|
||||
|
||||
history: TransferHistoryRef
|
||||
settlement: TransferSettlementResult | None = None
|
||||
|
||||
|
||||
class TransactionalChainDurableEventWriter(ChainDurableEventWriter):
|
||||
"""为每次 Chain 结果事件创建独占同步 Session 和 UoW。"""
|
||||
|
||||
@@ -109,41 +157,111 @@ class TransactionalChainDurableEventWriter(ChainDurableEventWriter):
|
||||
def transfer_result(
|
||||
self,
|
||||
*,
|
||||
topic: str,
|
||||
topic: str | None,
|
||||
stage_history: Callable[[TransferHistoryWriter], TransferHistoryRecord | None],
|
||||
event_payload: dict[str, Any],
|
||||
publish: Callable[[dict[str, Any]], None],
|
||||
) -> TransferHistoryRecord | None:
|
||||
"""原子写整理历史与结果 intent,并返回脱离 Session 的最小投影。"""
|
||||
publish: Callable[[dict[str, Any]], None] | None,
|
||||
settlement: TransferResultSettlement | None = None,
|
||||
) -> TransferHistoryRecord | TransferSettlementResult | None:
|
||||
"""原子写历史、可选任务终态与 intent,再返回稳定投影。"""
|
||||
if topic is None and settlement is None:
|
||||
raise ValueError("无事件 topic 的整理写入必须绑定 durable 任务结算")
|
||||
session = self._session_factory()
|
||||
try:
|
||||
staging = _StagingTransferHistoryWriter(TransferHistoryOper(session))
|
||||
history_repository = TransferHistoryOper(session)
|
||||
pending_repository = TransferPendingOper(session)
|
||||
receipt_repository = TransferSettlementReceiptOper(session)
|
||||
already_settled = self._read_settlement_result(
|
||||
pending_repository=pending_repository,
|
||||
receipt_repository=receipt_repository,
|
||||
settlement=settlement,
|
||||
)
|
||||
if already_settled is not None:
|
||||
return already_settled
|
||||
|
||||
command = DurableEventCommand(
|
||||
unit_of_work=SqlAlchemyUnitOfWork(session),
|
||||
outbox=SqlAlchemyOutboxRepository(session),
|
||||
)
|
||||
|
||||
def stage_business() -> TransferHistoryRef | None:
|
||||
"""复用历史字段映射,并在 flush 后冻结安全投影。"""
|
||||
def stage_business() -> _StagedTransferResult:
|
||||
"""同一事务暂存历史及受 fencing 保护的 pending 终态。"""
|
||||
expected_revision = self._settlement_revision(
|
||||
pending_repository=pending_repository,
|
||||
settlement=settlement,
|
||||
)
|
||||
next_revision = (
|
||||
expected_revision + 1
|
||||
if expected_revision is not None
|
||||
else None
|
||||
)
|
||||
staging = _StagingTransferHistoryWriter(
|
||||
history_repository,
|
||||
settlement=settlement,
|
||||
settlement_revision=next_revision,
|
||||
)
|
||||
history = stage_history(staging)
|
||||
if history is None:
|
||||
return None
|
||||
return TransferHistoryRef(
|
||||
raise RuntimeError("整理历史暂存失败,无法登记 durable 结果事件")
|
||||
projected = TransferHistoryRef(
|
||||
id=history.id,
|
||||
status=bool(history.status),
|
||||
src=history.src,
|
||||
src_storage=history.src_storage,
|
||||
src_fileitem=history.src_fileitem,
|
||||
)
|
||||
if settlement is None:
|
||||
return _StagedTransferResult(history=projected)
|
||||
assert expected_revision is not None
|
||||
assert next_revision is not None
|
||||
self._validate_history_outcome(projected, settlement)
|
||||
pending_deleted = self._stage_pending_terminal(
|
||||
session=session,
|
||||
repository=pending_repository,
|
||||
settlement=settlement,
|
||||
expected_revision=expected_revision,
|
||||
history_id=projected.id,
|
||||
)
|
||||
settled_at = datetime.now(timezone.utc).isoformat()
|
||||
receipt_repository.stage_append(
|
||||
task_id=settlement.task_id,
|
||||
history_id=projected.id,
|
||||
settlement_revision=next_revision,
|
||||
outcome=settlement.outcome,
|
||||
execution_fingerprint=settlement.execution_fingerprint,
|
||||
lease_token=settlement.lease_token,
|
||||
history_status=projected.status,
|
||||
src=projected.src,
|
||||
src_storage=projected.src_storage,
|
||||
pending_deleted=pending_deleted,
|
||||
error=settlement.error,
|
||||
settled_at=settled_at,
|
||||
)
|
||||
return _StagedTransferResult(
|
||||
history=projected,
|
||||
settlement=TransferSettlementResult(
|
||||
history_id=projected.id,
|
||||
settlement_revision=next_revision,
|
||||
pending_deleted=pending_deleted,
|
||||
),
|
||||
)
|
||||
|
||||
def build_intent(
|
||||
history: TransferHistoryRef | None,
|
||||
result: _StagedTransferResult,
|
||||
) -> OutboxIntent:
|
||||
"""历史 ID 确定后构造事件键与可恢复快照。"""
|
||||
if history is None:
|
||||
raise RuntimeError("整理历史暂存失败,无法登记 durable 结果事件")
|
||||
event_key = transfer_result_event_key(topic, history.id)
|
||||
event_payload["transfer_history_id"] = history.id
|
||||
assert topic is not None
|
||||
event_key = transfer_result_event_key(
|
||||
topic,
|
||||
result.history.id,
|
||||
settlement=settlement,
|
||||
settlement_revision=(
|
||||
result.settlement.settlement_revision
|
||||
if result.settlement is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
event_payload["transfer_history_id"] = result.history.id
|
||||
event_payload["idempotency_key"] = event_key
|
||||
return OutboxIntent(
|
||||
event_key=event_key,
|
||||
@@ -151,10 +269,204 @@ class TransactionalChainDurableEventWriter(ChainDurableEventWriter):
|
||||
payload=snapshot_transfer_result(event_payload),
|
||||
)
|
||||
|
||||
return command.execute(
|
||||
intent=build_intent,
|
||||
stage_business=stage_business,
|
||||
publish=lambda: publish(event_payload),
|
||||
)
|
||||
try:
|
||||
result = command.execute(
|
||||
intent=build_intent if topic is not None else None,
|
||||
stage_business=stage_business,
|
||||
publish=(
|
||||
(lambda: publish(event_payload))
|
||||
if (
|
||||
settlement is None
|
||||
and topic is not None
|
||||
and publish is not None
|
||||
)
|
||||
else None
|
||||
),
|
||||
)
|
||||
except (
|
||||
IntegrityError,
|
||||
TransferExecutionConflictError,
|
||||
TransferExecutionLeaseLostError,
|
||||
ValueError,
|
||||
):
|
||||
if settlement is None:
|
||||
raise
|
||||
session.rollback()
|
||||
replay = self._read_settlement_result(
|
||||
pending_repository=pending_repository,
|
||||
receipt_repository=receipt_repository,
|
||||
settlement=settlement,
|
||||
)
|
||||
if replay is None:
|
||||
raise
|
||||
return replay
|
||||
return result.settlement or result.history
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@staticmethod
|
||||
def _read_settlement_result(
|
||||
*,
|
||||
pending_repository: TransferPendingOper,
|
||||
receipt_repository: TransferSettlementReceiptOper,
|
||||
settlement: TransferResultSettlement | None,
|
||||
) -> TransferSettlementResult | None:
|
||||
"""识别已提交终态并返回幂等结果,未结算时返回空。"""
|
||||
if settlement is None:
|
||||
return None
|
||||
pending = pending_repository.get_by_task_id(task_id=settlement.task_id)
|
||||
latest = receipt_repository.get_latest_by_task_id(
|
||||
task_id=settlement.task_id
|
||||
)
|
||||
receipt = receipt_repository.get_by_identity(
|
||||
task_id=settlement.task_id,
|
||||
execution_fingerprint=settlement.execution_fingerprint,
|
||||
lease_token=settlement.lease_token,
|
||||
outcome=settlement.outcome,
|
||||
)
|
||||
if (
|
||||
pending is not None
|
||||
and pending.execution_state == TransferExecutionState.FAILED.value
|
||||
):
|
||||
if (
|
||||
latest is None
|
||||
or pending.terminal_history_id != latest.history_id
|
||||
or pending.settlement_revision != latest.settlement_revision
|
||||
or pending.execution_fingerprint != latest.execution_fingerprint
|
||||
or latest.outcome != "failed"
|
||||
or latest.pending_deleted
|
||||
):
|
||||
raise TransferExecutionConflictError("失败终态与最新结算回执不一致")
|
||||
if receipt is not None:
|
||||
TransactionalChainDurableEventWriter._validate_receipt(
|
||||
receipt=receipt,
|
||||
settlement=settlement,
|
||||
)
|
||||
return TransferSettlementResult(
|
||||
history_id=receipt.history_id,
|
||||
settlement_revision=receipt.settlement_revision,
|
||||
pending_deleted=receipt.pending_deleted,
|
||||
already_settled=True,
|
||||
)
|
||||
if pending is None:
|
||||
if latest is None:
|
||||
raise TransferExecutionConflictError(
|
||||
"pending 已不存在且没有可验证的终态回执"
|
||||
)
|
||||
raise TransferExecutionConflictError("整理终态与 durable 回执不一致")
|
||||
if pending.execution_state != TransferExecutionState.FAILED.value:
|
||||
return None
|
||||
raise TransferExecutionConflictError("失败终态缺少匹配的结算回执")
|
||||
|
||||
@staticmethod
|
||||
def _validate_receipt(
|
||||
*,
|
||||
receipt: TransferSettlementReceipt,
|
||||
settlement: TransferResultSettlement,
|
||||
) -> None:
|
||||
"""校验重放请求与独立回执中的终态身份完全一致。"""
|
||||
expected_status = settlement.outcome == "succeeded"
|
||||
if (
|
||||
receipt.task_id != settlement.task_id
|
||||
or receipt.outcome != settlement.outcome
|
||||
or receipt.execution_fingerprint != settlement.execution_fingerprint
|
||||
or receipt.lease_token != settlement.lease_token
|
||||
or receipt.history_status is not expected_status
|
||||
or receipt.error != settlement.error
|
||||
):
|
||||
raise TransferExecutionConflictError("整理终态与 durable 回执不一致")
|
||||
|
||||
@staticmethod
|
||||
def _settlement_revision(
|
||||
*,
|
||||
pending_repository: TransferPendingOper,
|
||||
settlement: TransferResultSettlement | None,
|
||||
) -> int | None:
|
||||
"""从当前 pending 读取 CAS 基准修订号并校验执行身份。"""
|
||||
if settlement is None:
|
||||
return None
|
||||
pending = pending_repository.get_by_task_id(task_id=settlement.task_id)
|
||||
if pending is None:
|
||||
raise TransferExecutionLeaseLostError("整理任务 pending 已不存在")
|
||||
now_utc = TransactionalChainDurableEventWriter._format_utc(
|
||||
datetime.now(timezone.utc)
|
||||
)
|
||||
if (
|
||||
pending.lease_token != settlement.lease_token
|
||||
or pending.lease_expires_at is None
|
||||
or pending.lease_expires_at <= now_utc
|
||||
):
|
||||
raise TransferExecutionLeaseLostError("整理任务租约已失效或被接管")
|
||||
if (
|
||||
pending.execution_state != TransferExecutionState.SETTLING.value
|
||||
or pending.execution_fingerprint
|
||||
!= settlement.execution_fingerprint
|
||||
):
|
||||
raise TransferExecutionConflictError("整理终态与执行检查点不匹配")
|
||||
return int(pending.settlement_revision)
|
||||
|
||||
@staticmethod
|
||||
def _stage_pending_terminal(
|
||||
*,
|
||||
session: Session,
|
||||
repository: TransferPendingOper,
|
||||
settlement: TransferResultSettlement,
|
||||
expected_revision: int,
|
||||
history_id: int,
|
||||
) -> bool:
|
||||
"""以同一修订和 lease CAS 收口 pending,成功时同时清理步骤。"""
|
||||
now = datetime.now(timezone.utc)
|
||||
now_utc = TransactionalChainDurableEventWriter._format_utc(now)
|
||||
if settlement.outcome == "succeeded":
|
||||
TransferExecutionStepOper(session).stage_delete_task(
|
||||
task_id=settlement.task_id
|
||||
)
|
||||
updated = repository.stage_delete_terminal_success(
|
||||
task_id=settlement.task_id,
|
||||
lease_token=settlement.lease_token,
|
||||
execution_fingerprint=settlement.execution_fingerprint,
|
||||
expected_revision=expected_revision,
|
||||
now_utc=now_utc,
|
||||
)
|
||||
pending_deleted = True
|
||||
else:
|
||||
updated = repository.stage_terminal_failure(
|
||||
task_id=settlement.task_id,
|
||||
lease_token=settlement.lease_token,
|
||||
execution_fingerprint=settlement.execution_fingerprint,
|
||||
expected_revision=expected_revision,
|
||||
history_id=history_id,
|
||||
error=settlement.error,
|
||||
now_utc=now_utc,
|
||||
updated_at=now.astimezone().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
pending_deleted = False
|
||||
if updated != 1:
|
||||
session.expire_all()
|
||||
current = repository.get_by_task_id(task_id=settlement.task_id)
|
||||
if (
|
||||
current is None
|
||||
or current.lease_token != settlement.lease_token
|
||||
or current.lease_expires_at is None
|
||||
or current.lease_expires_at <= now_utc
|
||||
):
|
||||
raise TransferExecutionLeaseLostError(
|
||||
"整理任务租约已失效或被其他 worker 接管"
|
||||
)
|
||||
raise TransferExecutionConflictError("整理终态结算版本发生冲突")
|
||||
return pending_deleted
|
||||
|
||||
@staticmethod
|
||||
def _validate_history_outcome(
|
||||
history: TransferHistoryRef,
|
||||
settlement: TransferResultSettlement,
|
||||
) -> None:
|
||||
"""拒绝结算终态与历史状态不一致的调用。"""
|
||||
expected_status = settlement.outcome == "succeeded"
|
||||
if history.status is not expected_status:
|
||||
raise TransferExecutionConflictError("整理终态与历史状态不一致")
|
||||
|
||||
@staticmethod
|
||||
def _format_utc(value: datetime) -> str:
|
||||
"""编码与 pending lease 列一致的固定宽度 UTC 时间。"""
|
||||
return value.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||
|
||||
@@ -145,6 +145,8 @@ class TransactionalTransferAdmissionRepository:
|
||||
planning_input: Optional[TransferPlanningInput] = None,
|
||||
) -> TransferAdmission:
|
||||
"""按输入指纹幂等持久化准入事实,并返回跨重启稳定身份。"""
|
||||
if not storage or not src_path:
|
||||
raise ValueError("整理任务的存储与源路径不能为空")
|
||||
effective_input = planning_input or TransferPlanningInput.legacy(
|
||||
storage=storage,
|
||||
src_path=src_path,
|
||||
@@ -170,7 +172,9 @@ class TransactionalTransferAdmissionRepository:
|
||||
input_fingerprint=effective_input.fingerprint,
|
||||
)
|
||||
if pending is None:
|
||||
raise ValueError("整理任务的存储与源路径不能为空")
|
||||
raise TransferAdmissionConflictError(
|
||||
f"整理源文件已有持久终态回执: {storage}:{src_path}"
|
||||
)
|
||||
session.flush()
|
||||
self._assert_input_match(pending, effective_input)
|
||||
admission = self._project(pending)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,14 @@ _MODEL_EXPORTS = {
|
||||
),
|
||||
"SystemConfig": ("app.db.models.systemconfig", "SystemConfig"),
|
||||
"TransferHistory": ("app.db.models.transferhistory", "TransferHistory"),
|
||||
"TransferExecutionStep": (
|
||||
"app.db.models.transferexecutionstep",
|
||||
"TransferExecutionStep",
|
||||
),
|
||||
"TransferSettlementReceipt": (
|
||||
"app.db.models.transfersettlementreceipt",
|
||||
"TransferSettlementReceipt",
|
||||
),
|
||||
"TransferPending": ("app.db.models.transferpending", "TransferPending"),
|
||||
"User": ("app.db.models.user", "User"),
|
||||
"UserConfig": ("app.db.models.userconfig", "UserConfig"),
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
"""整理任务外部操作步骤的持久化模型。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, cast
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
and_,
|
||||
delete,
|
||||
exists,
|
||||
or_,
|
||||
select,
|
||||
update,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
from sqlalchemy.sql.selectable import Exists
|
||||
|
||||
from app.db.base import Base, execute_dml, get_id_column
|
||||
from app.db.models.transferpending import TransferPending
|
||||
|
||||
|
||||
class TransferExecutionStep(Base):
|
||||
"""保存一次稳定外部操作的意图、尝试身份与结果证据。"""
|
||||
|
||||
id = get_id_column()
|
||||
task_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("transferpending.task_id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
operation_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
checkpoint_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
ordinal: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
phase: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
state: Mapped[str] = mapped_column(String(32), nullable=False, default="prepared")
|
||||
attempt_token: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
intent_version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
intent_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
result_version: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
result_payload: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON)
|
||||
last_error: Mapped[Optional[str]] = mapped_column(Text)
|
||||
prepared_at: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
started_at: Mapped[Optional[str]] = mapped_column(String(40))
|
||||
completed_at: Mapped[Optional[str]] = mapped_column(String(40))
|
||||
updated_at: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("operation_id", name="uq_transferexecutionstep_operation_id"),
|
||||
UniqueConstraint(
|
||||
"task_id",
|
||||
"ordinal",
|
||||
name="uq_transferexecutionstep_task_ordinal",
|
||||
),
|
||||
Index(
|
||||
"ix_transferexecutionstep_task_state_ordinal",
|
||||
"task_id",
|
||||
"state",
|
||||
"ordinal",
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_by_operation_id(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
operation_id: str,
|
||||
) -> Optional["TransferExecutionStep"]:
|
||||
"""按稳定操作标识读取步骤。"""
|
||||
if not operation_id:
|
||||
return None
|
||||
return cast(
|
||||
Optional["TransferExecutionStep"],
|
||||
db.execute(
|
||||
select(cls).where(cls.operation_id == operation_id)
|
||||
).scalars().first(),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def list_by_task_id(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
) -> list["TransferExecutionStep"]:
|
||||
"""按全局序号读取任务的全部外部操作步骤。"""
|
||||
if not task_id:
|
||||
return []
|
||||
return list(
|
||||
db.execute(
|
||||
select(cls)
|
||||
.where(cls.task_id == task_id)
|
||||
.order_by(cls.ordinal.asc())
|
||||
).scalars().all()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def stage_prepare(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
operation_id: str,
|
||||
checkpoint_fingerprint: str,
|
||||
ordinal: int,
|
||||
phase: str,
|
||||
kind: str,
|
||||
intent_version: int,
|
||||
intent_payload: dict[str, Any],
|
||||
now_time: str,
|
||||
) -> "TransferExecutionStep":
|
||||
"""在调用方事务中暂存尚未执行的稳定步骤意图。"""
|
||||
step = cls(
|
||||
task_id=task_id,
|
||||
operation_id=operation_id,
|
||||
checkpoint_fingerprint=checkpoint_fingerprint,
|
||||
ordinal=ordinal,
|
||||
phase=phase,
|
||||
kind=kind,
|
||||
state="prepared",
|
||||
attempt_count=0,
|
||||
intent_version=intent_version,
|
||||
intent_payload=intent_payload,
|
||||
prepared_at=now_time,
|
||||
updated_at=now_time,
|
||||
)
|
||||
db.add(step)
|
||||
return step
|
||||
|
||||
@classmethod
|
||||
def start_attempt(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
operation_id: str,
|
||||
attempt_token: str,
|
||||
now_utc: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""以有效任务租约 CAS 开始一次新的步骤尝试。"""
|
||||
return execute_dml(
|
||||
db,
|
||||
update(cls)
|
||||
.where(
|
||||
cls.task_id == task_id,
|
||||
cls.operation_id == operation_id,
|
||||
cls.state == "prepared",
|
||||
cls.attempt_token.is_(None),
|
||||
cls._active_lease_exists(
|
||||
task_id=task_id,
|
||||
lease_token=lease_token,
|
||||
now_utc=now_utc,
|
||||
),
|
||||
)
|
||||
.values(
|
||||
state="started",
|
||||
attempt_token=attempt_token,
|
||||
attempt_count=cls.attempt_count + 1,
|
||||
started_at=updated_at,
|
||||
completed_at=None,
|
||||
last_error=None,
|
||||
updated_at=updated_at,
|
||||
),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def restart_after_not_applied(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
operation_id: str,
|
||||
previous_attempt_token: str,
|
||||
attempt_token: str,
|
||||
result_version: int,
|
||||
result_payload: dict[str, Any],
|
||||
now_utc: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""以 NOT_APPLIED 证据和旧 attempt token CAS 重启遗留步骤。"""
|
||||
return execute_dml(
|
||||
db,
|
||||
update(cls)
|
||||
.where(
|
||||
cls.task_id == task_id,
|
||||
cls.operation_id == operation_id,
|
||||
cls.state == "started",
|
||||
cls.attempt_token == previous_attempt_token,
|
||||
cls._active_lease_exists(
|
||||
task_id=task_id,
|
||||
lease_token=lease_token,
|
||||
now_utc=now_utc,
|
||||
),
|
||||
)
|
||||
.values(
|
||||
attempt_token=attempt_token,
|
||||
attempt_count=cls.attempt_count + 1,
|
||||
result_version=result_version,
|
||||
result_payload=result_payload,
|
||||
started_at=updated_at,
|
||||
completed_at=None,
|
||||
last_error=None,
|
||||
updated_at=updated_at,
|
||||
),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def resume_failed_attempt(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
operation_id: str,
|
||||
attempt_token: str,
|
||||
now_utc: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""以重试调度的新 lease CAS 恢复 FAILED 步骤并保留失败证据。"""
|
||||
return execute_dml(
|
||||
db,
|
||||
update(cls)
|
||||
.where(
|
||||
cls.task_id == task_id,
|
||||
cls.operation_id == operation_id,
|
||||
cls.state == "failed",
|
||||
cls.attempt_token.is_(None),
|
||||
cls._active_lease_exists(
|
||||
task_id=task_id,
|
||||
lease_token=lease_token,
|
||||
now_utc=now_utc,
|
||||
),
|
||||
)
|
||||
.values(
|
||||
state="started",
|
||||
attempt_token=attempt_token,
|
||||
attempt_count=cls.attempt_count + 1,
|
||||
started_at=updated_at,
|
||||
completed_at=None,
|
||||
updated_at=updated_at,
|
||||
),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def complete_attempt(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
operation_id: str,
|
||||
attempt_token: str,
|
||||
result_version: int,
|
||||
result_payload: dict[str, Any],
|
||||
now_utc: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""以租约与 attempt 双 CAS 提交步骤成功证据。"""
|
||||
return execute_dml(
|
||||
db,
|
||||
update(cls)
|
||||
.where(
|
||||
cls.task_id == task_id,
|
||||
cls.operation_id == operation_id,
|
||||
cls.state == "started",
|
||||
cls.attempt_token == attempt_token,
|
||||
cls._active_lease_exists(
|
||||
task_id=task_id,
|
||||
lease_token=lease_token,
|
||||
now_utc=now_utc,
|
||||
),
|
||||
)
|
||||
.values(
|
||||
state="succeeded",
|
||||
attempt_token=None,
|
||||
result_version=result_version,
|
||||
result_payload=result_payload,
|
||||
last_error=None,
|
||||
completed_at=updated_at,
|
||||
updated_at=updated_at,
|
||||
),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def fail_attempt(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
operation_id: str,
|
||||
attempt_token: str,
|
||||
error: str,
|
||||
result_version: Optional[int],
|
||||
result_payload: Optional[dict[str, Any]],
|
||||
now_utc: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""以租约与 attempt 双 CAS 提交已知失败证据。"""
|
||||
return execute_dml(
|
||||
db,
|
||||
update(cls)
|
||||
.where(
|
||||
cls.task_id == task_id,
|
||||
cls.operation_id == operation_id,
|
||||
cls.state == "started",
|
||||
cls.attempt_token == attempt_token,
|
||||
cls._active_lease_exists(
|
||||
task_id=task_id,
|
||||
lease_token=lease_token,
|
||||
now_utc=now_utc,
|
||||
),
|
||||
)
|
||||
.values(
|
||||
state="failed",
|
||||
attempt_token=None,
|
||||
result_version=result_version,
|
||||
result_payload=result_payload,
|
||||
last_error=error,
|
||||
completed_at=updated_at,
|
||||
updated_at=updated_at,
|
||||
),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def mark_manual_review(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
operation_id: str,
|
||||
attempt_token: Optional[str],
|
||||
error: str,
|
||||
result_version: Optional[int],
|
||||
result_payload: Optional[dict[str, Any]],
|
||||
now_utc: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""以当前尝试身份隔离外部结果不可判定的步骤。"""
|
||||
attempt_match = (
|
||||
cls.attempt_token == attempt_token
|
||||
if attempt_token is not None
|
||||
else cls.attempt_token.is_(None)
|
||||
)
|
||||
return execute_dml(
|
||||
db,
|
||||
update(cls)
|
||||
.where(
|
||||
cls.task_id == task_id,
|
||||
cls.operation_id == operation_id,
|
||||
cls.state.in_(("prepared", "started", "failed")),
|
||||
attempt_match,
|
||||
cls._active_lease_exists(
|
||||
task_id=task_id,
|
||||
lease_token=lease_token,
|
||||
now_utc=now_utc,
|
||||
),
|
||||
)
|
||||
.values(
|
||||
state="manual_review",
|
||||
attempt_token=None,
|
||||
result_version=result_version,
|
||||
result_payload=result_payload,
|
||||
last_error=error,
|
||||
completed_at=updated_at,
|
||||
updated_at=updated_at,
|
||||
),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def resolve_manual_review(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
operation_id: str,
|
||||
target_state: str,
|
||||
reason: str,
|
||||
result_version: Optional[int],
|
||||
result_payload: Optional[dict[str, Any]],
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""仅在 pending 同为无租约人工态时 CAS 提交步骤判定。"""
|
||||
if target_state not in {"failed", "succeeded"}:
|
||||
return 0
|
||||
return execute_dml(
|
||||
db,
|
||||
update(cls)
|
||||
.where(
|
||||
cls.task_id == task_id,
|
||||
cls.operation_id == operation_id,
|
||||
cls.state == "manual_review",
|
||||
cls.attempt_token.is_(None),
|
||||
cls._manual_review_pending_exists(task_id=task_id),
|
||||
)
|
||||
.values(
|
||||
state=target_state,
|
||||
result_version=result_version,
|
||||
result_payload=result_payload,
|
||||
last_error=(reason if target_state == "failed" else None),
|
||||
completed_at=updated_at,
|
||||
updated_at=updated_at,
|
||||
),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def delete_by_task_id(cls, db: Session, *, task_id: str) -> int:
|
||||
"""在终态成功结算事务中删除任务的步骤证据。"""
|
||||
if not task_id:
|
||||
return 0
|
||||
return execute_dml(
|
||||
db,
|
||||
delete(cls).where(cls.task_id == task_id),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _active_lease_exists(
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
now_utc: str,
|
||||
) -> Exists:
|
||||
"""构造关联 pending 行仍持有当前有效租约的 SQL 谓词。"""
|
||||
return exists(
|
||||
select(TransferPending.id).where(
|
||||
and_(
|
||||
TransferPending.task_id == task_id,
|
||||
TransferPending.lease_token == lease_token,
|
||||
TransferPending.lease_expires_at.is_not(None),
|
||||
TransferPending.lease_expires_at > now_utc,
|
||||
or_(
|
||||
TransferPending.execution_state == "running",
|
||||
TransferPending.execution_state == "not_started",
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _manual_review_pending_exists(*, task_id: str) -> Exists:
|
||||
"""构造关联 pending 行处于无租约人工复核态的 SQL 谓词。"""
|
||||
return exists(
|
||||
select(TransferPending.id).where(
|
||||
TransferPending.task_id == task_id,
|
||||
TransferPending.execution_state == "manual_review",
|
||||
TransferPending.lease_token.is_(None),
|
||||
TransferPending.lease_owner.is_(None),
|
||||
)
|
||||
)
|
||||
@@ -1,9 +1,9 @@
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional
|
||||
from typing import Any, List, Optional, cast
|
||||
|
||||
from sqlalchemy import Boolean, Index, Integer, JSON, String, delete, func, or_, select, update
|
||||
from sqlalchemy import JSON, Boolean, Index, Integer, String, delete, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
@@ -24,6 +24,10 @@ class TransferHistory(Base):
|
||||
整理记录
|
||||
"""
|
||||
id = get_id_column()
|
||||
# 失败 pending 当前映射使用的稳定整理任务标识
|
||||
transfer_task_id: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
# 失败 pending 当前映射对应的结算版本
|
||||
transfer_settlement_revision: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
# 源路径
|
||||
src: Mapped[Optional[str]] = mapped_column(String, index=True)
|
||||
# 源存储
|
||||
@@ -90,6 +94,11 @@ class TransferHistory(Base):
|
||||
Index('ix_transferhistory_date_id', 'date', 'id'),
|
||||
Index('ix_transferhistory_media_identity', 'media_source', 'media_id'),
|
||||
Index('ux_transferhistory_src_storage', 'src', 'src_storage', unique=True),
|
||||
Index(
|
||||
'ux_transferhistory_transfer_task_id',
|
||||
'transfer_task_id',
|
||||
unique=True,
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -208,6 +217,82 @@ class TransferHistory(Base):
|
||||
statement = statement.where(cls.src_storage == storage)
|
||||
return db.execute(statement.order_by(cls.id.desc())).scalars().first()
|
||||
|
||||
@classmethod
|
||||
def get_by_transfer_task_id(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
) -> Optional["TransferHistory"]:
|
||||
"""按稳定整理任务标识读取终态结算历史。"""
|
||||
if not task_id:
|
||||
return None
|
||||
return cast(
|
||||
Optional["TransferHistory"],
|
||||
db.execute(
|
||||
select(cls).where(cls.transfer_task_id == task_id)
|
||||
).scalars().first(),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def upsert_by_transfer_task_id(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
settlement_revision: int,
|
||||
retain_task_mapping: bool,
|
||||
payload: dict[str, Any],
|
||||
) -> "TransferHistory":
|
||||
"""按任务幂等写历史,并维持同源存储仅保留一条的既有约束。"""
|
||||
if not task_id or settlement_revision <= 0:
|
||||
raise ValueError("整理历史结算缺少稳定任务或正向版本")
|
||||
column_names = {column.name for column in cls.__table__.columns}
|
||||
values = {
|
||||
key: value
|
||||
for key, value in payload.items()
|
||||
if key in column_names and key not in {
|
||||
"id",
|
||||
"transfer_task_id",
|
||||
"transfer_settlement_revision",
|
||||
}
|
||||
}
|
||||
src = values.get("src")
|
||||
if not src:
|
||||
raise ValueError("整理历史结算缺少源路径")
|
||||
src_storage = values.get("src_storage") or "local"
|
||||
values["src_storage"] = src_storage
|
||||
history = cls.get_by_transfer_task_id(db, task_id=task_id)
|
||||
if history is None:
|
||||
history = db.execute(
|
||||
select(cls).where(
|
||||
cls.src == src,
|
||||
cls.src_storage == src_storage,
|
||||
)
|
||||
).scalars().first()
|
||||
if history is None:
|
||||
history = cls(
|
||||
transfer_task_id=(task_id if retain_task_mapping else None),
|
||||
transfer_settlement_revision=(
|
||||
settlement_revision if retain_task_mapping else None
|
||||
),
|
||||
**values,
|
||||
)
|
||||
db.add(history)
|
||||
else:
|
||||
if (history.transfer_task_id == task_id
|
||||
and history.transfer_settlement_revision is not None
|
||||
and settlement_revision <= history.transfer_settlement_revision):
|
||||
raise ValueError("整理历史结算版本必须单调递增")
|
||||
history.transfer_task_id = task_id if retain_task_mapping else None
|
||||
history.transfer_settlement_revision = (
|
||||
settlement_revision if retain_task_mapping else None
|
||||
)
|
||||
for key, value in values.items():
|
||||
setattr(history, key, value)
|
||||
db.flush()
|
||||
return history
|
||||
|
||||
@classmethod
|
||||
def get_success_by_src(
|
||||
cls, db: Session, src: str,
|
||||
@@ -557,10 +642,20 @@ class TransferHistory(Base):
|
||||
src_storage = kwargs.get("src_storage") or "local"
|
||||
kwargs["src_storage"] = src_storage
|
||||
if src:
|
||||
durable = db.execute(
|
||||
select(cls.id).where(
|
||||
cls.src == src,
|
||||
cls.src_storage == src_storage,
|
||||
cls.transfer_task_id.is_not(None),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if durable is not None:
|
||||
raise ValueError("持久整理回执不能由旧历史写入口覆盖")
|
||||
db.execute(
|
||||
delete(cls).where(
|
||||
cls.src == src,
|
||||
cls.src_storage == src_storage,
|
||||
cls.transfer_task_id.is_(None),
|
||||
),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
@@ -590,7 +685,10 @@ class TransferHistory(Base):
|
||||
"""
|
||||
ids = db.execute(
|
||||
select(cls.id)
|
||||
.where(cls.date < before_time)
|
||||
.where(
|
||||
cls.date < before_time,
|
||||
cls.transfer_task_id.is_(None),
|
||||
)
|
||||
.order_by(cls.id.asc())
|
||||
.limit(limit)
|
||||
).scalars().all()
|
||||
|
||||
@@ -134,6 +134,42 @@ class TransferPending(Base):
|
||||
heartbeat_at: Mapped[Optional[str]] = mapped_column(String(40))
|
||||
# 真正取得新 token 的累计次数
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# 与规划状态正交的执行状态
|
||||
execution_state: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, default="not_started"
|
||||
)
|
||||
# 聚合执行检查点格式版本
|
||||
execution_version: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
# 可独立重放终态结算的聚合执行结果
|
||||
execution_payload: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON)
|
||||
# 聚合执行结果规范 JSON 的 SHA-256 指纹
|
||||
execution_fingerprint: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
# 每次进入 retry_wait 都递增的调度世代
|
||||
retry_generation: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# 已持久提交的步骤重试次数
|
||||
retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# 下一次允许 claim 的 UTC 时间
|
||||
retry_due_at: Mapped[Optional[str]] = mapped_column(String(40))
|
||||
# 最近一次终态失败重试请求身份
|
||||
retry_requested_by: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
# 最近一次终态失败重试请求原因
|
||||
retry_reason: Mapped[Optional[str]] = mapped_column(Text)
|
||||
# 已完成终态结算的单调版本
|
||||
settlement_revision: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# 失败终态保留的整理历史标识
|
||||
terminal_history_id: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
# 人工判定的单调审计版本
|
||||
manual_review_revision: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0
|
||||
)
|
||||
# 最近一次人工判定时间
|
||||
reviewed_at: Mapped[Optional[str]] = mapped_column(String(40))
|
||||
# 最近一次人工判定操作者
|
||||
reviewed_by: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
# 最近一次人工判定原因
|
||||
review_reason: Mapped[Optional[str]] = mapped_column(Text)
|
||||
# 最近一次人工判定结论
|
||||
review_decision: Mapped[Optional[str]] = mapped_column(String(32))
|
||||
|
||||
__table_args__ = (
|
||||
# 同一个文件重复入队只保留一条,回放时不会重复送入整理链
|
||||
@@ -153,6 +189,14 @@ class TransferPending(Base):
|
||||
"created_at",
|
||||
"id",
|
||||
),
|
||||
Index(
|
||||
"ix_transferpending_execution_due",
|
||||
"execution_state",
|
||||
"retry_due_at",
|
||||
"state",
|
||||
"created_at",
|
||||
"id",
|
||||
),
|
||||
UniqueConstraint("task_id", name="uq_transferpending_task_id"),
|
||||
)
|
||||
|
||||
@@ -264,6 +308,19 @@ class TransferPending(Base):
|
||||
cursor_created_at = func.coalesce(cls.created_at, "")
|
||||
statement = select(cls.task_id, cursor_created_at, cls.id).where(
|
||||
cls.state.in_(states),
|
||||
cls.execution_state.in_((
|
||||
"not_started",
|
||||
"running",
|
||||
"retry_wait",
|
||||
"settling",
|
||||
)),
|
||||
or_(
|
||||
cls.execution_state != "retry_wait",
|
||||
and_(
|
||||
cls.retry_due_at.is_not(None),
|
||||
cls.retry_due_at <= now_time,
|
||||
),
|
||||
),
|
||||
or_(
|
||||
cls.lease_token.is_(None),
|
||||
cls.lease_expires_at.is_(None),
|
||||
@@ -331,6 +388,19 @@ class TransferPending(Base):
|
||||
.where(
|
||||
cls.task_id == task_id,
|
||||
cls.state.in_(states),
|
||||
cls.execution_state.in_((
|
||||
"not_started",
|
||||
"running",
|
||||
"retry_wait",
|
||||
"settling",
|
||||
)),
|
||||
or_(
|
||||
cls.execution_state != "retry_wait",
|
||||
and_(
|
||||
cls.retry_due_at.is_not(None),
|
||||
cls.retry_due_at <= now_time,
|
||||
),
|
||||
),
|
||||
or_(
|
||||
cls.lease_token.is_(None),
|
||||
cls.lease_expires_at.is_(None),
|
||||
@@ -348,6 +418,345 @@ class TransferPending(Base):
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def stage_execution_running(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
now_utc: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""以有效租约把可执行任务推进或保持为 running。"""
|
||||
if not all((task_id, lease_token, now_utc, updated_at)):
|
||||
return 0
|
||||
return execute_dml(
|
||||
db,
|
||||
update(cls)
|
||||
.where(
|
||||
cls.task_id == task_id,
|
||||
cls.lease_token == lease_token,
|
||||
cls.lease_expires_at.is_not(None),
|
||||
cls.lease_expires_at > now_utc,
|
||||
cls.execution_state.in_(("not_started", "running", "retry_wait")),
|
||||
)
|
||||
.values(
|
||||
execution_state="running",
|
||||
retry_due_at=None,
|
||||
last_error=None,
|
||||
updated_at=updated_at,
|
||||
),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def defer_execution(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
error: str,
|
||||
retry_due_at: str,
|
||||
now_utc: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""以有效租约进入 retry_wait,并原子释放当前租约。"""
|
||||
if not all((task_id, lease_token, error, retry_due_at, now_utc, updated_at)):
|
||||
return 0
|
||||
return execute_dml(
|
||||
db,
|
||||
update(cls)
|
||||
.where(
|
||||
cls.task_id == task_id,
|
||||
cls.state == "planned",
|
||||
cls.checkpoint_version.is_not(None),
|
||||
cls.checkpoint_payload.is_not(None),
|
||||
cls.execution_state == "running",
|
||||
cls.lease_token == lease_token,
|
||||
cls.lease_expires_at.is_not(None),
|
||||
cls.lease_expires_at > now_utc,
|
||||
)
|
||||
.values(
|
||||
execution_state="retry_wait",
|
||||
retry_generation=cls.retry_generation + 1,
|
||||
retry_count=cls.retry_count + 1,
|
||||
retry_due_at=retry_due_at,
|
||||
lease_owner=None,
|
||||
lease_token=None,
|
||||
lease_expires_at=None,
|
||||
heartbeat_at=None,
|
||||
last_error=error,
|
||||
updated_at=updated_at,
|
||||
),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def mark_execution_manual_review(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
error: str,
|
||||
now_utc: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""以有效租约隔离执行结果未知的任务,并释放自动调度租约。"""
|
||||
if not all((task_id, lease_token, error, now_utc, updated_at)):
|
||||
return 0
|
||||
return execute_dml(
|
||||
db,
|
||||
update(cls)
|
||||
.where(
|
||||
cls.task_id == task_id,
|
||||
cls.execution_state.in_(("not_started", "running", "retry_wait")),
|
||||
cls.lease_token == lease_token,
|
||||
cls.lease_expires_at.is_not(None),
|
||||
cls.lease_expires_at > now_utc,
|
||||
)
|
||||
.values(
|
||||
execution_state="manual_review",
|
||||
lease_owner=None,
|
||||
lease_token=None,
|
||||
lease_expires_at=None,
|
||||
heartbeat_at=None,
|
||||
last_error=error,
|
||||
updated_at=updated_at,
|
||||
),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def checkpoint_execution(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
execution_version: int,
|
||||
execution_payload: dict[str, Any],
|
||||
execution_fingerprint: str,
|
||||
now_utc: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""以有效租约保存可重放执行检查点并进入 settling。"""
|
||||
if not all((
|
||||
task_id,
|
||||
lease_token,
|
||||
execution_version,
|
||||
execution_payload,
|
||||
execution_fingerprint,
|
||||
now_utc,
|
||||
updated_at,
|
||||
)):
|
||||
return 0
|
||||
return execute_dml(
|
||||
db,
|
||||
update(cls)
|
||||
.where(
|
||||
cls.task_id == task_id,
|
||||
cls.state == "planned",
|
||||
cls.checkpoint_version.is_not(None),
|
||||
cls.checkpoint_payload.is_not(None),
|
||||
cls.execution_state == "running",
|
||||
cls.lease_token == lease_token,
|
||||
cls.lease_expires_at.is_not(None),
|
||||
cls.lease_expires_at > now_utc,
|
||||
)
|
||||
.values(
|
||||
execution_state="settling",
|
||||
execution_version=execution_version,
|
||||
execution_payload=execution_payload,
|
||||
execution_fingerprint=execution_fingerprint,
|
||||
retry_due_at=None,
|
||||
last_error=None,
|
||||
updated_at=updated_at,
|
||||
),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def checkpoint_exhausted_failure(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
execution_version: int,
|
||||
execution_payload: dict[str, Any],
|
||||
execution_fingerprint: str,
|
||||
error: str,
|
||||
now_utc: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""以有效 lease 保存预算耗尽失败检查点并保持租约进入 settling。"""
|
||||
return execute_dml(
|
||||
db,
|
||||
update(cls)
|
||||
.where(
|
||||
cls.task_id == task_id,
|
||||
cls.state == "planned",
|
||||
cls.checkpoint_version.is_not(None),
|
||||
cls.checkpoint_payload.is_not(None),
|
||||
cls.execution_state == "running",
|
||||
cls.lease_token == lease_token,
|
||||
cls.lease_expires_at.is_not(None),
|
||||
cls.lease_expires_at > now_utc,
|
||||
)
|
||||
.values(
|
||||
execution_state="settling",
|
||||
execution_version=execution_version,
|
||||
execution_payload=execution_payload,
|
||||
execution_fingerprint=execution_fingerprint,
|
||||
retry_due_at=None,
|
||||
last_error=error,
|
||||
updated_at=updated_at,
|
||||
),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def request_execution_retry(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
reason: str,
|
||||
requested_by: str,
|
||||
retry_due_at: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""仅将无租约 FAILED 任务 CAS 为立即到期的 retry_wait。"""
|
||||
if not all((task_id, reason, requested_by, retry_due_at, updated_at)):
|
||||
return 0
|
||||
return execute_dml(
|
||||
db,
|
||||
update(cls)
|
||||
.where(
|
||||
cls.task_id == task_id,
|
||||
cls.execution_state == "failed",
|
||||
cls.lease_token.is_(None),
|
||||
)
|
||||
.values(
|
||||
execution_state="retry_wait",
|
||||
retry_generation=cls.retry_generation + 1,
|
||||
retry_due_at=retry_due_at,
|
||||
retry_requested_by=requested_by,
|
||||
retry_reason=reason,
|
||||
updated_at=updated_at,
|
||||
),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def resolve_manual_review(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
decision: str,
|
||||
actor: str,
|
||||
reason: str,
|
||||
retry_due_at: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""无 lease 地 CAS 提交人工判定审计并交回 retry_wait 调度。"""
|
||||
if decision not in {"not_applied", "applied"}:
|
||||
return 0
|
||||
return execute_dml(
|
||||
db,
|
||||
update(cls)
|
||||
.where(
|
||||
cls.task_id == task_id,
|
||||
cls.execution_state == "manual_review",
|
||||
cls.lease_token.is_(None),
|
||||
cls.lease_owner.is_(None),
|
||||
)
|
||||
.values(
|
||||
execution_state="retry_wait",
|
||||
retry_generation=cls.retry_generation + 1,
|
||||
retry_due_at=retry_due_at,
|
||||
manual_review_revision=cls.manual_review_revision + 1,
|
||||
reviewed_at=updated_at,
|
||||
reviewed_by=actor,
|
||||
review_reason=reason,
|
||||
review_decision=decision,
|
||||
last_error=(reason if decision == "not_applied" else None),
|
||||
updated_at=updated_at,
|
||||
),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def stage_terminal_failure(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
execution_fingerprint: str,
|
||||
expected_revision: int,
|
||||
history_id: int,
|
||||
error: Optional[str],
|
||||
now_utc: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""以执行指纹和结算版本 CAS 保留失败终态及其历史。"""
|
||||
return execute_dml(
|
||||
db,
|
||||
update(cls)
|
||||
.where(
|
||||
cls.task_id == task_id,
|
||||
cls.execution_state == "settling",
|
||||
cls.execution_fingerprint == execution_fingerprint,
|
||||
cls.settlement_revision == expected_revision,
|
||||
cls.lease_token == lease_token,
|
||||
cls.lease_expires_at.is_not(None),
|
||||
cls.lease_expires_at > now_utc,
|
||||
)
|
||||
.values(
|
||||
execution_state="failed",
|
||||
settlement_revision=cls.settlement_revision + 1,
|
||||
terminal_history_id=history_id,
|
||||
lease_owner=None,
|
||||
lease_token=None,
|
||||
lease_expires_at=None,
|
||||
heartbeat_at=None,
|
||||
last_error=error,
|
||||
updated_at=updated_at,
|
||||
),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def delete_terminal_success(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
execution_fingerprint: str,
|
||||
expected_revision: int,
|
||||
now_utc: str,
|
||||
) -> int:
|
||||
"""以执行指纹和结算版本 CAS 删除已成功结算的 pending。"""
|
||||
return execute_dml(
|
||||
db,
|
||||
delete(cls).where(
|
||||
cls.task_id == task_id,
|
||||
cls.execution_state == "settling",
|
||||
cls.execution_fingerprint == execution_fingerprint,
|
||||
cls.settlement_revision == expected_revision,
|
||||
cls.lease_token == lease_token,
|
||||
cls.lease_expires_at.is_not(None),
|
||||
cls.lease_expires_at > now_utc,
|
||||
),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def record_projection_failure(
|
||||
cls,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""整理任务终态结算回执模型。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, cast
|
||||
|
||||
from sqlalchemy import Boolean, Index, Integer, String, Text, UniqueConstraint, desc, select
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from app.db.base import Base, get_id_column
|
||||
|
||||
|
||||
class TransferSettlementReceipt(Base):
|
||||
"""按任务保存独立于最新历史投影的 durable 终态证据。"""
|
||||
|
||||
id = get_id_column()
|
||||
task_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
history_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
settlement_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
outcome: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
execution_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
lease_token: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
history_status: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||
src: Mapped[Optional[str]] = mapped_column(String)
|
||||
src_storage: Mapped[Optional[str]] = mapped_column(String)
|
||||
pending_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||
error: Mapped[Optional[str]] = mapped_column(Text)
|
||||
created_at: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
updated_at: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"task_id",
|
||||
"settlement_revision",
|
||||
name="uq_transfersettlementreceipt_task_revision",
|
||||
),
|
||||
Index(
|
||||
"ix_transfersettlementreceipt_task_revision",
|
||||
"task_id",
|
||||
"settlement_revision",
|
||||
),
|
||||
Index(
|
||||
"ix_transfersettlementreceipt_history_id",
|
||||
"history_id",
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_latest_by_task_id(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
) -> Optional["TransferSettlementReceipt"]:
|
||||
"""按稳定任务标识读取最新已提交结算回执。"""
|
||||
if not task_id:
|
||||
return None
|
||||
return cast(
|
||||
Optional["TransferSettlementReceipt"],
|
||||
db.execute(
|
||||
select(cls)
|
||||
.where(cls.task_id == task_id)
|
||||
.order_by(desc(cls.settlement_revision))
|
||||
).scalars().first(),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_by_identity(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
execution_fingerprint: str,
|
||||
lease_token: str,
|
||||
outcome: str,
|
||||
) -> Optional["TransferSettlementReceipt"]:
|
||||
"""按原始执行身份读取不可变结算回执。"""
|
||||
if not all((task_id, execution_fingerprint, lease_token, outcome)):
|
||||
return None
|
||||
return cast(
|
||||
Optional["TransferSettlementReceipt"],
|
||||
db.execute(
|
||||
select(cls).where(
|
||||
cls.task_id == task_id,
|
||||
cls.execution_fingerprint == execution_fingerprint,
|
||||
cls.lease_token == lease_token,
|
||||
cls.outcome == outcome,
|
||||
).order_by(desc(cls.settlement_revision))
|
||||
).scalars().first(),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def stage_append(
|
||||
cls,
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
history_id: int,
|
||||
settlement_revision: int,
|
||||
outcome: str,
|
||||
execution_fingerprint: str,
|
||||
lease_token: str,
|
||||
history_status: bool,
|
||||
src: Optional[str],
|
||||
src_storage: Optional[str],
|
||||
pending_deleted: bool,
|
||||
error: Optional[str],
|
||||
settled_at: str,
|
||||
) -> "TransferSettlementReceipt":
|
||||
"""按连续修订追加任务回执,旧修订证据永不覆盖。"""
|
||||
if not all((task_id, history_id, settlement_revision, outcome,
|
||||
execution_fingerprint, lease_token, settled_at)):
|
||||
raise ValueError("整理结算回执缺少稳定身份或结果证据")
|
||||
if outcome not in {"succeeded", "failed"}:
|
||||
raise ValueError(f"不支持的整理结算结果:{outcome}")
|
||||
if cls.get_by_identity(
|
||||
db,
|
||||
task_id=task_id,
|
||||
execution_fingerprint=execution_fingerprint,
|
||||
lease_token=lease_token,
|
||||
outcome=outcome,
|
||||
) is not None:
|
||||
raise ValueError("同一整理执行身份不能追加多个结算修订")
|
||||
latest = cls.get_latest_by_task_id(db, task_id=task_id)
|
||||
if latest is None:
|
||||
if settlement_revision != 1:
|
||||
raise ValueError("整理结算回执必须从修订 1 开始")
|
||||
elif settlement_revision != latest.settlement_revision + 1:
|
||||
raise ValueError("整理结算回执修订必须连续递增")
|
||||
receipt = cls(
|
||||
task_id=task_id,
|
||||
history_id=history_id,
|
||||
settlement_revision=settlement_revision,
|
||||
outcome=outcome,
|
||||
execution_fingerprint=execution_fingerprint,
|
||||
lease_token=lease_token,
|
||||
history_status=history_status,
|
||||
src=src,
|
||||
src_storage=src_storage,
|
||||
pending_deleted=pending_deleted,
|
||||
error=error,
|
||||
created_at=settled_at,
|
||||
updated_at=settled_at,
|
||||
)
|
||||
db.add(receipt)
|
||||
db.flush()
|
||||
return receipt
|
||||
@@ -33,6 +33,7 @@ if TYPE_CHECKING:
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
from app.db.oper.transferhistory import TransferHistoryOper
|
||||
from app.db.oper.transferpending import TransferPendingOper
|
||||
from app.db.oper.transfersettlementreceipt import TransferSettlementReceiptOper
|
||||
from app.db.oper.user import UserOper
|
||||
from app.db.oper.userconfig import UserConfigOper
|
||||
from app.db.oper.workflow import WorkflowOper
|
||||
@@ -52,6 +53,7 @@ _OPER_MODULES = {
|
||||
"SystemConfigOper": "systemconfig",
|
||||
"TransferHistoryOper": "transferhistory",
|
||||
"TransferPendingOper": "transferpending",
|
||||
"TransferSettlementReceiptOper": "transfersettlementreceipt",
|
||||
"UserConfigOper": "userconfig",
|
||||
"UserOper": "user",
|
||||
"WorkflowOper": "workflow",
|
||||
@@ -94,6 +96,7 @@ __all__ = [
|
||||
"SystemConfigOper",
|
||||
"TransferHistoryOper",
|
||||
"TransferPendingOper",
|
||||
"TransferSettlementReceiptOper",
|
||||
"UserConfigOper",
|
||||
"UserOper",
|
||||
"WorkflowOper",
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"""整理外部操作步骤的显式 Session 数据访问对象。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.transferexecutionstep import TransferExecutionStep
|
||||
|
||||
|
||||
class TransferExecutionStepOper(DbOper):
|
||||
"""在调用方事务中查询或暂存整理外部操作步骤。"""
|
||||
|
||||
def _session(self) -> Session:
|
||||
"""返回调用方同步 Session,拒绝隐式事务破坏原子状态推进。"""
|
||||
if not isinstance(self._db, Session):
|
||||
raise RuntimeError("整理执行步骤写入需要调用方提供同步 Session")
|
||||
return self._db
|
||||
|
||||
def get_by_operation_id(
|
||||
self,
|
||||
*,
|
||||
operation_id: str,
|
||||
) -> Optional[TransferExecutionStep]:
|
||||
"""按稳定操作标识查询步骤。"""
|
||||
return TransferExecutionStep.get_by_operation_id(
|
||||
self._session(),
|
||||
operation_id=operation_id,
|
||||
)
|
||||
|
||||
def list_by_task_id(self, *, task_id: str) -> list[TransferExecutionStep]:
|
||||
"""按全局序号查询任务的全部步骤。"""
|
||||
return TransferExecutionStep.list_by_task_id(
|
||||
self._session(),
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
def stage_prepare(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
operation_id: str,
|
||||
checkpoint_fingerprint: str,
|
||||
ordinal: int,
|
||||
phase: str,
|
||||
kind: str,
|
||||
intent_version: int,
|
||||
intent_payload: dict[str, Any],
|
||||
now_time: str,
|
||||
) -> TransferExecutionStep:
|
||||
"""暂存尚未执行的稳定步骤意图。"""
|
||||
return TransferExecutionStep.stage_prepare(
|
||||
self._session(),
|
||||
task_id=task_id,
|
||||
operation_id=operation_id,
|
||||
checkpoint_fingerprint=checkpoint_fingerprint,
|
||||
ordinal=ordinal,
|
||||
phase=phase,
|
||||
kind=kind,
|
||||
intent_version=intent_version,
|
||||
intent_payload=intent_payload,
|
||||
now_time=now_time,
|
||||
)
|
||||
|
||||
def stage_start_attempt(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
operation_id: str,
|
||||
attempt_token: str,
|
||||
now_utc: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""以有效任务租约暂存新步骤尝试。"""
|
||||
return TransferExecutionStep.start_attempt(
|
||||
self._session(),
|
||||
task_id=task_id,
|
||||
lease_token=lease_token,
|
||||
operation_id=operation_id,
|
||||
attempt_token=attempt_token,
|
||||
now_utc=now_utc,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
def stage_restart_after_not_applied(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
operation_id: str,
|
||||
previous_attempt_token: str,
|
||||
attempt_token: str,
|
||||
result_version: int,
|
||||
result_payload: dict[str, Any],
|
||||
now_utc: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""以严格未发生证据暂存遗留 STARTED 步骤的安全重启。"""
|
||||
return TransferExecutionStep.restart_after_not_applied(
|
||||
self._session(),
|
||||
task_id=task_id,
|
||||
lease_token=lease_token,
|
||||
operation_id=operation_id,
|
||||
previous_attempt_token=previous_attempt_token,
|
||||
attempt_token=attempt_token,
|
||||
result_version=result_version,
|
||||
result_payload=result_payload,
|
||||
now_utc=now_utc,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
def stage_resume_failed_attempt(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
operation_id: str,
|
||||
attempt_token: str,
|
||||
now_utc: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""以重试调度的新 lease 暂存 FAILED 步骤恢复。"""
|
||||
return TransferExecutionStep.resume_failed_attempt(
|
||||
self._session(),
|
||||
task_id=task_id,
|
||||
lease_token=lease_token,
|
||||
operation_id=operation_id,
|
||||
attempt_token=attempt_token,
|
||||
now_utc=now_utc,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
def stage_complete_attempt(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
operation_id: str,
|
||||
attempt_token: str,
|
||||
result_version: int,
|
||||
result_payload: dict[str, Any],
|
||||
now_utc: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""以 lease 与 attempt 双 CAS 暂存成功证据。"""
|
||||
return TransferExecutionStep.complete_attempt(
|
||||
self._session(),
|
||||
task_id=task_id,
|
||||
lease_token=lease_token,
|
||||
operation_id=operation_id,
|
||||
attempt_token=attempt_token,
|
||||
result_version=result_version,
|
||||
result_payload=result_payload,
|
||||
now_utc=now_utc,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
def stage_fail_attempt(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
operation_id: str,
|
||||
attempt_token: str,
|
||||
error: str,
|
||||
result_version: Optional[int],
|
||||
result_payload: Optional[dict[str, Any]],
|
||||
now_utc: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""以 lease 与 attempt 双 CAS 暂存已知失败证据。"""
|
||||
return TransferExecutionStep.fail_attempt(
|
||||
self._session(),
|
||||
task_id=task_id,
|
||||
lease_token=lease_token,
|
||||
operation_id=operation_id,
|
||||
attempt_token=attempt_token,
|
||||
error=error,
|
||||
result_version=result_version,
|
||||
result_payload=result_payload,
|
||||
now_utc=now_utc,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
def stage_manual_review(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
operation_id: str,
|
||||
attempt_token: Optional[str],
|
||||
error: str,
|
||||
result_version: Optional[int],
|
||||
result_payload: Optional[dict[str, Any]],
|
||||
now_utc: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""以当前尝试身份暂存人工复核证据。"""
|
||||
return TransferExecutionStep.mark_manual_review(
|
||||
self._session(),
|
||||
task_id=task_id,
|
||||
lease_token=lease_token,
|
||||
operation_id=operation_id,
|
||||
attempt_token=attempt_token,
|
||||
error=error,
|
||||
result_version=result_version,
|
||||
result_payload=result_payload,
|
||||
now_utc=now_utc,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
def stage_resolve_manual_review(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
operation_id: str,
|
||||
target_state: str,
|
||||
reason: str,
|
||||
result_version: Optional[int],
|
||||
result_payload: Optional[dict[str, Any]],
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""在 pending 同为无租约人工态时暂存步骤判定。"""
|
||||
return TransferExecutionStep.resolve_manual_review(
|
||||
self._session(),
|
||||
task_id=task_id,
|
||||
operation_id=operation_id,
|
||||
target_state=target_state,
|
||||
reason=reason,
|
||||
result_version=result_version,
|
||||
result_payload=result_payload,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
def stage_delete_task(self, *, task_id: str) -> int:
|
||||
"""暂存任务全部步骤删除。"""
|
||||
return TransferExecutionStep.delete_by_task_id(
|
||||
self._session(),
|
||||
task_id=task_id,
|
||||
)
|
||||
@@ -2,6 +2,7 @@ import time
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from sqlalchemy import delete as sqlalchemy_delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base import DbOper
|
||||
@@ -116,6 +117,19 @@ class TransferHistoryOper(DbOper):
|
||||
lambda session: TransferHistory.get_by_src(session, src, storage)
|
||||
)
|
||||
|
||||
def get_by_transfer_task_id(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
) -> Optional[TransferHistory]:
|
||||
"""按稳定整理任务标识读取终态历史。"""
|
||||
return self._execute_sync_query(
|
||||
lambda session: TransferHistory.get_by_transfer_task_id(
|
||||
session,
|
||||
task_id=task_id,
|
||||
)
|
||||
)
|
||||
|
||||
def get_success_by_src(
|
||||
self, src: str, storage: Optional[str] = None
|
||||
) -> Optional[TransferHistory]:
|
||||
@@ -264,33 +278,60 @@ class TransferHistoryOper(DbOper):
|
||||
|
||||
def delete(self, historyid):
|
||||
"""
|
||||
删除转移记录
|
||||
删除旧转移记录,失败任务历史由状态机独占。
|
||||
"""
|
||||
self._stage_delete(TransferHistory, historyid)
|
||||
self._execute_sync_write(
|
||||
lambda session: session.execute(
|
||||
sqlalchemy_delete(TransferHistory).where(
|
||||
TransferHistory.id == historyid,
|
||||
TransferHistory.transfer_task_id.is_(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def stage_delete(self, historyid: int) -> None:
|
||||
"""暂存整理记录删除,事务由调用方统一提交。"""
|
||||
self._db.execute(
|
||||
sqlalchemy_delete(TransferHistory).where(
|
||||
TransferHistory.id == historyid
|
||||
TransferHistory.id == historyid,
|
||||
TransferHistory.transfer_task_id.is_(None),
|
||||
)
|
||||
)
|
||||
|
||||
def stage_truncate(self) -> None:
|
||||
"""暂存全部整理记录删除,由请求级事务统一提交。"""
|
||||
self._db.execute(sqlalchemy_delete(TransferHistory))
|
||||
"""暂存旧整理记录删除,只保留当前失败任务历史。"""
|
||||
self._db.execute(
|
||||
sqlalchemy_delete(TransferHistory).where(
|
||||
TransferHistory.transfer_task_id.is_(None)
|
||||
)
|
||||
)
|
||||
|
||||
async def async_delete(self, historyid):
|
||||
"""
|
||||
异步删除转移记录。
|
||||
异步删除旧转移记录,失败任务历史由状态机独占。
|
||||
"""
|
||||
await self._stage_async_delete(TransferHistory, historyid)
|
||||
async def stage(session: AsyncSession) -> None:
|
||||
"""在异步事务内只删除没有任务回执的历史。"""
|
||||
await session.execute(
|
||||
sqlalchemy_delete(TransferHistory).where(
|
||||
TransferHistory.id == historyid,
|
||||
TransferHistory.transfer_task_id.is_(None),
|
||||
)
|
||||
)
|
||||
|
||||
await self._execute_async_write(stage)
|
||||
|
||||
def truncate(self):
|
||||
"""
|
||||
清空转移记录
|
||||
清空旧转移记录,只保留当前失败任务历史。
|
||||
"""
|
||||
self._stage_truncate(TransferHistory)
|
||||
self._execute_sync_write(
|
||||
lambda session: session.execute(
|
||||
sqlalchemy_delete(TransferHistory).where(
|
||||
TransferHistory.transfer_task_id.is_(None)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def add_force(self, **kwargs) -> Optional[TransferHistory]:
|
||||
"""
|
||||
@@ -327,6 +368,7 @@ class TransferHistoryOper(DbOper):
|
||||
sqlalchemy_delete(TransferHistory).where(
|
||||
TransferHistory.src == kwargs.get("src"),
|
||||
TransferHistory.src_storage == kwargs["src_storage"],
|
||||
TransferHistory.transfer_task_id.is_(None),
|
||||
)
|
||||
)
|
||||
self._db.flush()
|
||||
@@ -335,6 +377,47 @@ class TransferHistoryOper(DbOper):
|
||||
self._db.flush()
|
||||
return history
|
||||
|
||||
def stage_upsert_by_transfer_task_id(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
settlement_revision: int,
|
||||
retain_task_mapping: bool,
|
||||
payload: dict[str, Any],
|
||||
) -> TransferHistory:
|
||||
"""在调用方事务内按任务标识幂等暂存终态历史。"""
|
||||
if not isinstance(self._db, Session):
|
||||
raise RuntimeError("整理历史任务结算需要调用方提供同步 Session")
|
||||
payload = dict(payload)
|
||||
payload["src_storage"] = payload.get("src_storage") or "local"
|
||||
payload["date"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||
return TransferHistory.upsert_by_transfer_task_id(
|
||||
self._db,
|
||||
task_id=task_id,
|
||||
settlement_revision=settlement_revision,
|
||||
retain_task_mapping=retain_task_mapping,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
def stage_bind_settlement(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
settlement_revision: int,
|
||||
src: str,
|
||||
storage: Optional[str] = None,
|
||||
) -> Optional[TransferHistory]:
|
||||
"""复用已有成功历史且清除失败任务映射,不改写业务字段。"""
|
||||
if not isinstance(self._db, Session):
|
||||
raise RuntimeError("整理历史任务回执绑定需要调用方提供同步 Session")
|
||||
history = TransferHistory.get_success_by_src(self._db, src, storage)
|
||||
if history is None:
|
||||
return None
|
||||
history.transfer_task_id = None
|
||||
history.transfer_settlement_revision = None
|
||||
self._db.flush()
|
||||
return history
|
||||
|
||||
def update_download_hash(self, historyid, download_hash):
|
||||
"""
|
||||
补充转移记录download_hash
|
||||
|
||||
@@ -328,3 +328,210 @@ class TransferPendingOper(DbOper):
|
||||
now_time=now_time,
|
||||
)
|
||||
)
|
||||
|
||||
def stage_execution_running(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
now_utc: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""以有效租约暂存任务执行状态为 running。"""
|
||||
return self._execute_sync_write(
|
||||
lambda session: TransferPending.stage_execution_running(
|
||||
session,
|
||||
task_id=task_id,
|
||||
lease_token=lease_token,
|
||||
now_utc=now_utc,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
)
|
||||
|
||||
def stage_defer_execution(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
error: str,
|
||||
retry_due_at: str,
|
||||
now_utc: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""暂存重试世代和到期时间,并原子释放当前租约。"""
|
||||
return self._execute_sync_write(
|
||||
lambda session: TransferPending.defer_execution(
|
||||
session,
|
||||
task_id=task_id,
|
||||
lease_token=lease_token,
|
||||
error=error,
|
||||
retry_due_at=retry_due_at,
|
||||
now_utc=now_utc,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
)
|
||||
|
||||
def stage_mark_execution_manual_review(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
error: str,
|
||||
now_utc: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""暂存人工复核隔离状态,并原子释放当前租约。"""
|
||||
return self._execute_sync_write(
|
||||
lambda session: TransferPending.mark_execution_manual_review(
|
||||
session,
|
||||
task_id=task_id,
|
||||
lease_token=lease_token,
|
||||
error=error,
|
||||
now_utc=now_utc,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
)
|
||||
|
||||
def stage_checkpoint_execution(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
execution_version: int,
|
||||
execution_payload: dict[str, Any],
|
||||
execution_fingerprint: str,
|
||||
now_utc: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""以有效租约暂存聚合执行检查点并进入 settling。"""
|
||||
return self._execute_sync_write(
|
||||
lambda session: TransferPending.checkpoint_execution(
|
||||
session,
|
||||
task_id=task_id,
|
||||
lease_token=lease_token,
|
||||
execution_version=execution_version,
|
||||
execution_payload=execution_payload,
|
||||
execution_fingerprint=execution_fingerprint,
|
||||
now_utc=now_utc,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
)
|
||||
|
||||
def stage_checkpoint_exhausted_failure(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
execution_version: int,
|
||||
execution_payload: dict[str, Any],
|
||||
execution_fingerprint: str,
|
||||
error: str,
|
||||
now_utc: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""暂存预算耗尽失败检查点,并保留有效 lease 进入 settling。"""
|
||||
return self._execute_sync_write(
|
||||
lambda session: TransferPending.checkpoint_exhausted_failure(
|
||||
session,
|
||||
task_id=task_id,
|
||||
lease_token=lease_token,
|
||||
execution_version=execution_version,
|
||||
execution_payload=execution_payload,
|
||||
execution_fingerprint=execution_fingerprint,
|
||||
error=error,
|
||||
now_utc=now_utc,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
)
|
||||
|
||||
def stage_request_execution_retry(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
reason: str,
|
||||
requested_by: str,
|
||||
retry_due_at: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""仅将 FAILED 任务暂存为立即到期的 retry_wait。"""
|
||||
return self._execute_sync_write(
|
||||
lambda session: TransferPending.request_execution_retry(
|
||||
session,
|
||||
task_id=task_id,
|
||||
reason=reason,
|
||||
requested_by=requested_by,
|
||||
retry_due_at=retry_due_at,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
)
|
||||
|
||||
def stage_resolve_manual_review(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
decision: str,
|
||||
actor: str,
|
||||
reason: str,
|
||||
retry_due_at: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""无 lease 地暂存人工判定审计并交回 retry_wait 调度。"""
|
||||
return self._execute_sync_write(
|
||||
lambda session: TransferPending.resolve_manual_review(
|
||||
session,
|
||||
task_id=task_id,
|
||||
decision=decision,
|
||||
actor=actor,
|
||||
reason=reason,
|
||||
retry_due_at=retry_due_at,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
)
|
||||
|
||||
def stage_terminal_failure(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
execution_fingerprint: str,
|
||||
expected_revision: int,
|
||||
history_id: int,
|
||||
error: Optional[str],
|
||||
now_utc: str,
|
||||
updated_at: str,
|
||||
) -> int:
|
||||
"""以执行指纹和结算版本暂存失败终态。"""
|
||||
return self._execute_sync_write(
|
||||
lambda session: TransferPending.stage_terminal_failure(
|
||||
session,
|
||||
task_id=task_id,
|
||||
lease_token=lease_token,
|
||||
execution_fingerprint=execution_fingerprint,
|
||||
expected_revision=expected_revision,
|
||||
history_id=history_id,
|
||||
error=error,
|
||||
now_utc=now_utc,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
)
|
||||
|
||||
def stage_delete_terminal_success(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_token: str,
|
||||
execution_fingerprint: str,
|
||||
expected_revision: int,
|
||||
now_utc: str,
|
||||
) -> int:
|
||||
"""以执行指纹和结算版本暂存成功 pending 删除。"""
|
||||
return self._execute_sync_write(
|
||||
lambda session: TransferPending.delete_terminal_success(
|
||||
session,
|
||||
task_id=task_id,
|
||||
lease_token=lease_token,
|
||||
execution_fingerprint=execution_fingerprint,
|
||||
expected_revision=expected_revision,
|
||||
now_utc=now_utc,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""整理任务终态结算回执的事务内数据访问。"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.transfersettlementreceipt import TransferSettlementReceipt
|
||||
|
||||
|
||||
class TransferSettlementReceiptOper(DbOper):
|
||||
"""在调用方 Session 内读取和推进 durable 结算回执。"""
|
||||
|
||||
def get_latest_by_task_id(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
) -> Optional[TransferSettlementReceipt]:
|
||||
"""按稳定任务标识读取最新回执。"""
|
||||
return TransferSettlementReceipt.get_latest_by_task_id(
|
||||
self._db,
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
def get_by_identity(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
execution_fingerprint: str,
|
||||
lease_token: str,
|
||||
outcome: str,
|
||||
) -> Optional[TransferSettlementReceipt]:
|
||||
"""按原始执行身份读取不可变回执。"""
|
||||
return TransferSettlementReceipt.get_by_identity(
|
||||
self._db,
|
||||
task_id=task_id,
|
||||
execution_fingerprint=execution_fingerprint,
|
||||
lease_token=lease_token,
|
||||
outcome=outcome,
|
||||
)
|
||||
|
||||
def stage_append(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
history_id: int,
|
||||
settlement_revision: int,
|
||||
outcome: str,
|
||||
execution_fingerprint: str,
|
||||
lease_token: str,
|
||||
history_status: bool,
|
||||
src: Optional[str],
|
||||
src_storage: Optional[str],
|
||||
pending_deleted: bool,
|
||||
error: Optional[str],
|
||||
settled_at: str,
|
||||
) -> TransferSettlementReceipt:
|
||||
"""在调用方事务内按连续修订追加任务结算回执。"""
|
||||
return TransferSettlementReceipt.stage_append(
|
||||
self._db,
|
||||
task_id=task_id,
|
||||
history_id=history_id,
|
||||
settlement_revision=settlement_revision,
|
||||
outcome=outcome,
|
||||
execution_fingerprint=execution_fingerprint,
|
||||
lease_token=lease_token,
|
||||
history_status=history_status,
|
||||
src=src,
|
||||
src_storage=src_storage,
|
||||
pending_deleted=pending_deleted,
|
||||
error=error,
|
||||
settled_at=settled_at,
|
||||
)
|
||||
Reference in New Issue
Block a user