refactor: complete durable transfer execution settlement

This commit is contained in:
jxxghp
2026-08-27 20:27:42 +08:00
parent 8e7a553c1e
commit e82ce8447c
69 changed files with 11948 additions and 275 deletions
+336 -24
View File
@@ -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")
+5 -1
View File
@@ -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
+8
View File
@@ -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"),
+470
View File
@@ -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),
)
)
+101 -3
View File
@@ -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()
+409
View File
@@ -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,
+147
View File
@@ -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
+3
View File
@@ -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",
+243
View File
@@ -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,
)
+92 -9
View File
@@ -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
+207
View File
@@ -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,
)
)
+71
View File
@@ -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,
)