mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 00:16:57 +08:00
refactor: complete durable transfer execution settlement
This commit is contained in:
@@ -1,14 +1,17 @@
|
||||
"""旧待整理 Oper 的精确插件兼容与租约 fencing 测试。"""
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy import create_engine, select, update
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.db import base as db_base
|
||||
from app.db.models.transferexecutionstep import TransferExecutionStep
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.db.models.transferpending import TransferPending
|
||||
from app.db.oper.transferpending import TransferPendingOper as CanonicalTransferPendingOper
|
||||
from app.runtime.compat.manifest import MODULE_ALIASES
|
||||
@@ -19,6 +22,8 @@ def legacy_session_factory(tmp_path, monkeypatch):
|
||||
"""为无 Session 兼容 Oper 提供独占事务,并在提交后保留返回快照。"""
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'legacy-transferpending.db'}")
|
||||
TransferPending.__table__.create(engine)
|
||||
TransferExecutionStep.__table__.create(engine)
|
||||
TransferHistory.__table__.create(engine)
|
||||
factory = sessionmaker(bind=engine)
|
||||
|
||||
def run_transaction(operation: Callable[[Session], Any]) -> Any:
|
||||
@@ -49,6 +54,8 @@ def test_legacy_import_targets_private_sdk_facade() -> None:
|
||||
assert legacy is importlib.import_module(alias.target)
|
||||
assert legacy.__all__ == ["TransferPendingOper"]
|
||||
assert not hasattr(legacy, "TransferPending")
|
||||
assert not hasattr(legacy, "TransferExecutionStep")
|
||||
assert not hasattr(legacy, "TransferExecutionState")
|
||||
assert legacy.TransferPendingOper is not CanonicalTransferPendingOper
|
||||
for internal_method in (
|
||||
"stage_admit",
|
||||
@@ -59,6 +66,55 @@ def test_legacy_import_targets_private_sdk_facade() -> None:
|
||||
assert not hasattr(legacy.TransferPendingOper, internal_method)
|
||||
|
||||
|
||||
def test_legacy_oper_preserves_exact_public_method_abi() -> None:
|
||||
"""兼容门面只公开历史八方法,且位置参数与关键字参数边界保持不变。"""
|
||||
legacy = importlib.import_module("app.db.transferpending_oper")
|
||||
oper_type = legacy.TransferPendingOper
|
||||
public_methods = {
|
||||
name
|
||||
for name, value in oper_type.__dict__.items()
|
||||
if not name.startswith("_") and callable(value)
|
||||
}
|
||||
|
||||
assert public_methods == {
|
||||
"register",
|
||||
"list_by_state",
|
||||
"list_by_states",
|
||||
"get_by_identity",
|
||||
"get_by_task_id",
|
||||
"discard",
|
||||
"list_all",
|
||||
"clear",
|
||||
}
|
||||
assert str(inspect.signature(oper_type.register)) == (
|
||||
"(self, storage: str, src_path: str) -> "
|
||||
"app.db.models.transferpending.TransferPending | None"
|
||||
)
|
||||
assert str(inspect.signature(oper_type.list_by_state)) == (
|
||||
"(self, *, state: str, limit: int | None = 5000) -> "
|
||||
"List[app.db.models.transferpending.TransferPending]"
|
||||
)
|
||||
assert str(inspect.signature(oper_type.list_by_states)) == (
|
||||
"(self, *, states: tuple[str, ...], limit: int | None = 5000) -> "
|
||||
"List[app.db.models.transferpending.TransferPending]"
|
||||
)
|
||||
assert str(inspect.signature(oper_type.get_by_identity)) == (
|
||||
"(self, *, storage: str, src_path: str) -> "
|
||||
"app.db.models.transferpending.TransferPending | None"
|
||||
)
|
||||
assert str(inspect.signature(oper_type.get_by_task_id)) == (
|
||||
"(self, *, task_id: str) -> "
|
||||
"app.db.models.transferpending.TransferPending | None"
|
||||
)
|
||||
assert str(inspect.signature(oper_type.discard)) == (
|
||||
"(self, storage: str, src_path: str) -> int"
|
||||
)
|
||||
assert str(inspect.signature(oper_type.list_all)) == (
|
||||
"(self, limit: int | None = 5000) -> List[Tuple[str, str]]"
|
||||
)
|
||||
assert str(inspect.signature(oper_type.clear)) == "(self) -> int"
|
||||
|
||||
|
||||
def test_legacy_no_session_queries_preserve_historical_shapes(
|
||||
legacy_session_factory,
|
||||
) -> None:
|
||||
@@ -96,10 +152,35 @@ def test_legacy_no_session_queries_preserve_historical_shapes(
|
||||
assert oper.list_by_states(states=()) == []
|
||||
|
||||
|
||||
def test_legacy_register_allows_new_task_for_previously_settled_source(
|
||||
legacy_session_factory,
|
||||
) -> None:
|
||||
"""旧插件登记入口允许同源文件形成新的合法任务世代。"""
|
||||
with legacy_session_factory() as session:
|
||||
session.add(TransferHistory(
|
||||
transfer_task_id="settled-task",
|
||||
transfer_settlement_revision=1,
|
||||
src="/downloads/settled.mkv",
|
||||
src_storage="local",
|
||||
status=True,
|
||||
))
|
||||
session.commit()
|
||||
|
||||
legacy = importlib.import_module("app.db.transferpending_oper")
|
||||
pending = legacy.TransferPendingOper().register(
|
||||
"local",
|
||||
"/downloads/settled.mkv",
|
||||
)
|
||||
assert pending is not None
|
||||
assert pending.task_id != "settled-task"
|
||||
with legacy_session_factory() as session:
|
||||
assert session.execute(select(TransferPending)).scalar_one().task_id == pending.task_id
|
||||
|
||||
|
||||
def test_legacy_mutations_never_delete_claimed_rows(
|
||||
legacy_session_factory,
|
||||
) -> None:
|
||||
"""旧 discard/clear 只处理未 claim 行,有效或过期 token 均受保护。"""
|
||||
"""旧 discard/clear 只处理新鲜行,有效或过期 token 均受保护。"""
|
||||
legacy = importlib.import_module("app.db.transferpending_oper")
|
||||
oper = legacy.TransferPendingOper()
|
||||
active = oper.register("local", "/downloads/active.mkv")
|
||||
@@ -156,3 +237,204 @@ def test_legacy_mutations_never_delete_claimed_rows(
|
||||
(active.task_id, "active-token"),
|
||||
(expired.task_id, "expired-token"),
|
||||
]
|
||||
|
||||
|
||||
def test_legacy_mutations_protect_every_execution_and_terminal_state(
|
||||
legacy_session_factory,
|
||||
) -> None:
|
||||
"""无租约的运行、等待、结算、失败和人工判定任务仍归状态机所有。"""
|
||||
legacy = importlib.import_module("app.db.transferpending_oper")
|
||||
oper = legacy.TransferPendingOper()
|
||||
protected = {
|
||||
state: oper.register("local", f"/downloads/{state}.mkv")
|
||||
for state in (
|
||||
"running",
|
||||
"retry_wait",
|
||||
"settling",
|
||||
"failed",
|
||||
"manual_review",
|
||||
)
|
||||
}
|
||||
expired = oper.register("local", "/downloads/expired-running.mkv")
|
||||
safe_discard = oper.register("local", "/downloads/safe-discard.mkv")
|
||||
safe_clear = oper.register("local", "/downloads/safe-clear.mkv")
|
||||
assert all(protected.values())
|
||||
assert expired is not None
|
||||
assert safe_discard is not None
|
||||
assert safe_clear is not None
|
||||
|
||||
with legacy_session_factory() as session:
|
||||
for state, row in protected.items():
|
||||
assert row is not None
|
||||
session.execute(
|
||||
update(TransferPending)
|
||||
.where(TransferPending.task_id == row.task_id)
|
||||
.values(execution_state=state)
|
||||
)
|
||||
session.execute(
|
||||
update(TransferPending)
|
||||
.where(TransferPending.task_id == expired.task_id)
|
||||
.values(
|
||||
execution_state="running",
|
||||
lease_owner="expired-worker",
|
||||
lease_token="expired-token",
|
||||
lease_expires_at="2026-08-27 09:59:00.000000",
|
||||
heartbeat_at="2026-08-27 09:58:00.000000",
|
||||
attempt_count=1,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
assert oper.discard("local", safe_discard.src_path) == 1
|
||||
for state, row in protected.items():
|
||||
assert row is not None
|
||||
assert oper.discard("local", row.src_path) == 0, state
|
||||
assert oper.discard("local", expired.src_path) == 0
|
||||
assert oper.clear() == 1
|
||||
assert set(oper.list_all()) == {
|
||||
("local", row.src_path)
|
||||
for row in (*protected.values(), expired)
|
||||
if row is not None
|
||||
}
|
||||
|
||||
|
||||
def test_legacy_mutations_protect_execution_evidence_even_if_not_started(
|
||||
legacy_session_factory,
|
||||
) -> None:
|
||||
"""状态字段异常回退时,claim、步骤、重试和结算证据仍阻止旧接口删除。"""
|
||||
legacy = importlib.import_module("app.db.transferpending_oper")
|
||||
oper = legacy.TransferPendingOper()
|
||||
evidence_rows = {
|
||||
name: oper.register("local", f"/downloads/evidence-{name}.mkv")
|
||||
for name in (
|
||||
"claim",
|
||||
"checkpoint",
|
||||
"retry",
|
||||
"settlement",
|
||||
"error",
|
||||
"step",
|
||||
"history",
|
||||
)
|
||||
}
|
||||
safe = oper.register("local", "/downloads/evidence-free.mkv")
|
||||
assert all(evidence_rows.values())
|
||||
assert safe is not None
|
||||
|
||||
with legacy_session_factory() as session:
|
||||
rows = {
|
||||
name: row
|
||||
for name, row in evidence_rows.items()
|
||||
if row is not None
|
||||
}
|
||||
session.execute(
|
||||
update(TransferPending)
|
||||
.where(TransferPending.task_id == rows["claim"].task_id)
|
||||
.values(attempt_count=1)
|
||||
)
|
||||
session.execute(
|
||||
update(TransferPending)
|
||||
.where(TransferPending.task_id == rows["checkpoint"].task_id)
|
||||
.values(
|
||||
execution_version=1,
|
||||
execution_payload={"operation_ids": ["op-checkpoint"]},
|
||||
execution_fingerprint="f" * 64,
|
||||
)
|
||||
)
|
||||
session.execute(
|
||||
update(TransferPending)
|
||||
.where(TransferPending.task_id == rows["retry"].task_id)
|
||||
.values(
|
||||
retry_generation=1,
|
||||
retry_count=1,
|
||||
retry_due_at="2026-08-27 12:00:00.000000",
|
||||
)
|
||||
)
|
||||
session.execute(
|
||||
update(TransferPending)
|
||||
.where(TransferPending.task_id == rows["settlement"].task_id)
|
||||
.values(settlement_revision=1, terminal_history_id=42)
|
||||
)
|
||||
session.execute(
|
||||
update(TransferPending)
|
||||
.where(TransferPending.task_id == rows["error"].task_id)
|
||||
.values(last_error="execution outcome unknown")
|
||||
)
|
||||
session.add(
|
||||
TransferExecutionStep(
|
||||
task_id=rows["step"].task_id,
|
||||
operation_id="op-step-evidence",
|
||||
checkpoint_fingerprint="c" * 64,
|
||||
ordinal=0,
|
||||
phase="materialize",
|
||||
kind="copy",
|
||||
state="prepared",
|
||||
attempt_count=0,
|
||||
intent_version=1,
|
||||
intent_payload={"src": rows["step"].src_path},
|
||||
prepared_at="2026-08-27 10:00:00.000000",
|
||||
updated_at="2026-08-27 10:00:00.000000",
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
TransferHistory(
|
||||
transfer_task_id=rows["history"].task_id,
|
||||
transfer_settlement_revision=1,
|
||||
src=rows["history"].src_path,
|
||||
src_storage="local",
|
||||
status=True,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
for name, row in evidence_rows.items():
|
||||
assert row is not None
|
||||
assert oper.discard("local", row.src_path) == 0, name
|
||||
assert oper.clear() == 1
|
||||
assert set(oper.list_all()) == {
|
||||
("local", row.src_path)
|
||||
for row in evidence_rows.values()
|
||||
if row is not None
|
||||
}
|
||||
|
||||
|
||||
def test_legacy_register_returns_existing_terminal_or_manual_review_task(
|
||||
legacy_session_factory,
|
||||
) -> None:
|
||||
"""重复登记不得绕过失败或人工判定任务创建平行执行身份。"""
|
||||
legacy = importlib.import_module("app.db.transferpending_oper")
|
||||
oper = legacy.TransferPendingOper()
|
||||
failed = oper.register("local", "/downloads/retry-failed.mkv")
|
||||
manual = oper.register("local", "/downloads/retry-manual.mkv")
|
||||
assert failed is not None
|
||||
assert manual is not None
|
||||
|
||||
with legacy_session_factory() as session:
|
||||
session.execute(
|
||||
update(TransferPending)
|
||||
.where(TransferPending.task_id == failed.task_id)
|
||||
.values(
|
||||
execution_state="failed",
|
||||
settlement_revision=1,
|
||||
terminal_history_id=81,
|
||||
)
|
||||
)
|
||||
session.execute(
|
||||
update(TransferPending)
|
||||
.where(TransferPending.task_id == manual.task_id)
|
||||
.values(
|
||||
execution_state="manual_review",
|
||||
last_error="provider outcome unknown",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
repeated_failed = oper.register("local", failed.src_path)
|
||||
repeated_manual = oper.register("local", manual.src_path)
|
||||
assert repeated_failed is not None
|
||||
assert repeated_failed.task_id == failed.task_id
|
||||
assert repeated_failed.execution_state == "failed"
|
||||
assert repeated_failed.terminal_history_id == 81
|
||||
assert repeated_manual is not None
|
||||
assert repeated_manual.task_id == manual.task_id
|
||||
assert repeated_manual.execution_state == "manual_review"
|
||||
assert repeated_manual.last_error == "provider outcome unknown"
|
||||
|
||||
Reference in New Issue
Block a user