Files
MoviePilot/app/db/models/transferexecutionstep.py
T

471 lines
15 KiB
Python

"""整理任务外部操作步骤的持久化模型。"""
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),
)
)