mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-31 21:17:06 +08:00
refactor(transfer): complete durable recovery state machine
This commit is contained in:
@@ -33,22 +33,49 @@ def _column_names() -> set[str]:
|
||||
}
|
||||
|
||||
|
||||
def _has_task_id_constraint() -> bool:
|
||||
"""判断稳定任务标识唯一约束是否已经存在。"""
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
return any(
|
||||
constraint.get("name") == _TASK_ID_CONSTRAINT
|
||||
for constraint in inspector.get_unique_constraints(_TABLE_NAME)
|
||||
)
|
||||
def _repair_task_id_constraint() -> None:
|
||||
"""把稳定任务身份约束修复为 task_id 单列唯一约束。"""
|
||||
constraints = {
|
||||
constraint.get("name"): tuple(constraint.get("column_names") or ())
|
||||
for constraint in sa.inspect(op.get_bind()).get_unique_constraints(
|
||||
_TABLE_NAME
|
||||
)
|
||||
if constraint.get("name")
|
||||
}
|
||||
current = constraints.get(_TASK_ID_CONSTRAINT)
|
||||
if current is not None and current != ("task_id",):
|
||||
with op.batch_alter_table(_TABLE_NAME) as batch_op:
|
||||
batch_op.drop_constraint(_TASK_ID_CONSTRAINT, type_="unique")
|
||||
current = None
|
||||
if current is None:
|
||||
with op.batch_alter_table(_TABLE_NAME) as batch_op:
|
||||
batch_op.create_unique_constraint(
|
||||
_TASK_ID_CONSTRAINT,
|
||||
["task_id"],
|
||||
)
|
||||
|
||||
|
||||
def _has_state_created_index() -> bool:
|
||||
"""判断恢复主查询的复合索引是否已经存在。"""
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
return any(
|
||||
index.get("name") == _STATE_CREATED_INDEX
|
||||
for index in inspector.get_indexes(_TABLE_NAME)
|
||||
)
|
||||
def _repair_state_created_index() -> None:
|
||||
"""按列顺序和非唯一语义修复恢复扫描索引。"""
|
||||
indexes = {
|
||||
index.get("name"): index
|
||||
for index in sa.inspect(op.get_bind()).get_indexes(_TABLE_NAME)
|
||||
if index.get("name")
|
||||
}
|
||||
current = indexes.get(_STATE_CREATED_INDEX)
|
||||
if current is not None and (
|
||||
tuple(current.get("column_names") or ()) != ("state", "created_at", "id")
|
||||
or bool(current.get("unique"))
|
||||
):
|
||||
op.drop_index(_STATE_CREATED_INDEX, table_name=_TABLE_NAME)
|
||||
current = None
|
||||
if current is None:
|
||||
op.create_index(
|
||||
_STATE_CREATED_INDEX,
|
||||
_TABLE_NAME,
|
||||
["state", "created_at", "id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def _backfill_admission_state() -> None:
|
||||
@@ -135,19 +162,8 @@ def upgrade() -> None:
|
||||
batch_op.alter_column(
|
||||
"updated_at", existing_type=sa.String(length=40), nullable=False
|
||||
)
|
||||
if not _has_task_id_constraint():
|
||||
with op.batch_alter_table(_TABLE_NAME) as batch_op:
|
||||
batch_op.create_unique_constraint(
|
||||
_TASK_ID_CONSTRAINT,
|
||||
["task_id"],
|
||||
)
|
||||
if not _has_state_created_index():
|
||||
op.create_index(
|
||||
_STATE_CREATED_INDEX,
|
||||
_TABLE_NAME,
|
||||
["state", "created_at", "id"],
|
||||
unique=False,
|
||||
)
|
||||
_repair_task_id_constraint()
|
||||
_repair_state_created_index()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
@@ -155,10 +171,20 @@ def downgrade() -> None:
|
||||
columns = _column_names()
|
||||
if not columns or not (_NEW_COLUMNS & columns):
|
||||
return
|
||||
if _has_state_created_index():
|
||||
index_names = {
|
||||
index.get("name")
|
||||
for index in sa.inspect(op.get_bind()).get_indexes(_TABLE_NAME)
|
||||
}
|
||||
if _STATE_CREATED_INDEX in index_names:
|
||||
op.drop_index(_STATE_CREATED_INDEX, table_name=_TABLE_NAME)
|
||||
constraint_names = {
|
||||
constraint.get("name")
|
||||
for constraint in sa.inspect(op.get_bind()).get_unique_constraints(
|
||||
_TABLE_NAME
|
||||
)
|
||||
}
|
||||
with op.batch_alter_table(_TABLE_NAME) as batch_op:
|
||||
if "task_id" in columns and _has_task_id_constraint():
|
||||
if "task_id" in columns and _TASK_ID_CONSTRAINT in constraint_names:
|
||||
batch_op.drop_constraint(_TASK_ID_CONSTRAINT, type_="unique")
|
||||
for column_name in ("last_error", "updated_at", "state", "task_id"):
|
||||
if column_name in columns:
|
||||
|
||||
@@ -97,27 +97,26 @@ def _backfill_planning_input() -> None:
|
||||
)
|
||||
).mappings().all()
|
||||
for row in rows:
|
||||
if (
|
||||
row["input_version"] is not None
|
||||
and row["planning_input"] is not None
|
||||
and row["input_fingerprint"]
|
||||
):
|
||||
continue
|
||||
payload = row["planning_input"]
|
||||
if not isinstance(payload, dict):
|
||||
payload = _legacy_planning_payload(row["storage"], row["src_path"])
|
||||
payload_version = payload.get("schema_version")
|
||||
input_version = row["input_version"]
|
||||
if input_version is None:
|
||||
input_version = payload_version if isinstance(payload_version, int) else 1
|
||||
input_fingerprint = row["input_fingerprint"] or _fingerprint(payload)
|
||||
if (
|
||||
not isinstance(payload_version, int)
|
||||
or isinstance(payload_version, bool)
|
||||
or payload_version != 1
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"transferpending {row['id']} 的规划输入版本不受支持: "
|
||||
f"{payload_version}"
|
||||
)
|
||||
connection.execute(
|
||||
pending.update()
|
||||
.where(pending.c.id == row["id"])
|
||||
.values(
|
||||
input_version=input_version,
|
||||
input_version=payload_version,
|
||||
planning_input=payload,
|
||||
input_fingerprint=input_fingerprint,
|
||||
input_fingerprint=_fingerprint(payload),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -35,16 +35,28 @@ def _column_names() -> set[str]:
|
||||
}
|
||||
|
||||
|
||||
def _index_names() -> set[str]:
|
||||
"""返回当前待整理登记表的索引名称集合。"""
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if _TABLE_NAME not in inspector.get_table_names():
|
||||
return set()
|
||||
return {
|
||||
index["name"]
|
||||
for index in inspector.get_indexes(_TABLE_NAME)
|
||||
def _repair_lease_index() -> None:
|
||||
"""按列顺序和非唯一语义修复租约接管索引。"""
|
||||
indexes = {
|
||||
index.get("name"): index
|
||||
for index in sa.inspect(op.get_bind()).get_indexes(_TABLE_NAME)
|
||||
if index.get("name")
|
||||
}
|
||||
current = indexes.get(_LEASE_INDEX)
|
||||
if current is not None and (
|
||||
tuple(current.get("column_names") or ())
|
||||
!= ("state", "lease_expires_at", "created_at", "id")
|
||||
or bool(current.get("unique"))
|
||||
):
|
||||
op.drop_index(_LEASE_INDEX, table_name=_TABLE_NAME)
|
||||
current = None
|
||||
if current is None:
|
||||
op.create_index(
|
||||
_LEASE_INDEX,
|
||||
_TABLE_NAME,
|
||||
["state", "lease_expires_at", "created_at", "id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
@@ -96,13 +108,7 @@ def upgrade() -> None:
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
if _LEASE_INDEX not in _index_names():
|
||||
op.create_index(
|
||||
_LEASE_INDEX,
|
||||
_TABLE_NAME,
|
||||
["state", "lease_expires_at", "created_at", "id"],
|
||||
unique=False,
|
||||
)
|
||||
_repair_lease_index()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
@@ -110,7 +116,11 @@ def downgrade() -> None:
|
||||
columns = _column_names()
|
||||
if not columns or not (_LEASE_COLUMNS & columns):
|
||||
return
|
||||
if _LEASE_INDEX in _index_names():
|
||||
index_names = {
|
||||
index.get("name")
|
||||
for index in sa.inspect(op.get_bind()).get_indexes(_TABLE_NAME)
|
||||
}
|
||||
if _LEASE_INDEX in index_names:
|
||||
op.drop_index(_LEASE_INDEX, table_name=_TABLE_NAME)
|
||||
with op.batch_alter_table(_TABLE_NAME) as batch_op:
|
||||
for column_name in (
|
||||
|
||||
@@ -10,14 +10,18 @@ Revises: 7f5c1d2e3a4b
|
||||
Create Date: 2026-08-10
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "e3d9f4b7c806"
|
||||
down_revision = "7f5c1d2e3a4b"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_TABLE_NAME = "transferpending"
|
||||
_INDEX_NAME = "ux_transferpending_storage_path"
|
||||
_EXPECTED_COLUMNS = {"id", "storage", "src_path", "created_at"}
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
"""检查数据表是否已存在。"""
|
||||
@@ -25,33 +29,110 @@ def _has_table(table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""
|
||||
创建待整理文件登记表。
|
||||
"""
|
||||
if _has_table("transferpending"):
|
||||
return
|
||||
def _table_row_count() -> int:
|
||||
"""返回待整理表行数,用于判断残缺结构能否无损重建。"""
|
||||
return op.get_bind().execute(
|
||||
sa.select(sa.func.count()).select_from(sa.table(_TABLE_NAME))
|
||||
).scalar_one()
|
||||
|
||||
|
||||
def _create_table() -> None:
|
||||
"""创建 3.0.4 定义的完整待整理登记表。"""
|
||||
op.create_table(
|
||||
"transferpending",
|
||||
_TABLE_NAME,
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("storage", sa.String, nullable=False),
|
||||
sa.Column("src_path", sa.String, nullable=False),
|
||||
sa.Column("created_at", sa.String),
|
||||
)
|
||||
# 同一个文件重复入队只保留一条,回放时不会重复送入整理链
|
||||
op.create_index(
|
||||
"ux_transferpending_storage_path",
|
||||
"transferpending",
|
||||
["storage", "src_path"],
|
||||
unique=True,
|
||||
|
||||
|
||||
def _validate_or_recreate_table() -> None:
|
||||
"""校验中断升级留下的表结构,仅允许空残表自动重建。"""
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
columns = inspector.get_columns(_TABLE_NAME)
|
||||
column_names = {column["name"] for column in columns}
|
||||
nullable = {
|
||||
column["name"]
|
||||
for column in columns
|
||||
if column.get("nullable", True)
|
||||
}
|
||||
primary_key = tuple(
|
||||
inspector.get_pk_constraint(_TABLE_NAME).get("constrained_columns") or ()
|
||||
)
|
||||
column_types = {
|
||||
column["name"]: column["type"]
|
||||
for column in columns
|
||||
}
|
||||
wrong_types = (
|
||||
not isinstance(column_types.get("id"), sa.Integer)
|
||||
or any(
|
||||
not isinstance(column_types.get(column_name), sa.String)
|
||||
for column_name in ("storage", "src_path", "created_at")
|
||||
)
|
||||
)
|
||||
malformed = (
|
||||
column_names != _EXPECTED_COLUMNS
|
||||
or nullable != {"created_at"}
|
||||
or primary_key != ("id",)
|
||||
or wrong_types
|
||||
)
|
||||
if not malformed:
|
||||
return
|
||||
if _table_row_count() > 0:
|
||||
raise RuntimeError(
|
||||
"检测到含数据的不完整 transferpending 表,"
|
||||
"无法自动恢复 3.0.4 迁移"
|
||||
)
|
||||
op.drop_table(_TABLE_NAME)
|
||||
_create_table()
|
||||
|
||||
|
||||
def _repair_storage_path_index() -> None:
|
||||
"""把源身份索引修复为指定列上的唯一索引。"""
|
||||
indexes = {
|
||||
index["name"]: index
|
||||
for index in sa.inspect(op.get_bind()).get_indexes(_TABLE_NAME)
|
||||
if index.get("name")
|
||||
}
|
||||
current = indexes.get(_INDEX_NAME)
|
||||
if current is not None and (
|
||||
tuple(current.get("column_names") or ()) != ("storage", "src_path")
|
||||
or not current.get("unique")
|
||||
):
|
||||
op.drop_index(_INDEX_NAME, table_name=_TABLE_NAME)
|
||||
current = None
|
||||
if current is None:
|
||||
op.create_index(
|
||||
_INDEX_NAME,
|
||||
_TABLE_NAME,
|
||||
["storage", "src_path"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""
|
||||
创建待整理文件登记表。
|
||||
"""
|
||||
if not _has_table(_TABLE_NAME):
|
||||
_create_table()
|
||||
else:
|
||||
_validate_or_recreate_table()
|
||||
_repair_storage_path_index()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""
|
||||
删除待整理文件登记表。
|
||||
"""
|
||||
if not _has_table("transferpending"):
|
||||
if not _has_table(_TABLE_NAME):
|
||||
return
|
||||
op.drop_index("ux_transferpending_storage_path", table_name="transferpending")
|
||||
op.drop_table("transferpending")
|
||||
index_names = {
|
||||
index["name"]
|
||||
for index in sa.inspect(op.get_bind()).get_indexes(_TABLE_NAME)
|
||||
if index.get("name")
|
||||
}
|
||||
if _INDEX_NAME in index_names:
|
||||
op.drop_index(_INDEX_NAME, table_name=_TABLE_NAME)
|
||||
op.drop_table(_TABLE_NAME)
|
||||
|
||||
@@ -5,9 +5,9 @@ Revises: d3a9e5f7b2c4
|
||||
Create Date: 2026-08-27
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
@@ -21,6 +21,7 @@ _PENDING_TABLE = "transferpending"
|
||||
_HISTORY_TABLE = "transferhistory"
|
||||
_STEP_TABLE = "transferexecutionstep"
|
||||
_RECEIPT_TABLE = "transfersettlementreceipt"
|
||||
_RECEIPT_ARCHIVE_TABLE = "transfersettlementreceipt_3_0_16_archive"
|
||||
_PENDING_INDEX = "ix_transferpending_execution_due"
|
||||
_HISTORY_INDEX = "ux_transferhistory_transfer_task_id"
|
||||
_STEP_OPERATION_UNIQUE = "uq_transferexecutionstep_operation_id"
|
||||
@@ -247,6 +248,35 @@ def _repair_indexes(
|
||||
)
|
||||
|
||||
|
||||
def _repair_owned_index(
|
||||
*,
|
||||
table_name: str,
|
||||
index_name: str,
|
||||
columns: tuple[str, ...],
|
||||
unique: bool,
|
||||
) -> None:
|
||||
"""按列顺序和唯一性精确修复宿主表上的迁移自有索引。"""
|
||||
indexes = {
|
||||
item["name"]: item
|
||||
for item in sa.inspect(op.get_bind()).get_indexes(table_name)
|
||||
if item.get("name") and not item.get("duplicates_constraint")
|
||||
}
|
||||
current = indexes.get(index_name)
|
||||
if current is not None and (
|
||||
tuple(current.get("column_names") or ()) != columns
|
||||
or bool(current.get("unique")) != unique
|
||||
):
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
current = None
|
||||
if current is None:
|
||||
op.create_index(
|
||||
index_name,
|
||||
table_name,
|
||||
list(columns),
|
||||
unique=unique,
|
||||
)
|
||||
|
||||
|
||||
def _column_type_signature(column_type: sa.types.TypeEngine) -> tuple[str, object]:
|
||||
"""把方言反射类型归一为迁移可稳定比较的类型与长度。"""
|
||||
if isinstance(column_type, sa.JSON):
|
||||
@@ -554,6 +584,27 @@ def _create_receipt_table() -> None:
|
||||
)
|
||||
|
||||
|
||||
def _restore_receipt_archive() -> None:
|
||||
"""在重升时原子恢复降级保留的 append-only 结算证据。"""
|
||||
tables = _table_names()
|
||||
if _RECEIPT_ARCHIVE_TABLE not in tables:
|
||||
return
|
||||
if _RECEIPT_TABLE not in tables:
|
||||
op.rename_table(_RECEIPT_ARCHIVE_TABLE, _RECEIPT_TABLE)
|
||||
return
|
||||
archive_count = _table_row_count(_RECEIPT_ARCHIVE_TABLE)
|
||||
live_count = _table_row_count(_RECEIPT_TABLE)
|
||||
if archive_count > 0 and live_count > 0:
|
||||
raise RuntimeError(
|
||||
"结算回执现表与降级归档同时含数据,无法自动判定恢复顺序"
|
||||
)
|
||||
if archive_count > 0:
|
||||
op.drop_table(_RECEIPT_TABLE)
|
||||
op.rename_table(_RECEIPT_ARCHIVE_TABLE, _RECEIPT_TABLE)
|
||||
else:
|
||||
op.drop_table(_RECEIPT_ARCHIVE_TABLE)
|
||||
|
||||
|
||||
def _create_or_repair_receipt_table() -> None:
|
||||
"""创建结算回执表,并只对空的残缺表执行无损重建。"""
|
||||
if _RECEIPT_TABLE not in _table_names():
|
||||
@@ -704,30 +755,29 @@ def upgrade() -> None:
|
||||
tables = _table_names()
|
||||
if _PENDING_TABLE not in tables:
|
||||
return
|
||||
_restore_receipt_archive()
|
||||
_add_pending_columns()
|
||||
_backfill_pending()
|
||||
if _PENDING_INDEX not in _index_names(_PENDING_TABLE):
|
||||
op.create_index(
|
||||
_PENDING_INDEX,
|
||||
_PENDING_TABLE,
|
||||
[
|
||||
"execution_state",
|
||||
"retry_due_at",
|
||||
"state",
|
||||
"created_at",
|
||||
"id",
|
||||
],
|
||||
unique=False,
|
||||
)
|
||||
_repair_owned_index(
|
||||
table_name=_PENDING_TABLE,
|
||||
index_name=_PENDING_INDEX,
|
||||
columns=(
|
||||
"execution_state",
|
||||
"retry_due_at",
|
||||
"state",
|
||||
"created_at",
|
||||
"id",
|
||||
),
|
||||
unique=False,
|
||||
)
|
||||
if _HISTORY_TABLE in tables:
|
||||
_add_history_columns()
|
||||
if _HISTORY_INDEX not in _index_names(_HISTORY_TABLE):
|
||||
op.create_index(
|
||||
_HISTORY_INDEX,
|
||||
_HISTORY_TABLE,
|
||||
["transfer_task_id"],
|
||||
unique=True,
|
||||
)
|
||||
_repair_owned_index(
|
||||
table_name=_HISTORY_TABLE,
|
||||
index_name=_HISTORY_INDEX,
|
||||
columns=("transfer_task_id",),
|
||||
unique=True,
|
||||
)
|
||||
_create_or_repair_receipt_table()
|
||||
_create_or_repair_step_table()
|
||||
_backfill_legacy_review_steps()
|
||||
@@ -817,8 +867,24 @@ def downgrade() -> None:
|
||||
_mark_downgrade_uncertain()
|
||||
if _STEP_TABLE in _table_names():
|
||||
op.drop_table(_STEP_TABLE)
|
||||
if _RECEIPT_TABLE in _table_names():
|
||||
op.drop_table(_RECEIPT_TABLE)
|
||||
tables = _table_names()
|
||||
if _RECEIPT_TABLE in tables:
|
||||
live_count = _table_row_count(_RECEIPT_TABLE)
|
||||
if _RECEIPT_ARCHIVE_TABLE in tables:
|
||||
archive_count = _table_row_count(_RECEIPT_ARCHIVE_TABLE)
|
||||
if live_count > 0 and archive_count > 0:
|
||||
raise RuntimeError(
|
||||
"结算回执现表与降级归档同时含数据,拒绝覆盖恢复证据"
|
||||
)
|
||||
if archive_count > 0:
|
||||
op.drop_table(_RECEIPT_TABLE)
|
||||
live_count = 0
|
||||
else:
|
||||
op.drop_table(_RECEIPT_ARCHIVE_TABLE)
|
||||
if live_count > 0:
|
||||
op.rename_table(_RECEIPT_TABLE, _RECEIPT_ARCHIVE_TABLE)
|
||||
elif _RECEIPT_TABLE in _table_names():
|
||||
op.drop_table(_RECEIPT_TABLE)
|
||||
if _HISTORY_TABLE in _table_names():
|
||||
history_columns = _column_names(_HISTORY_TABLE)
|
||||
if _HISTORY_INDEX in _index_names(_HISTORY_TABLE):
|
||||
|
||||
@@ -0,0 +1,703 @@
|
||||
"""3.0.17 收口整理规划状态与执行恢复证据。
|
||||
|
||||
Revision ID: f6d8b0c2e4a7
|
||||
Revises: e5c7a9b1d3f6
|
||||
Create Date: 2026-08-27
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "f6d8b0c2e4a7"
|
||||
down_revision = "e5c7a9b1d3f6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_PENDING_TABLE = "transferpending"
|
||||
_STEP_TABLE = "transferexecutionstep"
|
||||
_RECEIPT_TABLE = "transfersettlementreceipt"
|
||||
_HISTORY_TABLE = "transferhistory"
|
||||
_ALLOWED_EXECUTION_STATES = {
|
||||
"not_started",
|
||||
"running",
|
||||
"retry_wait",
|
||||
"settling",
|
||||
"failed",
|
||||
"manual_review",
|
||||
}
|
||||
_REVIEW_DIAGNOSTIC = "升级检测到不完整执行状态,需人工确认后再处理"
|
||||
_REVIEW_STEP_KIND = "legacy_execution_review"
|
||||
_REVIEW_STEP_ORDINAL = 2_147_483_647
|
||||
_FALLBACK_TIME = "1970-01-01 00:00:00"
|
||||
|
||||
|
||||
def _table_names() -> set[str]:
|
||||
"""返回当前数据库的表名集合。"""
|
||||
return set(sa.inspect(op.get_bind()).get_table_names())
|
||||
|
||||
|
||||
def _canonical_json(payload: dict[str, Any]) -> str:
|
||||
"""生成与运行时一致的稳定 JSON 表达。"""
|
||||
return json.dumps(
|
||||
payload,
|
||||
ensure_ascii=True,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
)
|
||||
|
||||
|
||||
def _fingerprint(payload: dict[str, Any]) -> str:
|
||||
"""计算版本化 JSON 的 SHA-256 指纹。"""
|
||||
return hashlib.sha256(_canonical_json(payload).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _is_nonempty_string(value: object) -> bool:
|
||||
"""判断值是否为非空文本。"""
|
||||
return isinstance(value, str) and bool(value)
|
||||
|
||||
|
||||
def _is_source_fileitem(value: object) -> bool:
|
||||
"""判断 JSON 对象是否包含可恢复的源文件身份。"""
|
||||
return (
|
||||
isinstance(value, dict)
|
||||
and _is_nonempty_string(value.get("storage"))
|
||||
and _is_nonempty_string(value.get("path"))
|
||||
)
|
||||
|
||||
|
||||
def _is_optional_mapping(value: object) -> bool:
|
||||
"""判断值是否为 JSON 对象或空值。"""
|
||||
return value is None or isinstance(value, dict)
|
||||
|
||||
|
||||
def _is_optional_string(value: object) -> bool:
|
||||
"""判断值是否为字符串或空值。"""
|
||||
return value is None or isinstance(value, str)
|
||||
|
||||
|
||||
def _is_planning_input(
|
||||
value: object,
|
||||
*,
|
||||
expected_fingerprint: object,
|
||||
) -> bool:
|
||||
"""按第一版运行时契约校验规划输入及其持久指纹。"""
|
||||
if not isinstance(value, dict) or value.get("schema_version") != 1:
|
||||
return False
|
||||
if not _is_source_fileitem(value.get("source_fileitem")):
|
||||
return False
|
||||
if not all(
|
||||
_is_optional_mapping(value.get(field_name))
|
||||
for field_name in ("meta", "mediainfo", "target_directory")
|
||||
):
|
||||
return False
|
||||
if not all(
|
||||
_is_optional_string(value.get(field_name))
|
||||
for field_name in (
|
||||
"target_storage",
|
||||
"target_path",
|
||||
"requested_transfer_type",
|
||||
"media_source",
|
||||
"media_id",
|
||||
"media_type",
|
||||
"overwrite_mode",
|
||||
)
|
||||
):
|
||||
return False
|
||||
if not all(
|
||||
isinstance(value.get(field_name, default), bool)
|
||||
for field_name, default in (
|
||||
("need_scrape", False),
|
||||
("need_rename", True),
|
||||
("need_notify", True),
|
||||
("preview", False),
|
||||
)
|
||||
):
|
||||
return False
|
||||
episodes = value.get("episodes_info", [])
|
||||
if (
|
||||
not isinstance(episodes, list)
|
||||
or not all(isinstance(item, dict) for item in episodes)
|
||||
or not isinstance(value.get("options", {}), dict)
|
||||
):
|
||||
return False
|
||||
try:
|
||||
return _fingerprint(value) == expected_fingerprint
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _is_provider_reference(value: object) -> bool:
|
||||
"""判断旧 provider 引用是否包含稳定身份与固定方法。"""
|
||||
return (
|
||||
isinstance(value, dict)
|
||||
and _is_nonempty_string(value.get("plugin_id"))
|
||||
and _is_nonempty_string(value.get("plugin_name"))
|
||||
and value.get("method", "transfer") == "transfer"
|
||||
)
|
||||
|
||||
|
||||
def _is_provider_invocation(value: object) -> bool:
|
||||
"""按第一版旧 ABI 契约校验 provider 调用快照。"""
|
||||
if not isinstance(value, dict) or value.get("schema_version") != 1:
|
||||
return False
|
||||
if not _is_source_fileitem(value.get("fileitem")):
|
||||
return False
|
||||
for field_name in ("meta", "mediainfo", "target_directory"):
|
||||
field_value = value.get(field_name)
|
||||
if field_value is not None and not isinstance(field_value, dict):
|
||||
return False
|
||||
for field_name in ("meta_kind", "mediainfo_kind"):
|
||||
field_value = value.get(field_name)
|
||||
if field_value is not None and not _is_nonempty_string(field_value):
|
||||
return False
|
||||
for field_name in ("target_storage", "target_path", "transfer_type"):
|
||||
field_value = value.get(field_name)
|
||||
if field_value is not None and not isinstance(field_value, str):
|
||||
return False
|
||||
for field_name in (
|
||||
"scrape",
|
||||
"library_type_folder",
|
||||
"library_category_folder",
|
||||
):
|
||||
field_value = value.get(field_name)
|
||||
if field_value is not None and not isinstance(field_value, bool):
|
||||
return False
|
||||
episodes = value.get("episodes_info", [])
|
||||
return (
|
||||
isinstance(value.get("preview", False), bool)
|
||||
and isinstance(episodes, list)
|
||||
and all(isinstance(item, dict) for item in episodes)
|
||||
)
|
||||
|
||||
|
||||
def _is_plan_item(value: object, *, sequence: int) -> bool:
|
||||
"""判断计划叶子项是否具有连续序号和完整源目标身份。"""
|
||||
return (
|
||||
isinstance(value, dict)
|
||||
and value.get("sequence") == sequence
|
||||
and _is_source_fileitem(value.get("source_fileitem"))
|
||||
and _is_nonempty_string(value.get("target_storage"))
|
||||
and _is_nonempty_string(value.get("target_path"))
|
||||
and _is_nonempty_string(value.get("action", "transfer"))
|
||||
)
|
||||
|
||||
|
||||
def _classify_checkpoint(
|
||||
*,
|
||||
checkpoint_version: object,
|
||||
checkpoint_payload: object,
|
||||
input_fingerprint: object,
|
||||
) -> str | None:
|
||||
"""把完整计划检查点分类为宿主计划或 provider 待执行态。"""
|
||||
if (
|
||||
checkpoint_version != 1
|
||||
or not isinstance(checkpoint_payload, dict)
|
||||
or checkpoint_payload.get("schema_version") != 1
|
||||
or not _is_planning_input(
|
||||
checkpoint_payload.get("planning_input"),
|
||||
expected_fingerprint=input_fingerprint,
|
||||
)
|
||||
):
|
||||
return None
|
||||
providers = checkpoint_payload.get("legacy_transfer_providers", [])
|
||||
if (
|
||||
not isinstance(providers, list)
|
||||
or not all(_is_provider_reference(item) for item in providers)
|
||||
):
|
||||
return None
|
||||
if not all(
|
||||
_is_optional_mapping(checkpoint_payload.get(field_name))
|
||||
for field_name in ("resolved_meta", "resolved_mediainfo")
|
||||
):
|
||||
return None
|
||||
if not all(
|
||||
value is None or _is_nonempty_string(value)
|
||||
for value in (
|
||||
checkpoint_payload.get("resolved_meta_kind"),
|
||||
checkpoint_payload.get("resolved_mediainfo_kind"),
|
||||
)
|
||||
):
|
||||
return None
|
||||
resolved_episodes = checkpoint_payload.get("resolved_episodes_info", [])
|
||||
if (
|
||||
not isinstance(resolved_episodes, list)
|
||||
or not all(isinstance(item, dict) for item in resolved_episodes)
|
||||
or not all(
|
||||
isinstance(checkpoint_payload.get(field_name, default), bool)
|
||||
for field_name, default in (
|
||||
("pre_execution_cleanup_completed", False),
|
||||
("need_scrape", False),
|
||||
("need_rename", False),
|
||||
("need_notify", True),
|
||||
("preview", False),
|
||||
)
|
||||
)
|
||||
or not _is_optional_string(checkpoint_payload.get("overwrite_mode"))
|
||||
or not _is_optional_string(checkpoint_payload.get("skip_reason"))
|
||||
or not _is_optional_string(checkpoint_payload.get("rejection_error"))
|
||||
):
|
||||
return None
|
||||
invocation = checkpoint_payload.get("provider_invocation")
|
||||
if invocation is not None:
|
||||
if (
|
||||
not providers
|
||||
or not _is_provider_invocation(invocation)
|
||||
or not invocation.get("meta")
|
||||
or not _is_nonempty_string(invocation.get("meta_kind"))
|
||||
or not invocation.get("mediainfo")
|
||||
or not _is_nonempty_string(invocation.get("mediainfo_kind"))
|
||||
or not checkpoint_payload.get("resolved_meta")
|
||||
or not _is_nonempty_string(checkpoint_payload.get("resolved_meta_kind"))
|
||||
or not checkpoint_payload.get("resolved_mediainfo")
|
||||
or not _is_nonempty_string(
|
||||
checkpoint_payload.get("resolved_mediainfo_kind")
|
||||
)
|
||||
or checkpoint_payload.get("pre_execution_cleanup_completed", False)
|
||||
or any(checkpoint_payload.get(field_name) for field_name in (
|
||||
"target_storage",
|
||||
"root_target_path",
|
||||
"final_target_path",
|
||||
"resolved_transfer_type",
|
||||
"items",
|
||||
"skip_reason",
|
||||
))
|
||||
):
|
||||
return None
|
||||
return "provider_pending"
|
||||
items = checkpoint_payload.get("items", [])
|
||||
rejection_error = checkpoint_payload.get("rejection_error")
|
||||
if (
|
||||
not isinstance(items, list)
|
||||
or not all(
|
||||
_is_plan_item(item, sequence=sequence)
|
||||
for sequence, item in enumerate(items)
|
||||
)
|
||||
or not all(
|
||||
_is_nonempty_string(checkpoint_payload.get(field_name))
|
||||
for field_name in (
|
||||
"target_storage",
|
||||
"root_target_path",
|
||||
"final_target_path",
|
||||
"resolved_transfer_type",
|
||||
)
|
||||
)
|
||||
or (
|
||||
not items
|
||||
and not checkpoint_payload.get("preview", False)
|
||||
and not _is_nonempty_string(checkpoint_payload.get("skip_reason"))
|
||||
and not _is_nonempty_string(rejection_error)
|
||||
)
|
||||
or (
|
||||
rejection_error is not None
|
||||
and (
|
||||
not _is_nonempty_string(rejection_error)
|
||||
or not rejection_error.strip()
|
||||
or bool(items)
|
||||
)
|
||||
)
|
||||
):
|
||||
return None
|
||||
return "planned"
|
||||
|
||||
|
||||
def _normalize_legacy_planning_states() -> None:
|
||||
"""把旧 planning manual_review 恢复为运行时可领取的稳定状态。"""
|
||||
pending = sa.table(
|
||||
_PENDING_TABLE,
|
||||
sa.column("id", sa.Integer()),
|
||||
sa.column("state", sa.String(32)),
|
||||
sa.column("input_fingerprint", sa.String(64)),
|
||||
sa.column("checkpoint_version", sa.Integer()),
|
||||
sa.column("checkpoint_payload", sa.JSON()),
|
||||
sa.column("planned_at", sa.String(40)),
|
||||
)
|
||||
bind = op.get_bind()
|
||||
rows = bind.execute(
|
||||
sa.select(
|
||||
pending.c.id,
|
||||
pending.c.state,
|
||||
pending.c.input_fingerprint,
|
||||
pending.c.checkpoint_version,
|
||||
pending.c.checkpoint_payload,
|
||||
).where(pending.c.state.in_((
|
||||
"accepted",
|
||||
"planned",
|
||||
"provider_pending",
|
||||
"manual_review",
|
||||
)))
|
||||
).mappings().all()
|
||||
for row in rows:
|
||||
state = _classify_checkpoint(
|
||||
checkpoint_version=row["checkpoint_version"],
|
||||
checkpoint_payload=row["checkpoint_payload"],
|
||||
input_fingerprint=row["input_fingerprint"],
|
||||
)
|
||||
target_state = state
|
||||
values: dict[str, object] = {"state": target_state or "accepted"}
|
||||
if target_state is None:
|
||||
values.update({
|
||||
"checkpoint_version": None,
|
||||
"checkpoint_payload": sa.null(),
|
||||
"planned_at": None,
|
||||
})
|
||||
bind.execute(
|
||||
pending.update().where(pending.c.id == row["id"]).values(**values)
|
||||
)
|
||||
|
||||
|
||||
def _is_execution_checkpoint(row: dict[str, Any]) -> bool:
|
||||
"""校验执行检查点三元组的完整性、版本和内容指纹。"""
|
||||
values = (
|
||||
row["execution_version"],
|
||||
row["execution_payload"],
|
||||
row["execution_fingerprint"],
|
||||
)
|
||||
if all(value is None for value in values):
|
||||
return True
|
||||
if (
|
||||
row["execution_version"] != 1
|
||||
or not isinstance(row["execution_payload"], dict)
|
||||
or not _is_nonempty_string(row["execution_fingerprint"])
|
||||
):
|
||||
return False
|
||||
payload = row["execution_payload"]
|
||||
operation_ids = payload.get("operation_ids")
|
||||
skip_reason = payload.get("skip_reason")
|
||||
execution_result = payload.get("payload")
|
||||
if (
|
||||
payload.get("schema_version") != 1
|
||||
or not isinstance(execution_result, dict)
|
||||
or not isinstance(operation_ids, list)
|
||||
or not all(_is_nonempty_string(item) for item in operation_ids)
|
||||
or len(operation_ids) != len(set(operation_ids))
|
||||
or (not operation_ids and not _is_nonempty_string(skip_reason))
|
||||
or (skip_reason is not None and not isinstance(skip_reason, str))
|
||||
):
|
||||
return False
|
||||
outcome = execution_result.get("outcome")
|
||||
if outcome not in {"succeeded", "failed", "overwrite_skipped"}:
|
||||
return False
|
||||
transferinfo = execution_result.get("transferinfo")
|
||||
if transferinfo is not None:
|
||||
if not isinstance(transferinfo, dict):
|
||||
return False
|
||||
if (
|
||||
bool(transferinfo.get("success")) != (outcome == "succeeded")
|
||||
or bool(transferinfo.get("overwrite_skipped"))
|
||||
!= (outcome == "overwrite_skipped")
|
||||
):
|
||||
return False
|
||||
elif outcome == "overwrite_skipped":
|
||||
return False
|
||||
try:
|
||||
return _fingerprint(payload) == row["execution_fingerprint"]
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _has_failed_receipt(row: dict[str, Any]) -> bool:
|
||||
"""判断失败 pending 是否具有匹配历史与 append-only 回执。"""
|
||||
if (
|
||||
_RECEIPT_TABLE not in _table_names()
|
||||
or _HISTORY_TABLE not in _table_names()
|
||||
or not isinstance(row["terminal_history_id"], int)
|
||||
or not isinstance(row["settlement_revision"], int)
|
||||
or row["settlement_revision"] <= 0
|
||||
):
|
||||
return False
|
||||
receipts = sa.table(
|
||||
_RECEIPT_TABLE,
|
||||
sa.column("task_id", sa.String(64)),
|
||||
sa.column("history_id", sa.Integer()),
|
||||
sa.column("settlement_revision", sa.Integer()),
|
||||
sa.column("outcome", sa.String(16)),
|
||||
)
|
||||
history = sa.table(
|
||||
_HISTORY_TABLE,
|
||||
sa.column("id", sa.Integer()),
|
||||
)
|
||||
bind = op.get_bind()
|
||||
receipt_exists = bind.execute(
|
||||
sa.select(sa.literal(1)).select_from(receipts).where(
|
||||
receipts.c.task_id == row["task_id"],
|
||||
receipts.c.history_id == row["terminal_history_id"],
|
||||
receipts.c.settlement_revision == row["settlement_revision"],
|
||||
receipts.c.outcome == "failed",
|
||||
).limit(1)
|
||||
).first() is not None
|
||||
history_exists = bind.execute(
|
||||
sa.select(sa.literal(1)).select_from(history).where(
|
||||
history.c.id == row["terminal_history_id"]
|
||||
).limit(1)
|
||||
).first() is not None
|
||||
return receipt_exists and history_exists
|
||||
|
||||
|
||||
def _checkpoint_steps_complete(row: dict[str, Any]) -> bool:
|
||||
"""判断执行检查点引用的每个外部操作是否已有确定结果。"""
|
||||
payload = row["execution_payload"]
|
||||
if not isinstance(payload, dict):
|
||||
return False
|
||||
operation_ids = payload.get("operation_ids")
|
||||
if not isinstance(operation_ids, list):
|
||||
return False
|
||||
if not operation_ids:
|
||||
return True
|
||||
steps = sa.table(
|
||||
_STEP_TABLE,
|
||||
sa.column("task_id", sa.String(64)),
|
||||
sa.column("operation_id", sa.String(64)),
|
||||
sa.column("state", sa.String(32)),
|
||||
)
|
||||
completed = set(op.get_bind().execute(
|
||||
sa.select(steps.c.operation_id).where(
|
||||
steps.c.task_id == row["task_id"],
|
||||
steps.c.operation_id.in_(operation_ids),
|
||||
steps.c.state.in_(("succeeded", "failed")),
|
||||
)
|
||||
).scalars().all())
|
||||
return completed == set(operation_ids)
|
||||
|
||||
|
||||
def _has_manual_review_step(task_id: str) -> bool:
|
||||
"""判断人工复核态是否具有运行时可发现的对应步骤证据。"""
|
||||
steps = sa.table(
|
||||
_STEP_TABLE,
|
||||
sa.column("task_id", sa.String(64)),
|
||||
sa.column("state", sa.String(32)),
|
||||
)
|
||||
return op.get_bind().execute(
|
||||
sa.select(sa.literal(1)).select_from(steps).where(
|
||||
steps.c.task_id == task_id,
|
||||
steps.c.state == "manual_review",
|
||||
).limit(1)
|
||||
).first() is not None
|
||||
|
||||
|
||||
def _execution_issue(row: dict[str, Any]) -> str | None:
|
||||
"""返回阻止执行状态安全恢复的持久不变量缺口。"""
|
||||
state = row["execution_state"]
|
||||
if state not in _ALLOWED_EXECUTION_STATES:
|
||||
return f"未知执行状态: {state}"
|
||||
checkpoint_valid = _is_execution_checkpoint(row)
|
||||
has_checkpoint = row["execution_payload"] is not None
|
||||
if not checkpoint_valid:
|
||||
return "执行检查点三元组不完整或内容无效"
|
||||
if state != "not_started" and row["state"] not in {
|
||||
"planned",
|
||||
"provider_pending",
|
||||
}:
|
||||
return "执行态缺少完整 planned 或 provider_pending 计划"
|
||||
lease_values = (
|
||||
row["lease_owner"],
|
||||
row["lease_token"],
|
||||
row["lease_expires_at"],
|
||||
)
|
||||
if state != "manual_review" and any(lease_values) and not all(lease_values):
|
||||
return "执行租约身份不完整"
|
||||
if state == "not_started" and has_checkpoint:
|
||||
return "未开始态错误携带执行检查点"
|
||||
if state == "running" and has_checkpoint:
|
||||
return "运行态错误携带终态执行检查点"
|
||||
if state == "manual_review" and has_checkpoint:
|
||||
return "人工复核态错误携带终态执行检查点"
|
||||
if state == "manual_review" and not _has_manual_review_step(row["task_id"]):
|
||||
return "人工复核态缺少步骤证据"
|
||||
if state == "retry_wait" and not row["retry_due_at"]:
|
||||
return "重试等待态缺少到期时间"
|
||||
if state == "retry_wait" and any(lease_values):
|
||||
return "重试等待态错误持有执行租约"
|
||||
if state == "retry_wait" and has_checkpoint and (
|
||||
not _checkpoint_steps_complete(row) or not _has_failed_receipt(row)
|
||||
):
|
||||
return "终态重试缺少匹配步骤、历史或结算回执"
|
||||
if state == "settling" and (
|
||||
not has_checkpoint or not _checkpoint_steps_complete(row)
|
||||
):
|
||||
return "结算态缺少完整检查点或步骤结果"
|
||||
if state == "failed" and (
|
||||
not has_checkpoint
|
||||
or any(lease_values)
|
||||
or not _checkpoint_steps_complete(row)
|
||||
or not _has_failed_receipt(row)
|
||||
):
|
||||
return "失败终态缺少匹配步骤、检查点、历史或结算回执"
|
||||
return None
|
||||
|
||||
|
||||
def _review_operation_id(task_id: str) -> str:
|
||||
"""生成 3.0.17 数据修复步骤的确定性身份。"""
|
||||
return hashlib.sha256(
|
||||
f"moviepilot:3.0.17:execution-review:{task_id}".encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _append_review_error(last_error: object, issue: str) -> str:
|
||||
"""保留原始错误并追加可识别的迁移诊断。"""
|
||||
diagnostic = f"{_REVIEW_DIAGNOSTIC}({issue})"
|
||||
if not _is_nonempty_string(last_error):
|
||||
return diagnostic
|
||||
if _REVIEW_DIAGNOSTIC in last_error:
|
||||
return last_error
|
||||
return f"{last_error}\n{diagnostic}"
|
||||
|
||||
|
||||
def _next_review_ordinal(task_id: str) -> int:
|
||||
"""选择不会覆盖既有步骤证据的高位人工复核序号。"""
|
||||
steps = sa.table(
|
||||
_STEP_TABLE,
|
||||
sa.column("task_id", sa.String(64)),
|
||||
sa.column("ordinal", sa.Integer()),
|
||||
)
|
||||
ordinals = set(op.get_bind().execute(
|
||||
sa.select(steps.c.ordinal).where(steps.c.task_id == task_id)
|
||||
).scalars().all())
|
||||
ordinal = _REVIEW_STEP_ORDINAL
|
||||
while ordinal in ordinals:
|
||||
ordinal -= 1
|
||||
return ordinal
|
||||
|
||||
|
||||
def _insert_review_step(row: dict[str, Any], *, issue: str) -> None:
|
||||
"""把非法执行组合冻结为可人工判定的 synthetic 审计步骤。"""
|
||||
operation_id = _review_operation_id(row["task_id"])
|
||||
steps = sa.table(
|
||||
_STEP_TABLE,
|
||||
sa.column("task_id", sa.String(64)),
|
||||
sa.column("operation_id", sa.String(64)),
|
||||
sa.column("checkpoint_fingerprint", sa.String(64)),
|
||||
sa.column("ordinal", sa.Integer()),
|
||||
sa.column("phase", sa.String(32)),
|
||||
sa.column("kind", sa.String(32)),
|
||||
sa.column("state", sa.String(32)),
|
||||
sa.column("attempt_token", sa.String(64)),
|
||||
sa.column("attempt_count", sa.Integer()),
|
||||
sa.column("intent_version", sa.Integer()),
|
||||
sa.column("intent_payload", sa.JSON()),
|
||||
sa.column("result_version", sa.Integer()),
|
||||
sa.column("result_payload", sa.JSON()),
|
||||
sa.column("last_error", sa.Text()),
|
||||
sa.column("prepared_at", sa.String(40)),
|
||||
sa.column("started_at", sa.String(40)),
|
||||
sa.column("completed_at", sa.String(40)),
|
||||
sa.column("updated_at", sa.String(40)),
|
||||
)
|
||||
bind = op.get_bind()
|
||||
if bind.execute(
|
||||
sa.select(sa.literal(1)).select_from(steps).where(
|
||||
steps.c.operation_id == operation_id
|
||||
).limit(1)
|
||||
).first() is not None:
|
||||
return
|
||||
evidence = {
|
||||
"schema_version": 1,
|
||||
"origin": "3.0.17_migration",
|
||||
"diagnostic": issue,
|
||||
"legacy_state": row["state"],
|
||||
"legacy_execution_state": row["execution_state"],
|
||||
"execution_version": row["execution_version"],
|
||||
"execution_payload": row["execution_payload"],
|
||||
"execution_fingerprint": row["execution_fingerprint"],
|
||||
"settlement_revision": row["settlement_revision"],
|
||||
"terminal_history_id": row["terminal_history_id"],
|
||||
"lease_owner": row["lease_owner"],
|
||||
"lease_token": row["lease_token"],
|
||||
}
|
||||
evidence_time = row["updated_at"] or row["created_at"] or _FALLBACK_TIME
|
||||
bind.execute(steps.insert().values(
|
||||
task_id=row["task_id"],
|
||||
operation_id=operation_id,
|
||||
checkpoint_fingerprint=_fingerprint(evidence),
|
||||
ordinal=_next_review_ordinal(row["task_id"]),
|
||||
phase="legacy_upgrade",
|
||||
kind=_REVIEW_STEP_KIND,
|
||||
state="manual_review",
|
||||
attempt_token=None,
|
||||
attempt_count=row["attempt_count"] or 0,
|
||||
intent_version=1,
|
||||
intent_payload=evidence,
|
||||
result_version=None,
|
||||
result_payload=None,
|
||||
last_error=_append_review_error(row["last_error"], issue),
|
||||
prepared_at=evidence_time,
|
||||
started_at=None,
|
||||
completed_at=None,
|
||||
updated_at=evidence_time,
|
||||
))
|
||||
|
||||
|
||||
def _reconcile_execution_states() -> None:
|
||||
"""隔离非法执行组合,并统一清除人工复核态残留租约。"""
|
||||
pending = sa.table(
|
||||
_PENDING_TABLE,
|
||||
sa.column("id", sa.Integer()),
|
||||
sa.column("task_id", sa.String(64)),
|
||||
sa.column("state", sa.String(32)),
|
||||
sa.column("created_at", sa.String(40)),
|
||||
sa.column("updated_at", sa.String(40)),
|
||||
sa.column("last_error", sa.Text()),
|
||||
sa.column("lease_owner", sa.String(128)),
|
||||
sa.column("lease_token", sa.String(64)),
|
||||
sa.column("lease_expires_at", sa.String(40)),
|
||||
sa.column("heartbeat_at", sa.String(40)),
|
||||
sa.column("attempt_count", sa.Integer()),
|
||||
sa.column("execution_state", sa.String(32)),
|
||||
sa.column("execution_version", sa.Integer()),
|
||||
sa.column("execution_payload", sa.JSON()),
|
||||
sa.column("execution_fingerprint", sa.String(64)),
|
||||
sa.column("retry_due_at", sa.String(40)),
|
||||
sa.column("settlement_revision", sa.Integer()),
|
||||
sa.column("terminal_history_id", sa.Integer()),
|
||||
)
|
||||
bind = op.get_bind()
|
||||
rows = [
|
||||
dict(row)
|
||||
for row in bind.execute(sa.select(pending)).mappings().all()
|
||||
]
|
||||
for row in rows:
|
||||
issue = _execution_issue(row)
|
||||
if issue is not None:
|
||||
_insert_review_step(row, issue=issue)
|
||||
bind.execute(
|
||||
pending.update().where(pending.c.id == row["id"]).values(
|
||||
execution_state="manual_review",
|
||||
execution_version=None,
|
||||
execution_payload=sa.null(),
|
||||
execution_fingerprint=None,
|
||||
lease_owner=None,
|
||||
lease_token=None,
|
||||
lease_expires_at=None,
|
||||
heartbeat_at=None,
|
||||
retry_due_at=None,
|
||||
last_error=_append_review_error(row["last_error"], issue),
|
||||
)
|
||||
)
|
||||
elif row["execution_state"] == "manual_review":
|
||||
bind.execute(
|
||||
pending.update().where(pending.c.id == row["id"]).values(
|
||||
lease_owner=None,
|
||||
lease_token=None,
|
||||
lease_expires_at=None,
|
||||
heartbeat_at=None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""归一旧人工态并隔离无法安全自动恢复的执行组合。"""
|
||||
tables = _table_names()
|
||||
if _PENDING_TABLE not in tables:
|
||||
return
|
||||
if _STEP_TABLE not in tables:
|
||||
raise RuntimeError("缺少 3.0.16 transferexecutionstep 表,拒绝执行 3.0.17")
|
||||
_normalize_legacy_planning_states()
|
||||
_reconcile_execution_states()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""保留更安全的状态归一和人工证据,结构由 3.0.16 负责降级。"""
|
||||
Reference in New Issue
Block a user