mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-02 22:17:08 +08:00
471 lines
15 KiB
Python
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),
|
|
)
|
|
)
|