Files
MoviePilot/tests/test_transfer_settling_recovery.py
T

685 lines
23 KiB
Python

"""验证 settling 终态在崩溃后只重放持久结算。"""
import threading
import time
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.application.chain.events import TransferResultSettlement
from app.application.transfer.execution import (
TransferExecutionCheckpoint,
TransferExecutionCommand,
TransferExecutionSnapshot,
TransferExecutionState,
TransferOperationObservation,
TransferOperationObservationState,
TransferStepIntent,
TransferStepResult,
)
from app.application.transfer.workflow import (
TransferAdmission,
TransferPlanCheckpoint,
TransferPlanItem,
TransferPlanningInput,
TransferTask,
)
from app.chain import transfer as transfer_chain_module
from app.chain.transfer import TransferChain
from app.db.adapters.chain import TransactionalChainDurableEventWriter
from app.db.adapters.transfer.admission import TransactionalTransferAdmissionRepository
from app.db.adapters.transfer.execution import TransactionalTransferExecutionRepository
from app.db.models.transferexecutionstep import TransferExecutionStep
from app.db.models.transferhistory import TransferHistory
from app.db.models.transferpending import TransferPending
from app.db.models.transfersettlementreceipt import TransferSettlementReceipt
from app.schemas.file import FileItem
from app.schemas.transfer import TransferInfo
def _planning_input(path: str) -> TransferPlanningInput:
"""构造保留源文件身份的最小持久规划输入。"""
return TransferPlanningInput(
source_fileitem={
"storage": "local",
"path": path,
"type": "file",
"name": Path(path).name,
"basename": Path(path).stem,
"extension": Path(path).suffix.lstrip("."),
"size": 1024,
},
meta=None,
mediainfo=None,
requested_transfer_type="move",
)
def _plan_checkpoint(
planning_input: TransferPlanningInput,
) -> TransferPlanCheckpoint:
"""构造外部步骤已经结束后可直接结算的冻结计划。"""
return TransferPlanCheckpoint(
planning_input=planning_input,
target_storage="local",
root_target_path="/library",
final_target_path="/library/Movie.mkv",
resolved_transfer_type="move",
items=(),
need_notify=False,
skip_reason="测试已完成外部步骤",
)
def _transfer_result(path: str, *, success: bool) -> TransferInfo:
"""构造可完整写入 execution checkpoint 的整理结果。"""
fileitem = FileItem(
storage="local",
path=path,
type="file",
name=Path(path).name,
basename=Path(path).stem,
extension=Path(path).suffix.lstrip("."),
size=1024,
)
return TransferInfo(
success=success,
fileitem=fileitem,
target_item=(
FileItem(
storage="local",
path="/library/Movie.mkv",
type="file",
name="Movie.mkv",
)
if success
else None
),
transfer_type="move",
fail_list=[] if success else [path],
message="整理完成" if success else "整理失败",
need_notify=False,
)
def _execution_checkpoint(
path: str,
*,
success: bool,
include_transferinfo: bool = True,
) -> TransferExecutionCheckpoint:
"""构造成功或确定失败的聚合执行检查点。"""
payload = {
"outcome": "succeeded" if success else "failed",
"error": None if success else "整理失败",
}
if include_transferinfo:
payload["transferinfo"] = _transfer_result(
path,
success=success,
).model_dump(mode="json")
return TransferExecutionCheckpoint.create(
payload=payload,
operation_ids=("operation-1",),
)
def _add_settling_pending(
factory,
*,
path: str,
lease_state: str,
) -> tuple[TransferPlanCheckpoint, TransferExecutionCheckpoint]:
"""写入带完整计划和执行检查点的 settling 任务。"""
planning_input = _planning_input(path)
plan_checkpoint = _plan_checkpoint(planning_input)
execution_checkpoint = _execution_checkpoint(path, success=True)
lease_values = {
"lease_owner": None,
"lease_token": None,
"lease_expires_at": None,
"heartbeat_at": None,
"attempt_count": 0,
}
if lease_state == "expired":
lease_values.update({
"lease_owner": "old-owner",
"lease_token": "old-token",
"lease_expires_at": "2000-01-01 00:00:00.000000",
"heartbeat_at": "1999-12-31 23:59:00.000000",
"attempt_count": 1,
})
with factory() as session:
session.add(TransferPending(
task_id="settling-task",
storage="local",
src_path=path,
created_at="2026-08-27 09:00:00",
state="planned",
updated_at="2026-08-27 09:00:00",
input_version=planning_input.schema_version,
planning_input=planning_input.to_payload(),
input_fingerprint=planning_input.fingerprint,
checkpoint_version=plan_checkpoint.schema_version,
checkpoint_payload=plan_checkpoint.to_payload(),
planned_at="2026-08-27 09:00:00",
execution_state="settling",
execution_version=execution_checkpoint.version,
execution_payload=execution_checkpoint.to_payload(),
execution_fingerprint=execution_checkpoint.fingerprint,
retry_generation=0,
retry_count=0,
settlement_revision=0,
**lease_values,
))
session.commit()
return plan_checkpoint, execution_checkpoint
@pytest.fixture
def admission_store(tmp_path):
"""创建独立 SQLite admission 仓储及其 Session 工厂。"""
engine = create_engine(f"sqlite:///{tmp_path / 'settling-recovery.db'}")
TransferHistory.__table__.create(engine)
TransferPending.__table__.create(engine)
TransferExecutionStep.__table__.create(engine)
TransferSettlementReceipt.__table__.create(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
try:
yield TransactionalTransferAdmissionRepository(factory), factory
finally:
engine.dispose()
def _snapshot(
checkpoint: TransferExecutionCheckpoint,
) -> TransferExecutionSnapshot:
"""构造 settling 状态的脱离 Session 执行投影。"""
return TransferExecutionSnapshot(
task_id="settling-task",
state=TransferExecutionState.SETTLING,
checkpoint=checkpoint,
retry_generation=0,
retry_count=0,
retry_due_at=None,
settlement_revision=0,
terminal_history_id=None,
last_error=None,
steps=(),
)
def _build_chain(admissions) -> TransferChain:
"""构造只允许执行 settling 终态恢复的 TransferChain 骨架。"""
chain = object.__new__(TransferChain)
chain._transfer_admissions = admissions
chain._transfer_executions = MagicMock()
chain._worker_owner_id = "recovery-owner"
chain._owned_leases = {}
chain._queued_lease_tokens = set()
chain._worker_state_lock = threading.RLock()
chain._closing = False
chain._recovery_wakeup_event = threading.Event()
chain._replay_stop_event = threading.Event()
chain._lease_heartbeat_stop_event = threading.Event()
chain._lease_heartbeat_thread = None
chain._TransferChain__ensure_lease_heartbeat_owner = MagicMock()
chain._TransferChain__ensure_recovery_scheduler = MagicMock()
chain._TransferChain__restore_planned_task = MagicMock()
chain._TransferChain__select_storage_oper = MagicMock(
side_effect=AssertionError("settling 恢复不得选择存储适配器")
)
chain._plan_checkpoint_and_execute = MagicMock(
side_effect=AssertionError("settling 恢复不得重新执行计划")
)
chain.jobview = MagicMock()
return chain
def _recovered_task(
chain: TransferChain,
admission: TransferAdmission,
execution_checkpoint: TransferExecutionCheckpoint,
) -> TransferTask:
"""把 claim 投影绑定为只待终态 writer 处理的恢复任务。"""
assert admission.planning_input is not None
assert admission.checkpoint is not None
assert admission.lease_owner is not None
assert admission.lease_token is not None
task = TransferTask(
fileitem=FileItem.model_validate(
admission.planning_input.source_fileitem
)
)
task.bind_admission_task_id(admission.task_id)
task.bind_planning_input(admission.planning_input)
task.bind_plan_checkpoint(admission.checkpoint)
task.bind_execution_checkpoint(execution_checkpoint)
task.bind_execution_lease(
owner_id=admission.lease_owner,
lease_token=admission.lease_token,
)
chain._owned_leases[admission.task_id] = (
admission.lease_token,
time.monotonic() + 120,
)
chain._transfer_executions.get_snapshot.return_value = TransferExecutionSnapshot(
task_id=admission.task_id,
state=TransferExecutionState.SETTLING,
checkpoint=execution_checkpoint,
retry_generation=0,
retry_count=0,
retry_due_at=None,
settlement_revision=0,
terminal_history_id=None,
last_error=None,
steps=(),
)
return task
@pytest.mark.parametrize("lease_state", ["missing", "expired"])
def test_settling_task_can_be_claimed_by_only_one_owner(
admission_store,
lease_state,
) -> None:
"""空租约和过期租约的 settling 任务都只能由一个 worker 取得。"""
repository, factory = admission_store
_add_settling_pending(
factory,
path="/downloads/Movie.mkv",
lease_state=lease_state,
)
claimed = repository.claim_recoverable(
owner_id="first-owner",
limit=1,
lease_seconds=120,
)
competing = repository.claim_recoverable(
owner_id="second-owner",
limit=1,
lease_seconds=120,
)
assert len(claimed) == 1
assert claimed[0].lease_owner == "first-owner"
assert competing == []
with factory() as session:
pending = session.execute(select(TransferPending)).scalar_one()
assert pending.execution_state == "settling"
assert pending.lease_token == claimed[0].lease_token
@pytest.mark.parametrize(
("success", "include_transferinfo"),
[(True, True), (False, False)],
)
def test_settling_result_calls_only_task_aware_terminal_writer(
success,
include_transferinfo,
) -> None:
"""成功和确定失败都只从检查点恢复结果并调用携带 task 的 writer。"""
path = "/downloads/Movie.mkv"
planning_input = _planning_input(path)
plan_checkpoint = _plan_checkpoint(planning_input)
execution_checkpoint = _execution_checkpoint(
path,
success=success,
include_transferinfo=include_transferinfo,
)
admission = TransferAdmission(
task_id="settling-task",
storage="local",
src_path=path,
state="planned",
created_at="2026-08-27 09:00:00",
updated_at="2026-08-27 09:00:00",
planning_input=planning_input,
checkpoint=plan_checkpoint,
lease_owner="recovery-owner",
lease_token="recovery-token",
)
chain = _build_chain(MagicMock())
task = _recovered_task(chain, admission, execution_checkpoint)
writer_calls = []
def terminal_writer(
callback_task: TransferTask,
transferinfo: TransferInfo,
) -> tuple[bool, str]:
"""记录 task-aware 终态 writer 收到的恢复事实。"""
writer_calls.append((callback_task, transferinfo))
return transferinfo.success, transferinfo.message or ""
result = chain._TransferChain__handle_planned_transfer(
task,
terminal_writer,
)
assert result[0] is success
assert len(writer_calls) == 1
assert writer_calls[0][0] is task
assert writer_calls[0][0].execution_checkpoint == execution_checkpoint
assert writer_calls[0][1].success is success
chain._TransferChain__select_storage_oper.assert_not_called()
chain._plan_checkpoint_and_execute.assert_not_called()
def test_replay_settling_uses_frozen_source_without_filesystem_probe(
monkeypatch,
) -> None:
"""move 后源文件已消失时,settling 回放仍应直接入队结算。"""
path = "/already-moved/Movie.mkv"
planning_input = _planning_input(path)
plan_checkpoint = _plan_checkpoint(planning_input)
execution_checkpoint = _execution_checkpoint(path, success=True)
admission = TransferAdmission(
task_id="settling-task",
storage="local",
src_path=path,
state="planned",
created_at="2026-08-27 09:00:00",
updated_at="2026-08-27 09:00:00",
planning_input=planning_input,
checkpoint=plan_checkpoint,
lease_owner="recovery-owner",
lease_token="recovery-token",
)
admissions = MagicMock()
admissions.claim_recoverable.return_value = [admission]
executions = MagicMock()
executions.get_snapshot.return_value = _snapshot(execution_checkpoint)
chain = _build_chain(admissions)
chain._transfer_executions = executions
chain.put_to_queue = MagicMock(return_value=True)
def reject_stat(*_args, **_kwargs):
"""任何源文件探测都表示 settling 恢复走回了旧执行路径。"""
pytest.fail("settling 恢复不得探测已经移动的源文件")
monkeypatch.setattr(Path, "stat", reject_stat)
chain._TransferChain__replay_pending()
queued_task = chain.put_to_queue.call_args.args[0]
assert queued_task.fileitem.path == path
assert queued_task.execution_checkpoint == execution_checkpoint
admissions.abandon_unstarted.assert_not_called()
admissions.release_claim.assert_not_called()
chain._plan_checkpoint_and_execute.assert_not_called()
def test_writer_failure_releases_and_reclaims_same_settling_checkpoint(
admission_store,
) -> None:
"""writer 临时失败后释放租约,再次恢复不得重新执行外部步骤。"""
repository, factory = admission_store
_, execution_checkpoint = _add_settling_pending(
factory,
path="/downloads/Movie.mkv",
lease_state="missing",
)
first_admission = repository.claim_recoverable(
owner_id="recovery-owner",
limit=1,
lease_seconds=120,
)[0]
chain = _build_chain(repository)
first_task = _recovered_task(
chain,
first_admission,
execution_checkpoint,
)
def unavailable_writer(*_args, **_kwargs):
"""模拟历史与 pending 原子 writer 的暂时性数据库失败。"""
raise RuntimeError("writer temporarily unavailable")
with pytest.raises(RuntimeError, match="temporarily unavailable"):
chain._TransferChain__handle_planned_transfer(
first_task,
unavailable_writer,
)
assert chain._TransferChain__release_task_claim(
first_task,
error="writer temporarily unavailable",
)
chain._worker_owner_id = "second-recovery-owner"
second_admission = repository.claim_recoverable(
owner_id="second-recovery-owner",
limit=1,
lease_seconds=120,
)[0]
second_task = _recovered_task(
chain,
second_admission,
execution_checkpoint,
)
settled = []
def available_writer(
callback_task: TransferTask,
transferinfo: TransferInfo,
) -> tuple[bool, str]:
"""模拟下一轮恢复时恢复正常的 task-aware writer。"""
settled.append((callback_task, transferinfo))
return transferinfo.success, transferinfo.message or ""
result = chain._TransferChain__handle_planned_transfer(
second_task,
available_writer,
)
assert result == (True, "整理完成")
assert len(settled) == 1
assert settled[0][0].execution_checkpoint == execution_checkpoint
assert second_admission.lease_token != first_admission.lease_token
chain._TransferChain__select_storage_oper.assert_not_called()
chain._plan_checkpoint_and_execute.assert_not_called()
def test_bound_checkpoint_is_not_restored_outside_settling() -> None:
"""旧终态检查点留在 retry_wait 时不得跳过步骤恢复直接再次结算。"""
path = "/downloads/retry-state.mkv"
planning_input = _planning_input(path)
plan_checkpoint = _plan_checkpoint(planning_input)
execution_checkpoint = _execution_checkpoint(path, success=False)
admission = TransferAdmission(
task_id="retry-task",
storage="local",
src_path=path,
state="planned",
created_at="2026-08-27 09:00:00",
updated_at="2026-08-27 09:00:00",
planning_input=planning_input,
checkpoint=plan_checkpoint,
lease_owner="recovery-owner",
lease_token="retry-token",
)
chain = _build_chain(MagicMock())
task = _recovered_task(chain, admission, execution_checkpoint)
chain._transfer_executions.get_snapshot.return_value = TransferExecutionSnapshot(
task_id=admission.task_id,
state=TransferExecutionState.RETRY_WAIT,
checkpoint=execution_checkpoint,
retry_generation=1,
retry_count=1,
retry_due_at="2026-08-27 09:00:00.000000",
settlement_revision=1,
terminal_history_id=41,
last_error="copy failed",
steps=(),
)
restored = chain._TransferChain__restore_settling_transfer_result(task)
assert restored is None
def test_failed_settlement_retry_replays_step_and_commits_new_receipt(
admission_store,
) -> None:
"""失败结算请求重试后应恢复 FAILED 步骤,并以新检查点完成下一版结算。"""
admissions, factory = admission_store
path = "/downloads/retry-success.mkv"
planning_input = _planning_input(path)
plan_checkpoint = TransferPlanCheckpoint(
planning_input=planning_input,
target_storage="local",
root_target_path="/library",
final_target_path="/library/retry-success.mkv",
resolved_transfer_type="copy",
items=(TransferPlanItem(
sequence=0,
source_fileitem=planning_input.source_fileitem,
target_storage="local",
target_path="/library/retry-success.mkv",
),),
need_notify=False,
)
admitted = admissions.admit(
storage="local",
src_path=path,
planning_input=planning_input,
)
first_claim = admissions.claim_task(
task_id=admitted.task_id,
owner_id="first-owner",
lease_seconds=120,
)
assert first_claim is not None
assert first_claim.lease_token is not None
admissions.checkpoint_plan(
task_id=admitted.task_id,
lease_token=first_claim.lease_token,
input_fingerprint=planning_input.fingerprint,
checkpoint=plan_checkpoint,
)
executions = TransactionalTransferExecutionRepository(factory)
command = TransferExecutionCommand(
executions,
attempt_token_factory=iter(("attempt-1", "attempt-2")).__next__,
)
plan_fingerprint = (
TransferChain._TransferChain__transfer_plan_fingerprint(plan_checkpoint)
)
intent = TransferStepIntent.create(
task_id=admitted.task_id,
checkpoint_fingerprint=plan_fingerprint,
ordinal=0,
phase="transfer",
kind="copy",
payload={"source": path, "target": "/library/retry-success.mkv"},
)
prepared = command.prepare(
task_id=admitted.task_id,
lease_token=first_claim.lease_token,
intent=intent,
)
started = command.begin(
task_id=admitted.task_id,
lease_token=first_claim.lease_token,
operation_id=prepared.operation_id,
)
exhausted = command.exhaust(
task_id=admitted.task_id,
lease_token=first_claim.lease_token,
step=started,
error="copy failed",
evidence=TransferStepResult(payload={"target_exists": False}),
)
assert exhausted.checkpoint is not None
writer = TransactionalChainDurableEventWriter(factory)
first_settlement = writer.transfer_result(
topic=None,
stage_history=lambda repository: repository.add_force(
src=path,
src_storage="local",
status=False,
errmsg="copy failed",
),
event_payload={},
publish=None,
settlement=TransferResultSettlement(
task_id=admitted.task_id,
lease_token=first_claim.lease_token,
execution_fingerprint=exhausted.checkpoint.fingerprint,
outcome="failed",
error="copy failed",
),
)
assert first_settlement is not None
retry = command.request_retry(
task_id=admitted.task_id,
reason="manual retry",
requested_by="test",
)
assert retry.accepted is True
chain = _build_chain(admissions)
chain._transfer_executions = executions
chain.put_to_queue = MagicMock(return_value=True)
chain._TransferChain__replay_pending()
replayed_task = chain.put_to_queue.call_args.args[0]
assert replayed_task.execution_checkpoint is None
assert replayed_task.lease_token is not None
runner = transfer_chain_module._DurableTransferStepRunner(
task_id=admitted.task_id,
lease_token=replayed_task.lease_token,
checkpoint_fingerprint=plan_fingerprint,
repository=executions,
)
resumed = []
step_result = runner.run(
phase="transfer",
kind="copy",
payload={"source": path, "target": "/library/retry-success.mkv"},
execute=lambda: resumed.append("executed") or TransferStepResult(
payload={"target_exists": True}
),
observe=lambda: TransferOperationObservation(
state=TransferOperationObservationState.NOT_APPLIED,
evidence=TransferStepResult(payload={"target_exists": False}),
),
)
assert step_result.payload == {"target_exists": True}
assert resumed == ["executed"]
new_checkpoint = runner.checkpoint(_transfer_result(path, success=True))
assert new_checkpoint.fingerprint != exhausted.checkpoint.fingerprint
second_settlement = writer.transfer_result(
topic=None,
stage_history=lambda repository: repository.add_force(
src=path,
src_storage="local",
status=True,
errmsg=None,
),
event_payload={},
publish=None,
settlement=TransferResultSettlement(
task_id=admitted.task_id,
lease_token=replayed_task.lease_token,
execution_fingerprint=new_checkpoint.fingerprint,
outcome="succeeded",
),
)
assert second_settlement is not None
with factory() as session:
receipts = session.scalars(
select(TransferSettlementReceipt).order_by(
TransferSettlementReceipt.settlement_revision
)
).all()
pending = session.scalar(select(TransferPending))
steps = session.scalars(select(TransferExecutionStep)).all()
assert [receipt.outcome for receipt in receipts] == ["failed", "succeeded"]
assert pending is None
assert steps == []