refactor: complete durable transfer execution settlement

This commit is contained in:
jxxghp
2026-08-27 20:27:42 +08:00
parent 8e7a553c1e
commit e82ce8447c
69 changed files with 11948 additions and 275 deletions
+6
View File
@@ -231,6 +231,9 @@ def configure_plugin_system_services():
from app.db.oper.subscribehistory import SubscribeHistoryOper
from app.db.oper.transferhistory import TransferHistoryOper
from app.db.adapters.transfer import TransactionalTransferAdmissionRepository
from app.db.adapters.transfer_execution import (
TransactionalTransferExecutionRepository,
)
from app.db.oper.user import UserOper
from app.db.oper.workflow import WorkflowOper, configure_workflow_legacy_writer
from app.db.oper.message import MessageOper
@@ -306,6 +309,9 @@ def configure_plugin_system_services():
transfer_pending=lambda: TransactionalTransferAdmissionRepository(
SessionFactory
),
transfer_execution=lambda: TransactionalTransferExecutionRepository(
SessionFactory
),
media_server=lambda: MediaServerOper(),
download_failure=lambda: TransactionalDownloadFailureRepository(
SessionFactory
+58 -3
View File
@@ -1441,8 +1441,8 @@
"runtime_only": true
}
},
"edge_count": 6834,
"edge_sha256": "07c0b6f24ef3ce3e7ea0fa7c1b617388e11e2453ce2b0b35bddb38ed2d7f5835",
"edge_count": 6883,
"edge_sha256": "f44ae63f222eda2fc1b8ce805ab5138550a560bef6c266e3f2fae6338a243cd2",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -2217,6 +2217,9 @@
"app.agent.tools.impl.delete_transfer_history -> app.agent.tools.tags",
"app.agent.tools.impl.delete_transfer_history -> app.application",
"app.agent.tools.impl.delete_transfer_history -> app.application.agentdata",
"app.agent.tools.impl.delete_transfer_history -> app.application.chain",
"app.agent.tools.impl.delete_transfer_history -> app.application.chain.data",
"app.agent.tools.impl.delete_transfer_history -> app.application.transfer_execution",
"app.agent.tools.impl.delete_transfer_history -> app.chain",
"app.agent.tools.impl.delete_transfer_history -> app.chain.storage",
"app.agent.tools.impl.delete_transfer_history -> app.runtime",
@@ -3306,8 +3309,11 @@
"app.api.endpoints.history -> app.api.response",
"app.api.endpoints.history -> app.application",
"app.api.endpoints.history -> app.application.agent",
"app.api.endpoints.history -> app.application.chain",
"app.api.endpoints.history -> app.application.chain.data",
"app.api.endpoints.history -> app.application.configuration",
"app.api.endpoints.history -> app.application.history",
"app.api.endpoints.history -> app.application.transfer_execution",
"app.api.endpoints.history -> app.runtime",
"app.api.endpoints.history -> app.runtime.config",
"app.api.endpoints.history -> app.runtime.log",
@@ -3814,9 +3820,12 @@
"app.api.endpoints.transfer -> app.api.dependencies.history",
"app.api.endpoints.transfer -> app.api.response",
"app.api.endpoints.transfer -> app.application",
"app.api.endpoints.transfer -> app.application.chain",
"app.api.endpoints.transfer -> app.application.chain.data",
"app.api.endpoints.transfer -> app.application.configuration",
"app.api.endpoints.transfer -> app.application.directory",
"app.api.endpoints.transfer -> app.application.history",
"app.api.endpoints.transfer -> app.application.transfer_execution",
"app.api.endpoints.transfer -> app.chain",
"app.api.endpoints.transfer -> app.chain.media",
"app.api.endpoints.transfer -> app.chain.transfer",
@@ -3975,8 +3984,10 @@
"app.application.chain.context -> app.runtime.stop",
"app.application.chain.data -> app.application",
"app.application.chain.data -> app.application.transfer",
"app.application.chain.data -> app.application.transfer_execution",
"app.application.chain.durable_events -> app.application",
"app.application.chain.durable_events -> app.application.history",
"app.application.chain.durable_events -> app.application.transfer_execution",
"app.application.chain.durable_events -> app.domain",
"app.application.chain.durable_events -> app.domain.context",
"app.application.chain.durable_events -> app.domain.meta",
@@ -4436,6 +4447,7 @@
"app.application.transfer -> app.adapters.system.host",
"app.application.transfer -> app.application",
"app.application.transfer -> app.application.agent",
"app.application.transfer -> app.application.transfer_execution",
"app.application.transfer -> app.domain",
"app.application.transfer -> app.domain.context",
"app.application.transfer -> app.domain.media",
@@ -4557,6 +4569,7 @@
"app.chain._transfer -> app.application.formatting",
"app.chain._transfer -> app.application.history",
"app.chain._transfer -> app.application.transfer",
"app.chain._transfer -> app.application.transfer_execution",
"app.chain._transfer -> app.chain",
"app.chain._transfer -> app.chain._contracts",
"app.chain._transfer -> app.chain.media",
@@ -4982,12 +4995,14 @@
"app.chain.transfer -> app.application",
"app.chain.transfer -> app.application.chain",
"app.chain.transfer -> app.application.chain.data",
"app.chain.transfer -> app.application.chain.durable_events",
"app.chain.transfer -> app.application.configuration",
"app.chain.transfer -> app.application.directory",
"app.chain.transfer -> app.application.formatting",
"app.chain.transfer -> app.application.history",
"app.chain.transfer -> app.application.outbox",
"app.chain.transfer -> app.application.transfer",
"app.chain.transfer -> app.application.transfer_execution",
"app.chain.transfer -> app.chain",
"app.chain.transfer -> app.chain._transfer",
"app.chain.transfer -> app.chain.media",
@@ -5094,12 +5109,18 @@
"app.db.adapters.chain -> app.application.chain.durable_events",
"app.db.adapters.chain -> app.application.history",
"app.db.adapters.chain -> app.application.outbox",
"app.db.adapters.chain -> app.application.transfer_execution",
"app.db.adapters.chain -> app.db",
"app.db.adapters.chain -> app.db.adapters",
"app.db.adapters.chain -> app.db.adapters.outbox",
"app.db.adapters.chain -> app.db.models",
"app.db.adapters.chain -> app.db.models.transfersettlementreceipt",
"app.db.adapters.chain -> app.db.oper",
"app.db.adapters.chain -> app.db.oper.downloadhistory",
"app.db.adapters.chain -> app.db.oper.transferexecutionstep",
"app.db.adapters.chain -> app.db.oper.transferhistory",
"app.db.adapters.chain -> app.db.oper.transferpending",
"app.db.adapters.chain -> app.db.oper.transfersettlementreceipt",
"app.db.adapters.chain -> app.db.uow",
"app.db.adapters.download -> app.db",
"app.db.adapters.download -> app.db.oper",
@@ -5153,6 +5174,16 @@
"app.db.adapters.transfer -> app.db.oper",
"app.db.adapters.transfer -> app.db.oper.transferpending",
"app.db.adapters.transfer -> app.db.uow",
"app.db.adapters.transfer_execution -> app.application",
"app.db.adapters.transfer_execution -> app.application.transfer_execution",
"app.db.adapters.transfer_execution -> app.db",
"app.db.adapters.transfer_execution -> app.db.models",
"app.db.adapters.transfer_execution -> app.db.models.transferexecutionstep",
"app.db.adapters.transfer_execution -> app.db.models.transferpending",
"app.db.adapters.transfer_execution -> app.db.oper",
"app.db.adapters.transfer_execution -> app.db.oper.transferexecutionstep",
"app.db.adapters.transfer_execution -> app.db.oper.transferpending",
"app.db.adapters.transfer_execution -> app.db.uow",
"app.db.adapters.workflow -> app.application",
"app.db.adapters.workflow -> app.application.workflow",
"app.db.adapters.workflow -> app.db",
@@ -5258,6 +5289,10 @@
"app.db.models.subscribehistory -> app.schemas.types",
"app.db.models.systemconfig -> app.db",
"app.db.models.systemconfig -> app.db.base",
"app.db.models.transferexecutionstep -> app.db",
"app.db.models.transferexecutionstep -> app.db.base",
"app.db.models.transferexecutionstep -> app.db.models",
"app.db.models.transferexecutionstep -> app.db.models.transferpending",
"app.db.models.transferhistory -> app.db",
"app.db.models.transferhistory -> app.db.base",
"app.db.models.transferhistory -> app.db.models",
@@ -5266,6 +5301,8 @@
"app.db.models.transferhistory -> app.schemas.types",
"app.db.models.transferpending -> app.db",
"app.db.models.transferpending -> app.db.base",
"app.db.models.transfersettlementreceipt -> app.db",
"app.db.models.transfersettlementreceipt -> app.db.base",
"app.db.models.user -> app.db",
"app.db.models.user -> app.db.base",
"app.db.models.userconfig -> app.db",
@@ -5345,6 +5382,10 @@
"app.db.oper.systemconfig -> app.foundation.singleton",
"app.db.oper.systemconfig -> app.schemas",
"app.db.oper.systemconfig -> app.schemas.types",
"app.db.oper.transferexecutionstep -> app.db",
"app.db.oper.transferexecutionstep -> app.db.base",
"app.db.oper.transferexecutionstep -> app.db.models",
"app.db.oper.transferexecutionstep -> app.db.models.transferexecutionstep",
"app.db.oper.transferhistory -> app.db",
"app.db.oper.transferhistory -> app.db.base",
"app.db.oper.transferhistory -> app.db.models",
@@ -5355,6 +5396,10 @@
"app.db.oper.transferpending -> app.db.base",
"app.db.oper.transferpending -> app.db.models",
"app.db.oper.transferpending -> app.db.models.transferpending",
"app.db.oper.transfersettlementreceipt -> app.db",
"app.db.oper.transfersettlementreceipt -> app.db.base",
"app.db.oper.transfersettlementreceipt -> app.db.models",
"app.db.oper.transfersettlementreceipt -> app.db.models.transfersettlementreceipt",
"app.db.oper.user -> app.db",
"app.db.oper.user -> app.db.base",
"app.db.oper.user -> app.db.models",
@@ -5808,6 +5853,7 @@
"app.modules.filemanager.module -> app.application.messaging",
"app.modules.filemanager.module -> app.application.messaging.message",
"app.modules.filemanager.module -> app.application.transfer",
"app.modules.filemanager.module -> app.application.transfer_execution",
"app.modules.filemanager.module -> app.domain",
"app.modules.filemanager.module -> app.domain.context",
"app.modules.filemanager.module -> app.domain.meta",
@@ -5960,6 +6006,7 @@
"app.modules.filemanager.transhandler -> app.application.messaging",
"app.modules.filemanager.transhandler -> app.application.messaging.message",
"app.modules.filemanager.transhandler -> app.application.transfer",
"app.modules.filemanager.transhandler -> app.application.transfer_execution",
"app.modules.filemanager.transhandler -> app.domain",
"app.modules.filemanager.transhandler -> app.domain.context",
"app.modules.filemanager.transhandler -> app.domain.meta",
@@ -7587,6 +7634,7 @@
"app.schemas.token -> app.schemas",
"app.schemas.token -> app.schemas.user",
"app.schemas.transfer -> app.schemas",
"app.schemas.transfer -> app.schemas.common",
"app.schemas.transfer -> app.schemas.context",
"app.schemas.transfer -> app.schemas.file",
"app.schemas.transfer -> app.schemas.media",
@@ -7923,6 +7971,7 @@
"app.startup.initializers.modules -> app.db.adapters.subscription",
"app.startup.initializers.modules -> app.db.adapters.transaction",
"app.startup.initializers.modules -> app.db.adapters.transfer",
"app.startup.initializers.modules -> app.db.adapters.transfer_execution",
"app.startup.initializers.modules -> app.db.adapters.workflow",
"app.startup.initializers.modules -> app.db.oper",
"app.startup.initializers.modules -> app.db.oper.agentchat",
@@ -8279,7 +8328,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 837,
"module_count": 843,
"modules": [
"app",
"app.adapters",
@@ -8627,6 +8676,7 @@
"app.application.torrent",
"app.application.torrent_cache",
"app.application.transfer",
"app.application.transfer_execution",
"app.application.workflow",
"app.chain",
"app.chain._contracts",
@@ -8678,6 +8728,7 @@
"app.db.adapters.subscription",
"app.db.adapters.transaction",
"app.db.adapters.transfer",
"app.db.adapters.transfer_execution",
"app.db.adapters.workflow",
"app.db.base",
"app.db.decorators",
@@ -8707,8 +8758,10 @@
"app.db.models.subscribe",
"app.db.models.subscribehistory",
"app.db.models.systemconfig",
"app.db.models.transferexecutionstep",
"app.db.models.transferhistory",
"app.db.models.transferpending",
"app.db.models.transfersettlementreceipt",
"app.db.models.user",
"app.db.models.userconfig",
"app.db.models.workflow",
@@ -8726,8 +8779,10 @@
"app.db.oper.subscribe",
"app.db.oper.subscribehistory",
"app.db.oper.systemconfig",
"app.db.oper.transferexecutionstep",
"app.db.oper.transferhistory",
"app.db.oper.transferpending",
"app.db.oper.transfersettlementreceipt",
"app.db.oper.user",
"app.db.oper.userconfig",
"app.db.oper.workflow",
-15
View File
@@ -149,9 +149,6 @@
"app/agent/tools/impl/delete_rule_group.py": {
"I001": 1
},
"app/agent/tools/impl/delete_transfer_history.py": {
"I001": 1
},
"app/agent/tools/impl/execute_command.py": {
"I001": 1
},
@@ -411,9 +408,6 @@
"app/db/__init__.py": {
"I001": 1
},
"app/db/adapters/chain.py": {
"I001": 1
},
"app/db/adapters/outbox.py": {
"I001": 1
},
@@ -477,9 +471,6 @@
"app/db/models/systemconfig.py": {
"I001": 1
},
"app/db/models/transferhistory.py": {
"I001": 1
},
"app/db/models/user.py": {
"I001": 1
},
@@ -1265,9 +1256,6 @@
"tests/test_capability_runtime.py": {
"I001": 1
},
"tests/test_chain_durable_events.py": {
"I001": 1
},
"tests/test_chain_layering.py": {
"I001": 1
},
@@ -1334,9 +1322,6 @@
"tests/test_db_session_lifecycle.py": {
"I001": 1
},
"tests/test_db_transferhistory_queries.py": {
"F401": 1
},
"tests/test_delete_transfer_history_tool.py": {
"I001": 1
},
+6 -1
View File
@@ -7974,7 +7974,7 @@
}
},
"run_module": {
"call_count": 265,
"call_count": 266,
"dynamic_call_count": 0,
"dynamic_calls": [],
"method_count": 215,
@@ -8801,6 +8801,11 @@
"caller": "app.chain",
"count": 1,
"mode": "sync"
},
{
"caller": "app.chain.transfer",
"count": 1,
"mode": "sync"
}
],
"filter_torrents": [
+2
View File
@@ -482,6 +482,8 @@ def test_transfer_pending_oper_import_is_confined_to_database_boundary():
"""宿主仅允许事务适配器和兼容导出直接导入整理待处理 Oper。"""
allowed_paths = {
"app/db/adapters/transfer.py",
"app/db/adapters/chain.py",
"app/db/adapters/transfer_execution.py",
"app/db/oper/__init__.py",
}
violations: list[str] = []
+875 -1
View File
@@ -1,13 +1,18 @@
"""下载与整理 durable 事件的原子写入和对象恢复测试。"""
import json
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from unittest.mock import Mock
from uuid import UUID
import pytest
from sqlalchemy import create_engine, delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker
from app.application.chain.durable_events import (
TransferResultSettlement,
download_added_event_key,
restore_download_added,
restore_transfer_result,
@@ -15,16 +20,26 @@ from app.application.chain.durable_events import (
snapshot_transfer_result,
transfer_result_event_key,
)
from app.application.history import TransferHistoryMutationCommand
from app.application.transfer_execution import (
TransferExecutionLeaseLostError,
TransferSettlementResult,
)
from app.db.adapters.chain import TransactionalChainDurableEventWriter
from app.db.base import Base
from app.db.models.downloadhistory import DownloadFiles, DownloadHistory
from app.db.models.outbox import OutboxMessage
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.db.oper.transferhistory import TransferHistoryOper
from app.db.uow import SqlAlchemyUnitOfWork
from app.domain.context import Context, MediaInfo, TorrentInfo
from app.domain.metainfo import MetaInfo
from app.schemas.file import FileItem
from app.schemas.transfer import TransferInfo
from app.schemas.types import MediaSource, MediaType
from app.db.adapters.chain import TransactionalChainDurableEventWriter
def _session_factory():
@@ -66,6 +81,79 @@ def _objects():
return meta, media, context, fileitem, transferinfo
def _add_settling_pending(
factory,
*,
task_id: str = "task-1",
lease_token: str = "lease-1",
execution_fingerprint: str = "execution-1",
settlement_revision: int = 0,
src_path: str | None = None,
) -> None:
"""写入具备有效长租约和执行检查点的待结算任务。"""
with factory() as session:
session.add(TransferPending(
task_id=task_id,
storage="local",
src_path=src_path or f"/downloads/{task_id}.mkv",
created_at="2026-08-27 09:00:00",
state="planned",
updated_at="2026-08-27 09:00:00",
input_version=1,
planning_input={"schema_version": 1, "source": task_id},
input_fingerprint=f"input-{task_id}",
checkpoint_version=1,
checkpoint_payload={"schema_version": 1, "task_id": task_id},
planned_at="2026-08-27 09:00:00",
lease_owner="worker-1",
lease_token=lease_token,
lease_expires_at="2099-01-01 00:00:00.000000",
heartbeat_at="2026-08-27 01:00:00.000000",
attempt_count=1,
execution_state="settling",
execution_version=1,
execution_payload={"schema_version": 1},
execution_fingerprint=execution_fingerprint,
retry_generation=0,
retry_count=0,
settlement_revision=settlement_revision,
))
session.commit()
def _settlement(
*,
outcome: str,
task_id: str = "task-1",
lease_token: str = "lease-1",
execution_fingerprint: str = "execution-1",
) -> TransferResultSettlement:
"""构造测试使用的稳定终态结算身份。"""
return TransferResultSettlement(
task_id=task_id,
lease_token=lease_token,
execution_fingerprint=execution_fingerprint,
outcome=outcome,
error="目标文件校验失败" if outcome == "failed" else None,
)
def _stage_result_history(
repository,
*,
task_id: str,
succeeded: bool,
src_path: str | None = None,
):
"""通过兼容历史端口暂存一条最小任务结算记录。"""
return repository.add_force(
src=src_path or f"/downloads/{task_id}.mkv",
src_storage="local",
status=succeeded,
errmsg=None if succeeded else "目标文件校验失败",
)
def _assert_event_key(
event_key: str,
topic: str,
@@ -213,6 +301,71 @@ def test_event_keys_distinguish_reused_history_ids():
_assert_event_key(event_key, "transfer.completed", 7)
def test_task_settlement_event_key_is_deterministic_and_revision_scoped():
"""任务结算按稳定任务、修订号和结果生成可重放的唯一事件键。"""
succeeded = TransferResultSettlement(
task_id="task-1",
lease_token="lease-1",
execution_fingerprint="execution-1",
outcome="succeeded",
)
failed = TransferResultSettlement(
task_id="task-1",
lease_token="lease-1",
execution_fingerprint="execution-1",
outcome="failed",
error="目标文件校验失败",
)
assert transfer_result_event_key(
"transfer.completed", 7, settlement=succeeded, settlement_revision=2
) == "transfer.result:task-1:2:succeeded:v1"
assert transfer_result_event_key(
"transfer.completed", 99, settlement=succeeded, settlement_revision=2
) == "transfer.result:task-1:2:succeeded:v1"
assert transfer_result_event_key(
"transfer.failed", 7, settlement=failed, settlement_revision=3
) == "transfer.result:task-1:3:failed:v1"
@pytest.mark.parametrize(
("kwargs", "message"),
[
({"task_id": ""}, "缺少任务"),
({"outcome": "unknown"}, "不支持的整理终态"),
({"outcome": "failed", "error": None}, "必须包含可诊断原因"),
],
)
def test_task_settlement_rejects_incomplete_identity(kwargs, message):
"""任务结算在进入数据库适配器前拒绝不完整的 fencing 身份。"""
values = {
"task_id": "task-1",
"lease_token": "lease-1",
"execution_fingerprint": "execution-1",
"outcome": "succeeded",
"error": None,
}
values.update(kwargs)
with pytest.raises(ValueError, match=message):
TransferResultSettlement(**values)
def test_task_settlement_event_key_requires_transaction_revision():
"""任务结算事件键只能使用持久层已取得的正向修订号。"""
settlement = TransferResultSettlement(
task_id="task-1",
lease_token="lease-1",
execution_fingerprint="execution-1",
outcome="succeeded",
)
with pytest.raises(ValueError, match="缺少有效结算修订号"):
transfer_result_event_key(
"transfer.completed",
7,
settlement=settlement,
)
def test_transfer_succeeds_when_history_id_is_reused_with_retained_outbox():
"""整理历史删除而 outbox 保留时,复用主键不得阻断新整理记录。"""
factory = _session_factory()
@@ -302,3 +455,724 @@ def test_transfer_event_failure_leaves_committed_intent_pending():
assert history.status is True
assert outbox.status == "pending"
_assert_event_key(outbox.event_key, "transfer.completed", history.id)
def test_task_success_settlement_atomically_deletes_pending_and_steps():
"""成功终态原子提交历史、pending、步骤和待异步投递的 intent。"""
factory = _session_factory()
_add_settling_pending(factory)
with factory() as session:
session.add(TransferExecutionStep(
task_id="task-1",
operation_id="operation-1",
checkpoint_fingerprint="plan-1",
ordinal=0,
phase="transfer",
kind="copy",
state="succeeded",
attempt_count=1,
intent_version=1,
intent_payload={"src": "/downloads/task-1.mkv"},
result_version=1,
result_payload={"dest": "/library/task-1.mkv"},
prepared_at="2026-08-27 09:00:00",
completed_at="2026-08-27 09:01:00",
updated_at="2026-08-27 09:01:00",
))
session.commit()
writer = TransactionalChainDurableEventWriter(factory)
published = []
def publish(payload):
"""验证即时发布只能观察到已提交的完整终态。"""
with factory() as session:
assert session.execute(select(TransferPending)).scalar_one_or_none() is None
assert session.execute(select(TransferHistory)).scalar_one().status is True
assert session.execute(select(OutboxMessage)).scalar_one().status == "pending"
published.append(dict(payload))
result = writer.transfer_result(
topic="transfer.completed",
stage_history=lambda repository: _stage_result_history(
repository,
task_id="task-1",
succeeded=True,
),
event_payload={},
publish=publish,
settlement=_settlement(outcome="succeeded"),
)
assert result == TransferSettlementResult(
history_id=1,
settlement_revision=1,
pending_deleted=True,
)
with factory() as session:
history = session.execute(select(TransferHistory)).scalar_one()
receipt = session.execute(select(TransferSettlementReceipt)).scalar_one()
outbox = session.execute(select(OutboxMessage)).scalar_one()
assert session.execute(select(TransferPending)).scalar_one_or_none() is None
assert session.execute(select(TransferExecutionStep)).scalar_one_or_none() is None
assert history.transfer_task_id is None
assert history.transfer_settlement_revision is None
assert receipt.task_id == "task-1"
assert receipt.history_id == history.id
assert receipt.outcome == "succeeded"
assert receipt.execution_fingerprint == "execution-1"
assert receipt.lease_token == "lease-1"
assert receipt.history_status is True
assert receipt.src == "/downloads/task-1.mkv"
assert receipt.src_storage == "local"
assert receipt.pending_deleted is True
assert outbox.event_key == "transfer.result:task-1:1:succeeded:v1"
assert outbox.status == "pending"
assert outbox.payload["idempotency_key"] == outbox.event_key
assert "task_id" not in outbox.payload
assert published == []
def test_task_success_replay_reads_history_without_new_event():
"""成功删除 pending 后重复结算只回读历史,不重复登记或发布事件。"""
factory = _session_factory()
_add_settling_pending(factory)
writer = TransactionalChainDurableEventWriter(factory)
settlement = _settlement(outcome="succeeded")
calls = []
first = writer.transfer_result(
topic="transfer.completed",
stage_history=lambda repository: _stage_result_history(
repository,
task_id="task-1",
succeeded=True,
),
event_payload={},
publish=lambda _payload: calls.append("first"),
settlement=settlement,
)
replay = writer.transfer_result(
topic="transfer.completed",
stage_history=lambda _repository: pytest.fail("幂等回读不得重写历史"),
event_payload={},
publish=lambda _payload: pytest.fail("幂等回读不得重复发布"),
settlement=settlement,
)
assert isinstance(first, TransferSettlementResult)
assert replay == TransferSettlementResult(
history_id=first.history_id,
settlement_revision=1,
pending_deleted=True,
already_settled=True,
)
with factory() as session:
assert len(session.execute(select(TransferHistory)).scalars().all()) == 1
assert len(session.execute(select(OutboxMessage)).scalars().all()) == 1
assert calls == []
def test_multiple_same_source_tasks_keep_independent_replay_receipts():
"""同源多代任务可依次完成,旧任务仍由独立回执幂等回读。"""
factory = _session_factory()
writer = TransactionalChainDurableEventWriter(factory)
shared_src = "/downloads/shared-generation.mkv"
_add_settling_pending(
factory,
task_id="old-task",
src_path=shared_src,
)
old_settlement = _settlement(outcome="succeeded", task_id="old-task")
old_result = writer.transfer_result(
topic="transfer.completed",
stage_history=lambda repository: _stage_result_history(
repository,
task_id="old-task",
succeeded=True,
src_path=shared_src,
),
event_payload={},
publish=None,
settlement=old_settlement,
)
_add_settling_pending(
factory,
task_id="new-task",
lease_token="lease-2",
execution_fingerprint="execution-2",
src_path=shared_src,
)
new_result = writer.transfer_result(
topic="transfer.completed",
stage_history=lambda repository: _stage_result_history(
repository,
task_id="new-task",
succeeded=True,
src_path=shared_src,
),
event_payload={},
publish=None,
settlement=_settlement(
outcome="succeeded",
task_id="new-task",
lease_token="lease-2",
execution_fingerprint="execution-2",
),
)
old_replay = writer.transfer_result(
topic="transfer.completed",
stage_history=lambda _repository: pytest.fail("旧任务不得改写最新投影"),
event_payload={},
publish=None,
settlement=old_settlement,
)
with factory() as session:
history = session.execute(select(TransferHistory)).scalar_one()
receipts = session.execute(
select(TransferSettlementReceipt).order_by(TransferSettlementReceipt.id)
).scalars().all()
assert session.execute(select(TransferPending)).scalar_one_or_none() is None
assert isinstance(old_result, TransferSettlementResult)
assert isinstance(new_result, TransferSettlementResult)
assert old_replay == TransferSettlementResult(
history_id=old_result.history_id,
settlement_revision=1,
pending_deleted=True,
already_settled=True,
)
assert history.transfer_task_id is None
assert history.transfer_settlement_revision is None
assert [receipt.task_id for receipt in receipts] == ["old-task", "new-task"]
assert [receipt.history_id for receipt in receipts] == [history.id, history.id]
def test_task_settlement_without_public_topic_commits_no_outbox():
"""无公共事件的文件仍原子结算历史和 pending,且不登记或发布事件。"""
factory = _session_factory()
_add_settling_pending(factory, task_id="lyrics-task")
writer = TransactionalChainDurableEventWriter(factory)
result = writer.transfer_result(
topic=None,
stage_history=lambda repository: _stage_result_history(
repository,
task_id="lyrics-task",
succeeded=True,
),
event_payload={"unexpected": "must-not-publish"},
publish=lambda _payload: pytest.fail("无公共 topic 不得发布"),
settlement=_settlement(
outcome="succeeded",
task_id="lyrics-task",
),
)
assert result == TransferSettlementResult(
history_id=1,
settlement_revision=1,
pending_deleted=True,
)
with factory() as session:
history = session.execute(select(TransferHistory)).scalar_one()
assert history.transfer_task_id is None
assert session.execute(select(TransferPending)).scalar_one_or_none() is None
assert session.execute(select(OutboxMessage)).scalar_one_or_none() is None
def test_task_settlement_binds_receipt_without_overwriting_success_history():
"""不覆盖裁决只绑定任务回执,保留旧成功历史的全部业务字段。"""
factory = _session_factory()
_add_settling_pending(factory, task_id="declined-task")
with factory() as session:
session.add(TransferHistory(
src="/downloads/declined-task.mkv",
src_storage="local",
dest="/library/original.mkv",
title="Original",
status=True,
date="2026-08-26 20:00:00",
))
session.commit()
writer = TransactionalChainDurableEventWriter(factory)
settlement = _settlement(
outcome="succeeded",
task_id="declined-task",
)
first = writer.transfer_result(
topic=None,
stage_history=lambda repository: repository.get_success_by_src(
"/downloads/declined-task.mkv",
"local",
),
event_payload={},
publish=None,
settlement=settlement,
)
replay = writer.transfer_result(
topic=None,
stage_history=lambda _repository: pytest.fail("回执重放不得重新查写历史"),
event_payload={},
publish=None,
settlement=settlement,
)
assert isinstance(first, TransferSettlementResult)
assert replay == TransferSettlementResult(
history_id=first.history_id,
settlement_revision=1,
pending_deleted=True,
already_settled=True,
)
with factory() as session:
history = session.execute(select(TransferHistory)).scalar_one()
assert session.execute(select(TransferPending)).scalar_one_or_none() is None
assert session.execute(select(OutboxMessage)).scalar_one_or_none() is None
assert history.dest == "/library/original.mkv"
assert history.title == "Original"
assert history.status is True
assert history.date == "2026-08-26 20:00:00"
assert history.transfer_task_id is None
assert history.transfer_settlement_revision is None
@pytest.mark.parametrize("cleanup", ["delete", "truncate"])
def test_receipt_replay_survives_real_history_command_cleanup(cleanup):
"""真实历史删除或清空命令执行后,独立回执仍可重放成功终态。"""
factory = _session_factory()
_add_settling_pending(factory, task_id="cleanup-task")
writer = TransactionalChainDurableEventWriter(factory)
settlement = _settlement(outcome="succeeded", task_id="cleanup-task")
first = writer.transfer_result(
topic=None,
stage_history=lambda repository: _stage_result_history(
repository,
task_id="cleanup-task",
succeeded=True,
),
event_payload={},
publish=None,
settlement=settlement,
)
with factory() as session:
command = TransferHistoryMutationCommand(
repository=TransferHistoryOper(session),
download_repository=Mock(),
unit_of_work=SqlAlchemyUnitOfWork(session),
file_item_factory=Mock(),
delete_media_file=Mock(return_value=True),
publish_download_file_deleted=Mock(),
clear_failures=Mock(),
)
cleanup_result = (
command.delete(first.history_id)
if cleanup == "delete"
else command.truncate()
)
assert cleanup_result.success is True
replay = writer.transfer_result(
topic=None,
stage_history=lambda _repository: pytest.fail("清理后重放不得重建历史"),
event_payload={},
publish=None,
settlement=settlement,
)
with factory() as session:
assert session.execute(select(TransferHistory)).scalar_one_or_none() is None
receipts = session.execute(
select(TransferSettlementReceipt)
.order_by(TransferSettlementReceipt.settlement_revision)
).scalars().all()
assert isinstance(first, TransferSettlementResult)
assert replay == TransferSettlementResult(
history_id=first.history_id,
settlement_revision=1,
pending_deleted=True,
already_settled=True,
)
assert len(receipts) == 1
assert receipts[0].task_id == "cleanup-task"
assert receipts[0].history_id == first.history_id
def test_success_receipt_allows_expiry_and_legacy_same_source_replace():
"""成功回执不锁死业务历史,过期清理和旧兼容替换仍按原契约工作。"""
factory = _session_factory()
shared_src = "/downloads/cleanup-compatible.mkv"
_add_settling_pending(
factory,
task_id="compatible-task",
src_path=shared_src,
)
writer = TransactionalChainDurableEventWriter(factory)
settlement = _settlement(outcome="succeeded", task_id="compatible-task")
first = writer.transfer_result(
topic=None,
stage_history=lambda repository: _stage_result_history(
repository,
task_id="compatible-task",
succeeded=True,
src_path=shared_src,
),
event_payload={},
publish=None,
settlement=settlement,
)
with factory() as session:
assert TransferHistory.delete_before(
session,
before_time="9999-12-31 23:59:59",
limit=100,
) == 1
session.commit()
legacy = TransferHistoryOper(session).stage_replace_by_src(
src=shared_src,
src_storage="local",
status=True,
)
session.commit()
assert legacy.transfer_task_id is None
replay = writer.transfer_result(
topic=None,
stage_history=lambda _repository: pytest.fail("旧兼容替换后不得重写历史"),
event_payload={},
publish=None,
settlement=settlement,
)
assert isinstance(first, TransferSettlementResult)
assert replay == TransferSettlementResult(
history_id=first.history_id,
settlement_revision=1,
pending_deleted=True,
already_settled=True,
)
def test_task_failure_settlement_is_replayable_and_retry_advances_revision():
"""失败保留终态证据,重复调用幂等,显式重试后才递增修订号。"""
factory = _session_factory()
_add_settling_pending(factory)
writer = TransactionalChainDurableEventWriter(factory)
calls = []
first_settlement = _settlement(outcome="failed")
first = writer.transfer_result(
topic="transfer.failed",
stage_history=lambda repository: _stage_result_history(
repository,
task_id="task-1",
succeeded=False,
),
event_payload={},
publish=lambda _payload: calls.append("first"),
settlement=first_settlement,
)
replay = writer.transfer_result(
topic="transfer.failed",
stage_history=lambda _repository: pytest.fail("失败回读不得重写历史"),
event_payload={},
publish=lambda _payload: pytest.fail("失败回读不得重复发布"),
settlement=first_settlement,
)
assert isinstance(first, TransferSettlementResult)
assert replay.already_settled is True
assert replay.settlement_revision == 1
with factory() as session:
pending = session.execute(select(TransferPending)).scalar_one()
assert pending.execution_state == "failed"
assert pending.lease_token is None
assert pending.settlement_revision == 1
assert pending.terminal_history_id == first.history_id
pending.execution_state = "settling"
pending.execution_fingerprint = "execution-2"
pending.lease_owner = "worker-2"
pending.lease_token = "lease-2"
pending.lease_expires_at = "2099-01-01 00:00:00.000000"
session.commit()
retried = writer.transfer_result(
topic="transfer.failed",
stage_history=lambda repository: _stage_result_history(
repository,
task_id="task-1",
succeeded=False,
),
event_payload={},
publish=lambda _payload: calls.append("retry"),
settlement=_settlement(
outcome="failed",
lease_token="lease-2",
execution_fingerprint="execution-2",
),
)
assert retried == TransferSettlementResult(
history_id=first.history_id,
settlement_revision=2,
pending_deleted=False,
)
stale_replay = writer.transfer_result(
topic="transfer.failed",
stage_history=lambda _repository: pytest.fail(
"旧修订延迟重放不得覆盖最新历史"
),
event_payload={},
publish=None,
settlement=first_settlement,
)
assert stale_replay == TransferSettlementResult(
history_id=first.history_id,
settlement_revision=1,
pending_deleted=False,
already_settled=True,
)
with factory() as session:
histories = session.execute(select(TransferHistory)).scalars().all()
outboxes = session.execute(
select(OutboxMessage).order_by(OutboxMessage.id)
).scalars().all()
pending = session.execute(select(TransferPending)).scalar_one()
receipts = session.execute(
select(TransferSettlementReceipt)
.order_by(TransferSettlementReceipt.settlement_revision)
).scalars().all()
assert len(histories) == 1
assert histories[0].transfer_settlement_revision == 2
assert pending.settlement_revision == 2
assert [receipt.settlement_revision for receipt in receipts] == [1, 2]
assert all(receipt.task_id == "task-1" for receipt in receipts)
assert all(receipt.history_id == first.history_id for receipt in receipts)
assert receipts[0].execution_fingerprint == "execution-1"
assert receipts[0].lease_token == "lease-1"
assert receipts[1].outcome == "failed"
assert receipts[1].execution_fingerprint == "execution-2"
assert receipts[1].lease_token == "lease-2"
assert receipts[1].pending_deleted is False
assert receipts[1].error == "目标文件校验失败"
assert [item.event_key for item in outboxes] == [
"transfer.result:task-1:1:failed:v1",
"transfer.result:task-1:2:failed:v1",
]
assert calls == []
def test_failed_revision_replays_after_later_success_deleted_pending():
"""后续重试成功删除 pending 后,旧失败修订仍按原执行身份幂等回读。"""
factory = _session_factory()
_add_settling_pending(factory)
writer = TransactionalChainDurableEventWriter(factory)
failed_settlement = _settlement(outcome="failed")
failed = writer.transfer_result(
topic="transfer.failed",
stage_history=lambda repository: _stage_result_history(
repository,
task_id="task-1",
succeeded=False,
),
event_payload={},
publish=None,
settlement=failed_settlement,
)
with factory() as session:
pending = session.execute(select(TransferPending)).scalar_one()
pending.execution_state = "settling"
pending.execution_fingerprint = "execution-2"
pending.lease_owner = "worker-2"
pending.lease_token = "lease-2"
pending.lease_expires_at = "2099-01-01 00:00:00.000000"
session.commit()
succeeded_settlement = _settlement(
outcome="succeeded",
lease_token="lease-2",
execution_fingerprint="execution-2",
)
succeeded = writer.transfer_result(
topic="transfer.completed",
stage_history=lambda repository: _stage_result_history(
repository,
task_id="task-1",
succeeded=True,
),
event_payload={},
publish=None,
settlement=succeeded_settlement,
)
stale_replay = writer.transfer_result(
topic="transfer.failed",
stage_history=lambda _repository: pytest.fail("旧失败回执不得重写历史"),
event_payload={},
publish=None,
settlement=failed_settlement,
)
success_replay = writer.transfer_result(
topic="transfer.completed",
stage_history=lambda _repository: pytest.fail("成功回执不得重写历史"),
event_payload={},
publish=None,
settlement=succeeded_settlement,
)
assert isinstance(failed, TransferSettlementResult)
assert isinstance(succeeded, TransferSettlementResult)
assert stale_replay == TransferSettlementResult(
history_id=failed.history_id,
settlement_revision=1,
pending_deleted=False,
already_settled=True,
)
assert success_replay == TransferSettlementResult(
history_id=succeeded.history_id,
settlement_revision=2,
pending_deleted=True,
already_settled=True,
)
with factory() as session:
history = session.execute(select(TransferHistory)).scalar_one()
receipts = session.execute(
select(TransferSettlementReceipt)
.order_by(TransferSettlementReceipt.settlement_revision)
).scalars().all()
assert session.execute(select(TransferPending)).scalar_one_or_none() is None
assert history.transfer_task_id is None
assert history.transfer_settlement_revision is None
assert [receipt.outcome for receipt in receipts] == ["failed", "succeeded"]
def test_task_settlement_outbox_conflict_rolls_back_history_and_pending():
"""intent 唯一键冲突时回滚此前已暂存的历史与 pending 终态。"""
factory = _session_factory()
_add_settling_pending(factory)
event_key = "transfer.result:task-1:1:succeeded:v1"
with factory() as session:
session.add(OutboxMessage(
event_key=event_key,
topic="transfer.completed",
payload_version=1,
payload={},
status="completed",
attempt=0,
next_retry_at="2026-08-27T01:00:00+00:00",
created_at="2026-08-27T01:00:00+00:00",
))
session.commit()
writer = TransactionalChainDurableEventWriter(factory)
with pytest.raises(IntegrityError):
writer.transfer_result(
topic="transfer.completed",
stage_history=lambda repository: _stage_result_history(
repository,
task_id="task-1",
succeeded=True,
),
event_payload={},
publish=lambda _payload: pytest.fail("事务失败不得发布"),
settlement=_settlement(outcome="succeeded"),
)
with factory() as session:
pending = session.execute(select(TransferPending)).scalar_one()
histories = session.execute(select(TransferHistory)).scalars().all()
receipts = session.execute(select(TransferSettlementReceipt)).scalars().all()
outboxes = session.execute(select(OutboxMessage)).scalars().all()
assert pending.execution_state == "settling"
assert pending.settlement_revision == 0
assert pending.lease_token == "lease-1"
assert histories == []
assert receipts == []
assert len(outboxes) == 1
assert outboxes[0].event_key == event_key
def test_task_settlement_rejects_stale_lease_without_business_writes():
"""陈旧 lease 在历史回调前即被 fencing,不能留下历史或 intent。"""
factory = _session_factory()
_add_settling_pending(factory)
writer = TransactionalChainDurableEventWriter(factory)
with pytest.raises(TransferExecutionLeaseLostError):
writer.transfer_result(
topic="transfer.completed",
stage_history=lambda _repository: pytest.fail("陈旧 lease 不得写历史"),
event_payload={},
publish=lambda _payload: pytest.fail("陈旧 lease 不得发布"),
settlement=_settlement(
outcome="succeeded",
lease_token="stale-lease",
),
)
with factory() as session:
pending = session.execute(select(TransferPending)).scalar_one()
assert session.execute(select(TransferHistory)).scalar_one_or_none() is None
assert session.execute(
select(TransferSettlementReceipt)
).scalar_one_or_none() is None
assert session.execute(select(OutboxMessage)).scalar_one_or_none() is None
assert pending.execution_state == "settling"
assert pending.lease_token == "lease-1"
def test_concurrent_duplicate_settlement_returns_one_commit_and_one_replay(
tmp_path,
monkeypatch,
):
"""并发重复结算只有一个事务写入,竞争输家回读同一不可变回执。"""
engine = create_engine(
f"sqlite+pysqlite:///{tmp_path / 'settlement-race.db'}",
connect_args={"check_same_thread": False, "timeout": 10},
)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
_add_settling_pending(factory)
writer = TransactionalChainDurableEventWriter(factory)
settlement = _settlement(outcome="succeeded")
barrier = Barrier(2)
original_read = TransactionalChainDurableEventWriter._read_settlement_result
def synchronized_read(**kwargs):
"""让两个调用都先观察到未结算,再同时进入事务竞争。"""
result = original_read(**kwargs)
if result is None:
barrier.wait(timeout=10)
return result
monkeypatch.setattr(
TransactionalChainDurableEventWriter,
"_read_settlement_result",
staticmethod(synchronized_read),
)
def settle_once():
"""用相同 fencing 身份提交同一成功终态。"""
return writer.transfer_result(
topic=None,
stage_history=lambda repository: _stage_result_history(
repository,
task_id="task-1",
succeeded=True,
),
event_payload={},
publish=None,
settlement=settlement,
)
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(lambda _index: settle_once(), range(2)))
assert sorted(result.already_settled for result in results) == [False, True]
assert {result.history_id for result in results} == {1}
with factory() as session:
assert len(session.execute(select(TransferHistory)).scalars().all()) == 1
assert len(
session.execute(select(TransferSettlementReceipt)).scalars().all()
) == 1
assert session.execute(select(TransferPending)).scalar_one_or_none() is None
+59 -1
View File
@@ -5,7 +5,6 @@
来的)、统计。查重误判会重复整理或永久漏件——挂载故障那一类问题最终就落在这张表上;
溯源查错会让「重新整理」把不相干的文件搬走。
"""
import asyncio
import time as _time
import pytest
@@ -515,6 +514,65 @@ def test_delete_before_is_batched_and_keeps_recent(db):
assert TransferHistory.get_by_src(db.session, "/data/recent.mkv") is not None
def test_delete_before_preserves_current_failed_task_history(db):
"""过期维护不得删除当前失败 pending 映射使用的历史。"""
durable = _hist(
"durable-old",
src="/data/durable-old.mkv",
date="2026-01-01 10:00:00",
status=False,
)
durable.transfer_task_id = "task-durable-old"
durable.transfer_settlement_revision = 1
db.add(durable)
assert TransferHistory.delete_before(
db.session,
before_time="2026-08-01",
limit=100,
) == 0
assert TransferHistory.get_by_transfer_task_id(
db.session,
task_id="task-durable-old",
) is not None
def test_upsert_durable_projection_advances_to_new_same_source_task(db):
"""同源历史只表达最新任务投影,旧任务重放身份由独立回执持有。"""
durable = _hist(
"old-task",
src="/data/reused.mkv",
date="2026-08-01 10:00:00",
)
durable.transfer_task_id = "old-task"
durable.transfer_settlement_revision = 1
db.add(durable)
projected = TransferHistory.upsert_by_transfer_task_id(
db.session,
task_id="new-task",
settlement_revision=1,
retain_task_mapping=False,
payload={
"src": "/data/reused.mkv",
"src_storage": "local",
"status": True,
},
)
assert TransferHistory.get_by_transfer_task_id(
db.session,
task_id="old-task",
) is None
assert TransferHistory.get_by_transfer_task_id(
db.session,
task_id="new-task",
) is None
assert projected is durable
assert projected.transfer_task_id is None
assert projected.transfer_settlement_revision is None
def test_delete_before_keeps_the_row_exactly_at_the_boundary(db):
"""
保留时间点上的整理历史属于「保留期内」,不能被清理(``date < before_time``)。
+2
View File
@@ -13,6 +13,7 @@ from sqlalchemy.orm import sessionmaker
from app.db import base as db_base
from app.db.adapters.transfer import TransactionalTransferAdmissionRepository
from app.db.models.transferhistory import TransferHistory
from app.db.models.transferpending import TransferPending
from app.db.oper.transferpending import TransferPendingOper
@@ -143,6 +144,7 @@ def test_oper_staging_reuses_explicit_write_session(db, monkeypatch):
def test_transactional_repository_commits_frozen_projections(tmp_path):
"""适配器应独立提交 UoW,并在会话关闭前冻结应用 DTO。"""
engine = create_engine(f"sqlite:///{tmp_path / 'transfer.db'}")
TransferHistory.__table__.create(engine)
TransferPending.__table__.create(engine)
factory = sessionmaker(bind=engine)
repository = TransactionalTransferAdmissionRepository(factory)
+437
View File
@@ -0,0 +1,437 @@
"""验证 AI 历史入口不会绕过 durable 整理任务的唯一重试权。"""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from app.agent.tools.impl.delete_transfer_history import DeleteTransferHistoryTool
from app.api.endpoints import history as history_endpoint
from app.application.configuration import ApiRuntimeConfig
from app.application.transfer_execution import (
TransferExecutionState,
TransferRetryRequestResult,
)
from app.runtime.progress import AsyncProgressHelper
from app.schemas.history import BatchTransferHistoryRedoRequest, TransferHistory
class _HistoryQuery:
"""按测试输入返回脱离数据库的整理历史 DTO。"""
def __init__(self, histories: list[TransferHistory]) -> None:
"""保存按 ID 可查的测试历史。"""
self._histories = {history.id: history for history in histories}
async def get_transfer(self, history_id: int) -> TransferHistory | None:
"""返回单条测试历史。"""
return self._histories.get(history_id)
async def get_transfers(
self,
history_ids: list[int],
) -> tuple[list[TransferHistory], list[int]]:
"""按输入顺序返回存在和缺失的测试历史。"""
records = [self._histories[item] for item in history_ids if item in self._histories]
missing = [item for item in history_ids if item not in self._histories]
return records, missing
class _RetryCommand:
"""记录 execution 重试请求并返回逐任务测试结果。"""
calls: list[tuple[object, dict]] = []
results: dict[str, TransferRetryRequestResult] = {}
def __init__(self, repository: object) -> None:
"""保存调用方取得的 execution 仓储。"""
self._repository = repository
def request_retry(self, **kwargs) -> TransferRetryRequestResult:
"""记录调用并返回任务对应结果。"""
self.calls.append((self._repository, kwargs))
return self.results[kwargs["task_id"]]
async def _record_async(target: list[dict], payload: dict) -> None:
"""记录被 await 的异步边界调用。"""
target.append(payload)
def _runtime(*, ai_enabled: bool = True) -> ApiRuntimeConfig:
"""构造历史端点需要的最小稳定配置快照。"""
return ApiRuntimeConfig(
advanced_mode=False,
access_token_expire_minutes=30,
btrfs_fsid_dedup=False,
ai_agent_enable=ai_enabled,
)
def _retry_result(
*,
accepted: bool,
state: TransferExecutionState,
message: str,
) -> TransferRetryRequestResult:
"""构造 execution 重试登记结果。"""
return TransferRetryRequestResult(
accepted=accepted,
state=state,
retry_generation=3,
message=message,
)
def _install_retry_command(monkeypatch, results: dict[str, TransferRetryRequestResult]) -> object:
"""安装不会接触真实数据库的 execution 端口和命令替身。"""
repository = object()
_RetryCommand.calls = []
_RetryCommand.results = results
monkeypatch.setattr(
history_endpoint,
"get_chain_transfer_execution_port",
lambda: repository,
)
monkeypatch.setattr(history_endpoint, "TransferExecutionCommand", _RetryCommand)
return repository
def test_transfer_history_task_id_is_internal_projection_only() -> None:
"""durable 任务标识可供宿主读取,但不得扩展公开历史响应。"""
history = TransferHistory(id=7, transfer_task_id="task-7")
assert history.transfer_task_id == "task-7"
assert "transfer_task_id" not in history.model_dump()
def test_durable_retry_progress_is_immediately_completed_for_existing_sse() -> None:
"""durable 重试完成进度应让既有 SSE 客户端首次读取即可收口。"""
async def scenario() -> dict:
"""写入并回读同一个测试进度键。"""
progress_key = "test_history_durable_retry_completed"
await history_endpoint._complete_durable_retry_progress(
progress_key=progress_key,
text="整理任务已登记重试",
history_ids=[8],
)
detail = await AsyncProgressHelper(progress_key).get()
assert detail is not None
return detail
detail = asyncio.run(scenario())
assert detail["enable"] is False
assert detail["value"] == 100
assert detail["data"]["history_ids"] == [8]
assert detail["data"]["success"] is True
assert detail["data"]["completed"] is True
assert detail["data"]["message"] == "整理任务已登记重试"
def test_single_ai_redo_requests_durable_retry_without_agent(monkeypatch) -> None:
"""单条 durable AI 重做只登记调度重试,即使 Agent 功能未启用。"""
repository = _install_retry_command(
monkeypatch,
{
"task-11": _retry_result(
accepted=True,
state=TransferExecutionState.RETRY_WAIT,
message="整理任务已登记重试",
)
},
)
monkeypatch.setattr(
history_endpoint,
"_start_ai_redo_task",
lambda **_kwargs: (_ for _ in ()).throw(
AssertionError("durable 重试不得启动 Agent")
),
)
completed_progress: list[dict] = []
monkeypatch.setattr(
history_endpoint,
"_complete_durable_retry_progress",
lambda **kwargs: _record_async(completed_progress, kwargs),
)
response = asyncio.run(
history_endpoint.ai_redo_transfer_history(
11,
query=_HistoryQuery([TransferHistory(id=11, transfer_task_id="task-11")]),
runtime_config=_runtime(ai_enabled=False),
task_registry=object(),
_=object(),
)
)
assert response.success is True
assert response.data is not None
assert response.data["progress_key"].startswith("transfer_retry_11_")
assert response.message == "整理任务已登记重试"
assert completed_progress[0]["history_ids"] == [11]
assert _RetryCommand.calls == [
(
repository,
{
"task_id": "task-11",
"reason": "AI REST 请求重试整理历史 #11",
"requested_by": "history_ai_redo",
},
)
]
def test_single_ai_redo_reports_manual_review_rejection(monkeypatch) -> None:
"""人工复核状态必须原样拒绝,且不得回退到破坏性 Agent 流程。"""
_install_retry_command(
monkeypatch,
{
"task-12": _retry_result(
accepted=False,
state=TransferExecutionState.MANUAL_REVIEW,
message="人工复核任务必须先完成专门判定",
)
},
)
monkeypatch.setattr(
history_endpoint,
"build_manual_redo_prompt",
lambda _history: (_ for _ in ()).throw(
AssertionError("拒绝后不得生成 Agent 提示词")
),
)
response = asyncio.run(
history_endpoint.ai_redo_transfer_history(
12,
query=_HistoryQuery([TransferHistory(id=12, transfer_task_id="task-12")]),
runtime_config=_runtime(),
task_registry=object(),
_=object(),
)
)
assert response.success is False
assert response.message == "人工复核任务必须先完成专门判定"
def test_batch_ai_redo_returns_completed_progress_for_durable_tasks(monkeypatch) -> None:
"""全 durable 批量接受后保留前端既有 progress_key 协议。"""
_install_retry_command(
monkeypatch,
{
"task-18": _retry_result(
accepted=True,
state=TransferExecutionState.RETRY_WAIT,
message="整理任务已登记重试",
),
"task-19": _retry_result(
accepted=True,
state=TransferExecutionState.RETRY_WAIT,
message="整理任务已在等待重试",
),
},
)
completed_progress: list[dict] = []
monkeypatch.setattr(
history_endpoint,
"_complete_durable_retry_progress",
lambda **kwargs: _record_async(completed_progress, kwargs),
)
monkeypatch.setattr(
history_endpoint,
"_start_batch_ai_redo_task",
lambda **_kwargs: (_ for _ in ()).throw(
AssertionError("全 durable 批量不得启动 Agent")
),
)
response = asyncio.run(
history_endpoint.batch_ai_redo_transfer_history(
BatchTransferHistoryRedoRequest(history_ids=[18, 19]),
query=_HistoryQuery([
TransferHistory(id=18, transfer_task_id="task-18"),
TransferHistory(id=19, transfer_task_id="task-19"),
]),
runtime_config=_runtime(ai_enabled=False),
task_registry=object(),
_=object(),
)
)
assert response.success is True
assert response.data is not None
assert response.data["progress_key"].startswith("transfer_retry_batch_")
assert response.data["history_ids"] == [18, 19]
assert completed_progress[0]["history_ids"] == [18, 19]
def test_batch_ai_redo_reports_each_rejection_without_starting_legacy_agent(
monkeypatch,
) -> None:
"""混合批量逐 task 返回拒绝,避免同时产生无人监听的旧 Agent 任务。"""
_install_retry_command(
monkeypatch,
{
"task-21": _retry_result(
accepted=True,
state=TransferExecutionState.RETRY_WAIT,
message="整理任务已登记重试",
),
"task-22": _retry_result(
accepted=False,
state=TransferExecutionState.RUNNING,
message="整理任务当前状态不接受用户重试",
),
},
)
started: list[dict] = []
prompted: list[list[int]] = []
monkeypatch.setattr(
history_endpoint,
"build_batch_manual_redo_prompt",
lambda histories: prompted.append([history.id for history in histories]) or "legacy prompt",
)
monkeypatch.setattr(
history_endpoint,
"_start_batch_ai_redo_task",
lambda **kwargs: started.append(kwargs),
)
histories = [
TransferHistory(id=21, transfer_task_id="task-21"),
TransferHistory(id=22, transfer_task_id="task-22"),
TransferHistory(id=23),
]
response = asyncio.run(
history_endpoint.batch_ai_redo_transfer_history(
BatchTransferHistoryRedoRequest(history_ids=[21, 22, 23]),
query=_HistoryQuery(histories),
runtime_config=_runtime(),
task_registry=object(),
_=object(),
)
)
assert response.success is False
assert "已登记 1 个持久整理任务重试" in response.message
assert "#22 [running]: 整理任务当前状态不接受用户重试" in response.message
assert response.data is None
assert "1 条旧历史未提交" in response.message
assert prompted == []
assert [call[1]["task_id"] for call in _RetryCommand.calls] == [
"task-21",
"task-22",
]
assert started == []
def test_batch_ai_redo_sends_only_legacy_records_after_durable_acceptance(
monkeypatch,
) -> None:
"""混合批量全接受时 durable 只登记重试,旧历史才进入 Agent。"""
_install_retry_command(
monkeypatch,
{
"task-24": _retry_result(
accepted=True,
state=TransferExecutionState.RETRY_WAIT,
message="整理任务已登记重试",
)
},
)
prompted: list[list[int]] = []
started: list[dict] = []
monkeypatch.setattr(
history_endpoint,
"build_batch_manual_redo_prompt",
lambda histories: prompted.append([history.id for history in histories]) or "legacy prompt",
)
monkeypatch.setattr(
history_endpoint,
"_start_batch_ai_redo_task",
lambda **kwargs: started.append(kwargs),
)
response = asyncio.run(
history_endpoint.batch_ai_redo_transfer_history(
BatchTransferHistoryRedoRequest(history_ids=[24, 25]),
query=_HistoryQuery([
TransferHistory(id=24, transfer_task_id="task-24"),
TransferHistory(id=25),
]),
runtime_config=_runtime(),
task_registry=object(),
_=object(),
)
)
assert response.success is True
assert response.data is not None
assert response.data["history_ids"] == [24, 25]
assert prompted == [[25]]
assert started[0]["history_ids"] == [25]
def test_agent_delete_tool_requests_retry_before_any_destructive_action(monkeypatch) -> None:
"""Agent 工具命中 durable 历史时保留目标、历史和失败证据。"""
history = SimpleNamespace(
id=31,
transfer_task_id="task-31",
dest_fileitem={"path": "/library/demo.mkv"},
)
delete_calls: list[int] = []
class _HistoryPort:
"""提供 durable 历史并观察是否发生删除。"""
async def async_get(self, history_id: int) -> object:
"""返回 durable 历史。"""
assert history_id == 31
return history
async def async_delete(self, history_id: int) -> None:
"""记录不应发生的历史删除。"""
delete_calls.append(history_id)
retry_calls: list[dict] = []
monkeypatch.setattr(
"app.agent.tools.impl.delete_transfer_history.get_agent_transfer_history_port",
_HistoryPort,
)
monkeypatch.setattr(
"app.agent.tools.impl.delete_transfer_history._request_transfer_retry",
lambda **kwargs: retry_calls.append(kwargs)
or _retry_result(
accepted=False,
state=TransferExecutionState.MANUAL_REVIEW,
message="人工复核任务必须先完成专门判定",
),
)
monkeypatch.setattr(
"app.agent.tools.impl.delete_transfer_history.StorageChain",
lambda: (_ for _ in ()).throw(
AssertionError("durable 目标文件不得删除")
),
)
result = asyncio.run(
DeleteTransferHistoryTool(
session_id="redo-session",
user_id="10001",
).run(history_id=31)
)
assert "未登记重试" in result
assert "state=manual_review" in result
assert "不要调用 transfer_file" in result
assert retry_calls == [
{
"history_id": 31,
"task_id": "task-31",
"user_id": "10001",
}
]
assert delete_calls == []
@@ -45,3 +45,22 @@ def test_batch_manual_redo_job_definition_contains_plain_text_rules():
assert any("plain text only" in rule for rule in task_rules)
assert any("Markdown formatting" in rule for rule in task_rules)
def test_manual_redo_tasks_stop_after_durable_retry_result():
"""系统任务必须禁止 durable 历史在登记重试后继续直接整理。"""
definition = prompt_manager.load_system_tasks_definition()
for task_name in (
"transfer_failed_retry",
"batch_transfer_failed_retry",
"manual_transfer_redo",
"batch_manual_transfer_redo",
):
task = definition.task_types[task_name]
instructions = [*task.steps, *task.task_rules]
assert any("persistent retry scheduler" in item for item in instructions)
assert any(
"do not call `transfer_file`" in item.lower()
for item in instructions
)
+17
View File
@@ -37,6 +37,7 @@ def _history():
download_hash="abc",
src_fileitem={"path": "/downloads/demo.mkv"},
dest_fileitem={"path": "/media/demo.mkv"},
transfer_task_id=None,
)
@@ -119,5 +120,21 @@ def test_transfer_truncate_uses_single_transaction():
result = command.truncate()
assert result.success is True
assert result.message == "已清空旧整理记录,失败任务记录已保留"
dependencies["repository"].stage_truncate.assert_called_once_with()
dependencies["unit_of_work"].commit.assert_called_once_with()
def test_transfer_delete_rejects_durable_receipt_before_file_side_effects():
"""durable 回执不能被历史 API 连同源或目标文件一起删除。"""
history = _history()
history.transfer_task_id = "task-durable"
command, dependencies = _transfer_command(history=history)
result = command.delete(7, delete_source=True, delete_destination=True)
assert result.success is False
assert result.message == "持久整理失败记录不可删除,请使用重试或人工复核入口"
dependencies["delete_media_file"].assert_not_called()
dependencies["repository"].stage_delete.assert_not_called()
dependencies["unit_of_work"].commit.assert_not_called()
+45
View File
@@ -3,6 +3,7 @@ import importlib
import pytest
from app.application.transfer import TransferTask as CanonicalTransferTask
from app.db.models.transferhistory import TransferHistory
from app.schemas.file import FileItem
@@ -136,6 +137,50 @@ def test_legacy_transfer_history_writes_delegate_to_application_service(
assert captured == {**arguments, "transfer_history_oper": oper}
def test_legacy_transfer_history_mutations_preserve_durable_receipts(db):
"""旧插件 delete/truncate/add_force 不能删除或覆盖 durable 终态回执。"""
legacy = importlib.import_module("app.db.transferhistory_oper")
durable = TransferHistory(
src="/downloads/durable.mkv",
src_storage="local",
status=True,
transfer_task_id="task-durable",
transfer_settlement_revision=1,
)
legacy_row = TransferHistory(
src="/downloads/legacy.mkv",
src_storage="local",
status=True,
)
db.add(durable, legacy_row)
oper = legacy.TransferHistoryOper(db.session)
oper.delete(durable.id)
oper.truncate()
assert TransferHistory.get_by_transfer_task_id(
db.session,
task_id="task-durable",
) is not None
assert TransferHistory.get_by_src(
db.session,
"/downloads/legacy.mkv",
"local",
) is None
with pytest.raises(ValueError, match="持久整理回执"):
oper.add_force(
src="/downloads/durable.mkv",
src_storage="local",
status=False,
)
receipt = TransferHistory.get_by_transfer_task_id(
db.session,
task_id="task-durable",
)
assert receipt is not None
assert receipt.status is True
class LegacyPydanticValue:
"""模拟旧插件放进 TransferTask 的 Pydantic 风格对象。"""
+213
View File
@@ -0,0 +1,213 @@
"""验证历史重试入口只把 durable 任务交给持久恢复调度器。"""
from types import SimpleNamespace
from app.application.transfer_execution import (
TransferExecutionState,
TransferRetryRequestResult,
)
from app.chain.transfer import TransferChain
from app.schemas.types import NotificationChannel
class _RetryCommand:
"""记录历史入口提交的类型化重试请求。"""
calls: list[tuple[object, dict]] = []
result = TransferRetryRequestResult(
accepted=True,
state=TransferExecutionState.RETRY_WAIT,
retry_generation=2,
message="整理任务已登记重试",
)
def __init__(self, repository: object) -> None:
"""保存测试仓储实例。"""
self._repository = repository
def request_retry(self, **kwargs) -> TransferRetryRequestResult:
"""记录请求并返回用例指定结果。"""
self.calls.append((self._repository, kwargs))
return self.result
def _install_retry_port(monkeypatch) -> object:
"""安装不会接触数据库的 execution 端口与命令替身。"""
repository = object()
_RetryCommand.calls = []
_RetryCommand.result = TransferRetryRequestResult(
accepted=True,
state=TransferExecutionState.RETRY_WAIT,
retry_generation=2,
message="整理任务已登记重试",
)
monkeypatch.setattr(
"app.chain._transfer.get_chain_transfer_execution_port",
lambda: repository,
)
monkeypatch.setattr(
"app.chain._transfer.TransferExecutionCommand",
_RetryCommand,
)
return repository
def test_durable_history_redo_only_requests_persistent_retry(monkeypatch):
"""durable 重做不得检查源文件、重新识别或重新准入执行。"""
repository = _install_retry_port(monkeypatch)
history = SimpleNamespace(
id=81,
transfer_task_id="transfer-task-81",
src="/missing/source.mkv",
)
monkeypatch.setattr(
"app.chain._transfer.get_chain_transfer_history_port",
lambda: SimpleNamespace(get=lambda history_id: history),
)
monkeypatch.setattr(
"app.chain._transfer.Path.exists",
lambda _path: (_ for _ in ()).throw(
AssertionError("durable 重试不应检查源路径")
),
)
chain = object.__new__(TransferChain)
monkeypatch.setattr(
chain,
"do_transfer",
lambda **_kwargs: (_ for _ in ()).throw(
AssertionError("durable 重试不应重新准入")
),
)
state, message = chain._re_transfer(logid=81)
assert state is True
assert message == "整理任务已登记重试"
assert _RetryCommand.calls == [
(
repository,
{
"task_id": "transfer-task-81",
"reason": "用户请求重试整理历史 #81",
"requested_by": "history_redo",
},
)
]
def test_durable_manual_cleanup_keeps_target_history_and_failure_budget(monkeypatch):
"""手动重整命中 durable 历史时不得先删目标、历史或失败计数。"""
_install_retry_port(monkeypatch)
history = SimpleNamespace(
id=82,
transfer_task_id="transfer-task-82",
status=False,
mode="copy",
src="/downloads/source.mkv",
src_storage="local",
dest_fileitem={
"storage": "local",
"path": "/library/source.mkv",
"type": "file",
},
)
history_port = SimpleNamespace(
delete=lambda _history_id: (_ for _ in ()).throw(
AssertionError("durable 历史不得删除")
)
)
monkeypatch.setattr(
"app.chain._transfer.StorageChain",
lambda: (_ for _ in ()).throw(
AssertionError("durable 目标不得删除")
),
)
monkeypatch.setattr(
"app.chain._transfer.clear_transfer_failures",
lambda *_args: (_ for _ in ()).throw(
AssertionError("durable 失败计数不得清零")
),
)
chain = object.__new__(TransferChain)
state, message = chain._delete_manual_transfer_history(
history=history,
transfer_history_oper=history_port,
)
assert state is False
assert message == "整理任务已登记重试"
assert _RetryCommand.calls[0][1]["requested_by"] == "manual_reorganize"
def test_durable_ai_button_bypasses_agent_and_requests_scheduler(monkeypatch):
"""AI 按钮命中 durable 历史时也只能登记调度重试。"""
_install_retry_port(monkeypatch)
history = SimpleNamespace(id=83, transfer_task_id="transfer-task-83")
monkeypatch.setattr(
"app.chain._transfer.get_chain_transfer_history_port",
lambda: SimpleNamespace(get=lambda history_id: history),
)
monkeypatch.setattr(
"app.chain._transfer.build_manual_redo_prompt",
lambda _history: (_ for _ in ()).throw(
AssertionError("durable 重试不得生成 Agent 破坏性提示词")
),
)
monkeypatch.setattr(
"app.chain._transfer.get_task_registry",
lambda: (_ for _ in ()).throw(
AssertionError("durable 重试不得提交 Agent 任务")
),
)
messages = []
chain = object.__new__(TransferChain)
chain.runtime_config = SimpleNamespace(
ai_agent_enable=False,
history_url="/history",
)
chain.post_message = messages.append
chain._take_over_transfer_history_by_ai(
history_id=83,
channel=NotificationChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
)
assert len(messages) == 1
assert messages[0].title == "整理任务已登记重试"
assert _RetryCommand.calls[0][1]["requested_by"] == "ai_retry_button"
def test_durable_manual_review_rejection_does_not_fall_back_to_legacy(monkeypatch):
"""人工复核任务被拒绝后不得回退到旧识别和重整流程。"""
_install_retry_port(monkeypatch)
_RetryCommand.result = TransferRetryRequestResult(
accepted=False,
state=TransferExecutionState.MANUAL_REVIEW,
retry_generation=1,
message="人工复核任务必须先完成专门判定",
)
history = SimpleNamespace(
id=84,
transfer_task_id="transfer-task-84",
src="/downloads/source.mkv",
)
monkeypatch.setattr(
"app.chain._transfer.get_chain_transfer_history_port",
lambda: SimpleNamespace(get=lambda history_id: history),
)
monkeypatch.setattr(
"app.chain._transfer.Path.exists",
lambda _path: (_ for _ in ()).throw(
AssertionError("拒绝后不得回退旧流程")
),
)
chain = object.__new__(TransferChain)
state, message = chain._re_transfer(logid=84)
assert state is False
assert message == "人工复核任务必须先完成专门判定"
+769
View File
@@ -0,0 +1,769 @@
"""整理执行证据 3.0.16 Alembic 迁移的保守升级与可逆性测试。"""
import importlib
from datetime import datetime, timezone
from io import StringIO
from types import SimpleNamespace
import pytest
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
from sqlalchemy.orm import sessionmaker
from app.application.transfer_execution import (
TransferExecutionCommand,
TransferExecutionConflictError,
TransferExecutionState,
TransferManualReviewDecision,
TransferManualReviewQuery,
TransferStepIntent,
TransferStepResult,
)
from app.db.adapters.transfer_execution import (
TransactionalTransferExecutionRepository,
)
from app.db.models.transferpending import TransferPending
MIGRATION = "database.versions.e5c7a9b1d3f6_3_0_16"
LEGACY_DIAGNOSTIC = "升级检测到既有执行迹象,需人工确认后再处理"
def _bind_migration(monkeypatch, connection):
"""把 3.0.16 迁移绑定到隔离 SQLite 连接。"""
migration = importlib.import_module(MIGRATION)
monkeypatch.setattr(
migration,
"op",
Operations(MigrationContext.configure(connection)),
)
return migration
def _create_legacy_tables(connection) -> tuple[sa.Table, sa.Table]:
"""创建具备 3.0.15 租约字段的最小 pending/history 表。"""
metadata = sa.MetaData()
pending = sa.Table(
"transferpending",
metadata,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("task_id", sa.String(64), nullable=False),
sa.Column("storage", sa.String(), nullable=False),
sa.Column("src_path", sa.String(), nullable=False),
sa.Column("created_at", sa.String()),
sa.Column("state", sa.String(32), nullable=False),
sa.Column("updated_at", sa.String(40)),
sa.Column("last_error", sa.Text()),
sa.Column("input_version", sa.Integer(), nullable=False),
sa.Column("planning_input", sa.JSON(), nullable=False),
sa.Column("input_fingerprint", sa.String(64), nullable=False),
sa.Column("checkpoint_version", sa.Integer()),
sa.Column("checkpoint_payload", sa.JSON()),
sa.Column("planned_at", sa.String(40)),
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(), nullable=False),
sa.UniqueConstraint("task_id", name="uq_transferpending_task_id"),
)
history = sa.Table(
"transferhistory",
metadata,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("src", sa.String()),
sa.Column("src_storage", sa.String(), nullable=False),
)
metadata.create_all(connection)
return pending, history
def _insert_legacy_rows(connection, pending: sa.Table) -> None:
"""写入安全未开始和多种执行结果未知的旧任务。"""
base = {
"storage": "local",
"created_at": "2026-08-27 10:00:00",
"updated_at": "2026-08-27 10:00:00",
"last_error": None,
"input_version": 1,
"planning_input": {"schema_version": 1},
"input_fingerprint": "0" * 64,
"checkpoint_version": None,
"checkpoint_payload": None,
"planned_at": None,
"lease_owner": None,
"lease_token": None,
"lease_expires_at": None,
"heartbeat_at": None,
"attempt_count": 0,
}
rows = [
{**base, "id": 1, "task_id": "safe", "src_path": "/safe", "state": "accepted"},
{
**base,
"id": 2,
"task_id": "planned",
"src_path": "/planned",
"state": "planned",
"checkpoint_version": 1,
"checkpoint_payload": {"schema_version": 1},
},
{
**base,
"id": 3,
"task_id": "attempted",
"src_path": "/attempted",
"state": "accepted",
"attempt_count": 1,
},
{
**base,
"id": 4,
"task_id": "provider",
"src_path": "/provider",
"state": "provider_pending",
"checkpoint_version": 1,
"checkpoint_payload": {"provider": True},
},
]
connection.execute(pending.insert(), rows)
def _assert_execution_tables_match_models(connection) -> None:
"""断言步骤与 append-only 回执表的字段、约束和索引精确匹配 ORM。"""
inspector = sa.inspect(connection)
step_columns = {
column["name"]: column["nullable"]
for column in inspector.get_columns("transferexecutionstep")
}
assert step_columns == {
"id": False,
"task_id": False,
"operation_id": False,
"checkpoint_fingerprint": False,
"ordinal": False,
"phase": False,
"kind": False,
"state": False,
"attempt_token": True,
"attempt_count": False,
"intent_version": False,
"intent_payload": False,
"result_version": True,
"result_payload": True,
"last_error": True,
"prepared_at": False,
"started_at": True,
"completed_at": True,
"updated_at": False,
}
assert {
column["name"]: str(column["type"])
for column in inspector.get_columns("transferexecutionstep")
} == {
"id": "INTEGER",
"task_id": "VARCHAR(64)",
"operation_id": "VARCHAR(64)",
"checkpoint_fingerprint": "VARCHAR(64)",
"ordinal": "INTEGER",
"phase": "VARCHAR(32)",
"kind": "VARCHAR(32)",
"state": "VARCHAR(32)",
"attempt_token": "VARCHAR(64)",
"attempt_count": "INTEGER",
"intent_version": "INTEGER",
"intent_payload": "JSON",
"result_version": "INTEGER",
"result_payload": "JSON",
"last_error": "TEXT",
"prepared_at": "VARCHAR(40)",
"started_at": "VARCHAR(40)",
"completed_at": "VARCHAR(40)",
"updated_at": "VARCHAR(40)",
}
assert inspector.get_pk_constraint("transferexecutionstep")[
"constrained_columns"
] == ["id"]
assert [
(
item["constrained_columns"],
item["referred_table"],
item["referred_columns"],
item["options"].get("ondelete"),
)
for item in inspector.get_foreign_keys("transferexecutionstep")
] == [(["task_id"], "transferpending", ["task_id"], "CASCADE")]
step_uniques = {
item["name"]: item["column_names"]
for item in inspector.get_unique_constraints("transferexecutionstep")
}
assert step_uniques == {
"uq_transferexecutionstep_operation_id": ["operation_id"],
"uq_transferexecutionstep_task_ordinal": ["task_id", "ordinal"],
}
step_indexes = {
item["name"]: item["column_names"]
for item in inspector.get_indexes("transferexecutionstep")
}
assert step_indexes == {
"ix_transferexecutionstep_task_state_ordinal": [
"task_id",
"state",
"ordinal",
],
}
receipt_columns = {
column["name"]: column["nullable"]
for column in inspector.get_columns("transfersettlementreceipt")
}
assert receipt_columns == {
"id": False,
"task_id": False,
"history_id": False,
"settlement_revision": False,
"outcome": False,
"execution_fingerprint": False,
"lease_token": False,
"history_status": False,
"src": True,
"src_storage": True,
"pending_deleted": False,
"error": True,
"created_at": False,
"updated_at": False,
}
assert {
column["name"]: str(column["type"])
for column in inspector.get_columns("transfersettlementreceipt")
} == {
"id": "INTEGER",
"task_id": "VARCHAR(64)",
"history_id": "INTEGER",
"settlement_revision": "INTEGER",
"outcome": "VARCHAR(16)",
"execution_fingerprint": "VARCHAR(64)",
"lease_token": "VARCHAR(64)",
"history_status": "BOOLEAN",
"src": "VARCHAR",
"src_storage": "VARCHAR",
"pending_deleted": "BOOLEAN",
"error": "TEXT",
"created_at": "VARCHAR(40)",
"updated_at": "VARCHAR(40)",
}
assert inspector.get_pk_constraint("transfersettlementreceipt")[
"constrained_columns"
] == ["id"]
assert inspector.get_foreign_keys("transfersettlementreceipt") == []
receipt_uniques = {
item["name"]: item["column_names"]
for item in inspector.get_unique_constraints("transfersettlementreceipt")
}
assert receipt_uniques == {
"uq_transfersettlementreceipt_task_revision": [
"task_id",
"settlement_revision",
],
}
receipt_indexes = {
item["name"]: item["column_names"]
for item in inspector.get_indexes("transfersettlementreceipt")
}
assert receipt_indexes == {
"ix_transfersettlementreceipt_history_id": ["history_id"],
"ix_transfersettlementreceipt_task_revision": [
"task_id",
"settlement_revision",
],
}
def test_upgrade_is_conservative_and_repairs_interrupted_indexes(monkeypatch):
"""旧执行迹象必须隔离,重复升级应补齐索引且不覆盖保守状态。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
pending, _ = _create_legacy_tables(connection)
_insert_legacy_rows(connection, pending)
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
connection.execute(sa.text(
"DROP INDEX ix_transferexecutionstep_task_state_ordinal"
))
connection.execute(sa.text(
"DROP INDEX ix_transfersettlementreceipt_history_id"
))
connection.execute(sa.text(
"DROP INDEX ix_transfersettlementreceipt_task_revision"
))
migration.upgrade()
states = dict(connection.execute(sa.text(
"SELECT task_id, execution_state FROM transferpending ORDER BY id"
)).all())
assert states == {
"safe": "not_started",
"planned": "manual_review",
"attempted": "manual_review",
"provider": "manual_review",
}
inspector = sa.inspect(connection)
assert {
"transferexecutionstep",
"transfersettlementreceipt",
}.issubset(inspector.get_table_names())
execution_due_index = next(
index
for index in inspector.get_indexes("transferpending")
if index["name"] == "ix_transferpending_execution_due"
)
assert execution_due_index["column_names"] == [
"execution_state",
"retry_due_at",
"state",
"created_at",
"id",
]
assert "ix_transferexecutionstep_task_state_ordinal" in {
index["name"] for index in inspector.get_indexes("transferexecutionstep")
}
assert "ux_transferhistory_transfer_task_id" in {
index["name"] for index in inspector.get_indexes("transferhistory")
}
_assert_execution_tables_match_models(connection)
engine.dispose()
def test_downgrade_marks_step_evidence_then_reupgrade_keeps_manual_review(
monkeypatch,
) -> None:
"""降级不得丢失执行不确定性,再升级也不能把该任务自动重放。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
pending, _ = _create_legacy_tables(connection)
_insert_legacy_rows(connection, pending)
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
connection.execute(sa.text(
"INSERT INTO transferexecutionstep ("
"task_id, operation_id, checkpoint_fingerprint, ordinal, phase, kind, "
"state, attempt_token, attempt_count, intent_version, intent_payload, "
"prepared_at, updated_at"
") VALUES ("
"'safe', 'operation', 'plan', 0, 'transfer', 'copy', "
"'prepared', NULL, 0, 1, '{}', "
"'2026-08-27 10:00:00', '2026-08-27 10:00:00'"
")"
))
connection.execute(sa.text(
"UPDATE transferpending SET last_error = '原始失败细节' "
"WHERE task_id = 'safe'"
))
migration.downgrade()
assert connection.execute(sa.text(
"SELECT state FROM transferpending WHERE task_id = 'safe'"
)).scalar_one() == "accepted"
assert connection.execute(sa.text(
"SELECT last_error FROM transferpending WHERE task_id = 'safe'"
)).scalar_one() == f"原始失败细节\n{LEGACY_DIAGNOSTIC}"
inspector = sa.inspect(connection)
assert "transferexecutionstep" not in inspector.get_table_names()
assert "transfersettlementreceipt" not in inspector.get_table_names()
assert "execution_state" not in {
column["name"] for column in inspector.get_columns("transferpending")
}
migration.upgrade()
assert connection.execute(sa.text(
"SELECT execution_state FROM transferpending WHERE task_id = 'safe'"
)).scalar_one() == "manual_review"
engine.dispose()
def test_upgrade_without_pending_table_is_a_safe_noop(monkeypatch):
"""全新数据库尚未执行前置迁移时本版本应安全等待迁移链建表。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
assert sa.inspect(connection).get_table_names() == []
engine.dispose()
def test_execution_table_ddl_compiles_for_postgresql(monkeypatch) -> None:
"""步骤与回执建表 DDL 必须在生产 PostgreSQL 方言下可编译。"""
output = StringIO()
context = MigrationContext.configure(
dialect_name="postgresql",
opts={"as_sql": True, "output_buffer": output},
)
migration = importlib.import_module(MIGRATION)
monkeypatch.setattr(migration, "op", Operations(context))
migration._create_step_table()
migration._create_receipt_table()
ddl = output.getvalue()
assert "CREATE TABLE transferexecutionstep" in ddl
assert "FOREIGN KEY(task_id) REFERENCES transferpending (task_id) ON DELETE CASCADE" in ddl
assert "CREATE TABLE transfersettlementreceipt" in ddl
assert "CONSTRAINT uq_transfersettlementreceipt_task_revision UNIQUE" in ddl
def test_repair_indexes_ignores_postgresql_unique_backing_index(monkeypatch) -> None:
"""PG 唯一约束后端索引应留给约束治理,普通意外索引仍须删除。"""
migration = importlib.import_module(MIGRATION)
table_name = "transfersettlementreceipt"
indexes = [
{
"name": "uq_transfersettlementreceipt_task_revision",
"column_names": ["task_id", "settlement_revision"],
"unique": True,
"duplicates_constraint": "uq_transfersettlementreceipt_task_revision",
},
{
"name": "ix_transfersettlementreceipt_task_revision",
"column_names": ["task_id", "settlement_revision"],
"unique": False,
},
{
"name": "ix_transfersettlementreceipt_unexpected",
"column_names": ["outcome"],
"unique": False,
},
]
dropped = []
created = []
def drop_index(index_name: str, *, table_name: str) -> None:
"""记录删除并模拟 PostgreSQL 反射结果随 DDL 更新。"""
dropped.append((index_name, table_name))
indexes[:] = [item for item in indexes if item["name"] != index_name]
inspector = SimpleNamespace(
get_table_names=lambda: [table_name],
get_indexes=lambda inspected_table: list(indexes),
)
monkeypatch.setattr(migration.sa, "inspect", lambda bind: inspector)
monkeypatch.setattr(
migration,
"op",
SimpleNamespace(
get_bind=lambda: object(),
drop_index=drop_index,
create_index=lambda *args, **kwargs: created.append((args, kwargs)),
),
)
migration._repair_indexes(
table_name=table_name,
expected={
"ix_transfersettlementreceipt_task_revision": (
"task_id",
"settlement_revision",
),
},
)
assert dropped == [
("ix_transfersettlementreceipt_unexpected", table_name),
]
assert created == []
assert any(
item["name"] == "uq_transfersettlementreceipt_task_revision"
for item in indexes
)
def test_interrupted_column_upgrade_preserves_existing_manual_state(monkeypatch):
"""字段阶段中断后重跑应补齐 schema 且保留已写入的人工复核状态。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
pending, _ = _create_legacy_tables(connection)
_insert_legacy_rows(connection, pending)
connection.execute(sa.text(
"ALTER TABLE transferpending ADD COLUMN execution_state VARCHAR(32)"
))
connection.execute(sa.text(
"UPDATE transferpending SET execution_state = 'manual_review' "
"WHERE task_id = 'safe'"
))
connection.execute(sa.text(
"ALTER TABLE transferhistory ADD COLUMN transfer_task_id VARCHAR(64)"
))
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
assert connection.execute(sa.text(
"SELECT execution_state FROM transferpending WHERE task_id = 'safe'"
)).scalar_one() == "manual_review"
pending_columns = {
column["name"]
for column in sa.inspect(connection).get_columns("transferpending")
}
assert {
"execution_fingerprint",
"retry_generation",
"retry_requested_by",
"settlement_revision",
}.issubset(pending_columns)
assert "transfer_settlement_revision" in {
column["name"]
for column in sa.inspect(connection).get_columns("transferhistory")
}
engine.dispose()
def test_upgrade_recreates_empty_partial_execution_tables(monkeypatch) -> None:
"""中断升级留下的空残表应无损重建为完整步骤与回执 schema。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
pending, _ = _create_legacy_tables(connection)
_insert_legacy_rows(connection, pending)
connection.execute(sa.text(
"CREATE TABLE transferexecutionstep ("
"id INTEGER PRIMARY KEY, task_id VARCHAR(64) NOT NULL)"
))
connection.execute(sa.text(
"CREATE TABLE transfersettlementreceipt ("
"id INTEGER PRIMARY KEY, task_id VARCHAR(64) NOT NULL)"
))
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
_assert_execution_tables_match_models(connection)
engine.dispose()
def test_upgrade_replaces_old_single_task_receipt_unique(monkeypatch) -> None:
"""中断版本的 task 单列唯一约束必须移除,保留数据后允许追加新 revision。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
pending, _ = _create_legacy_tables(connection)
_insert_legacy_rows(connection, pending)
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
connection.execute(sa.text(
"INSERT INTO transfersettlementreceipt ("
"task_id, history_id, settlement_revision, outcome, "
"execution_fingerprint, lease_token, history_status, src, src_storage, "
"pending_deleted, error, created_at, updated_at"
") VALUES ("
"'task-a', 1, 1, 'succeeded', 'fingerprint-1', 'lease-1', 1, "
"'/src', 'local', 1, NULL, "
"'2026-08-27 10:00:00', '2026-08-27 10:00:00'"
")"
))
with migration.op.batch_alter_table("transfersettlementreceipt") as batch_op:
batch_op.drop_constraint(
"uq_transfersettlementreceipt_task_revision",
type_="unique",
)
batch_op.create_unique_constraint(
"uq_transfersettlementreceipt_task_id",
["task_id"],
)
migration.upgrade()
connection.execute(sa.text(
"INSERT INTO transfersettlementreceipt ("
"task_id, history_id, settlement_revision, outcome, "
"execution_fingerprint, lease_token, history_status, src, src_storage, "
"pending_deleted, error, created_at, updated_at"
") VALUES ("
"'task-a', 2, 2, 'failed', 'fingerprint-2', 'lease-2', 0, "
"'/src', 'local', 1, 'failed', "
"'2026-08-27 11:00:00', '2026-08-27 11:00:00'"
")"
))
assert connection.execute(sa.text(
"SELECT settlement_revision FROM transfersettlementreceipt "
"WHERE task_id = 'task-a' ORDER BY settlement_revision"
)).scalars().all() == [1, 2]
_assert_execution_tables_match_models(connection)
engine.dispose()
@pytest.mark.parametrize(
("table_name", "create_sql", "insert_sql"),
(
(
"transferexecutionstep",
"CREATE TABLE transferexecutionstep ("
"id INTEGER PRIMARY KEY, task_id VARCHAR(64) NOT NULL)",
"INSERT INTO transferexecutionstep (id, task_id) VALUES (1, 'planned')",
),
(
"transfersettlementreceipt",
"CREATE TABLE transfersettlementreceipt ("
"id INTEGER PRIMARY KEY, task_id VARCHAR(64) NOT NULL)",
"INSERT INTO transfersettlementreceipt (id, task_id) "
"VALUES (1, 'settled')",
),
),
)
def test_upgrade_rejects_nonempty_partial_execution_table(
monkeypatch,
table_name: str,
create_sql: str,
insert_sql: str,
) -> None:
"""含数据残表不能猜测修复,且必须报告明确迁移冲突而非缺列 SQL。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
pending, _ = _create_legacy_tables(connection)
_insert_legacy_rows(connection, pending)
connection.execute(sa.text(create_sql))
connection.execute(sa.text(insert_sql))
migration = _bind_migration(monkeypatch, connection)
with pytest.raises(
RuntimeError,
match=rf"含数据的不完整迁移表 {table_name}.*缺少字段",
):
migration.upgrade()
engine.dispose()
def test_upgrade_adds_synthetic_review_when_nonmanual_step_already_exists(
monkeypatch,
) -> None:
"""已有普通步骤不代表可人工判定,迁移仍须补 synthetic 并稳定回填时间。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
pending, _ = _create_legacy_tables(connection)
_insert_legacy_rows(connection, pending)
connection.execute(sa.text(
"UPDATE transferpending SET updated_at = NULL WHERE task_id = 'planned'"
))
migration = _bind_migration(monkeypatch, connection)
migration._add_pending_columns()
migration._backfill_pending()
migration._create_step_table()
connection.execute(sa.text(
"INSERT INTO transferexecutionstep ("
"task_id, operation_id, checkpoint_fingerprint, ordinal, phase, kind, "
"state, attempt_token, attempt_count, intent_version, intent_payload, "
"prepared_at, updated_at"
") VALUES ("
"'planned', 'existing-operation', 'existing-plan', 0, 'transfer', 'copy', "
"'prepared', NULL, 0, 1, '{}', "
"'2026-08-27 09:00:00', '2026-08-27 09:00:00'"
")"
))
migration.upgrade()
migration.upgrade()
rows = connection.execute(sa.text(
"SELECT kind, state, prepared_at FROM transferexecutionstep "
"WHERE task_id = 'planned' ORDER BY ordinal"
)).all()
assert rows == [
("copy", "prepared", "2026-08-27 09:00:00"),
("legacy_execution_review", "manual_review", "2026-08-27 10:00:00"),
]
engine.dispose()
def test_migrated_legacy_reviews_are_discoverable_resolvable_and_retryable(
monkeypatch,
) -> None:
"""迁移遗留任务应可分页判定,并在判定后准备真实首步骤。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
pending, _ = _create_legacy_tables(connection)
_insert_legacy_rows(connection, pending)
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
factory = sessionmaker(bind=engine, expire_on_commit=False)
repository = TransactionalTransferExecutionRepository(
factory,
local_clock=lambda: datetime(2026, 8, 27, 11, 0, 0),
lease_clock=lambda: datetime(
2026,
8,
27,
3,
0,
0,
tzinfo=timezone.utc,
),
)
query = TransferManualReviewQuery(repository)
command = TransferExecutionCommand(repository)
first_page = query.list(page=1, page_size=2)
second_page = query.list(page=2, page_size=2)
assert first_page.total == 3
assert len(first_page.items) == 2
assert len(second_page.items) == 1
reviews = {
item.task_id: item
for item in (*first_page.items, *second_page.items)
}
assert set(reviews) == {"planned", "attempted", "provider"}
assert all(
item.step.kind == "legacy_execution_review"
for item in reviews.values()
)
assert query.get(task_id="planned") == reviews["planned"]
with pytest.raises(
TransferExecutionConflictError,
match="没有足够证据证明外部操作已发生",
):
command.resolve_manual_review(
task_id="planned",
operation_id=reviews["planned"].step.operation_id,
decision=TransferManualReviewDecision.APPLIED,
actor="admin",
reason="无法仅凭旧状态确认外部结果",
result=TransferStepResult(payload={"confirmed": True}),
)
assert query.get(task_id="planned").state is TransferExecutionState.MANUAL_REVIEW
resolved = []
for task_id in ("planned", "attempted"):
resolved.append(command.resolve_manual_review(
task_id=task_id,
operation_id=reviews[task_id].step.operation_id,
decision=TransferManualReviewDecision.NOT_APPLIED,
actor="admin",
reason="已回滚或确认旧步骤未发生",
result=TransferStepResult(payload={"confirmed": False}),
))
assert all(
item.state is TransferExecutionState.RETRY_WAIT
for item in resolved
)
retry_page = query.list(
state=TransferExecutionState.RETRY_WAIT,
page=1,
page_size=10,
)
assert {item.task_id for item in retry_page.items} == {"planned", "attempted"}
assert query.get(task_id="planned").step.evidence == {"confirmed": False}
assert query.get(task_id="attempted").step.evidence == {"confirmed": False}
with factory() as session:
rows = list(session.scalars(
sa.select(TransferPending).where(
TransferPending.task_id.in_(("planned", "attempted"))
)
).all())
for row in rows:
row.lease_owner = "worker"
row.lease_token = f"lease-{row.task_id}"
row.lease_expires_at = "2099-01-01 00:00:00.000000"
session.commit()
for task_id in ("planned", "attempted"):
snapshot = repository.get_snapshot(task_id=task_id)
assert snapshot is not None
assert snapshot.steps == ()
prepared = command.prepare(
task_id=task_id,
lease_token=f"lease-{task_id}",
intent=TransferStepIntent.create(
task_id=task_id,
checkpoint_fingerprint="f" * 64,
ordinal=0,
phase="transfer",
kind="copy",
payload={"source": task_id},
),
)
assert prepared.ordinal == 0
engine.dispose()
@@ -0,0 +1,454 @@
"""验证整理执行证据、CAS fencing 与终态结算持久化。"""
from datetime import datetime, timezone
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.application.transfer_execution import (
TransferExecutionCheckpoint,
TransferExecutionCommand,
TransferExecutionConflictError,
TransferExecutionState,
TransferManualReviewDecision,
TransferOperationObservation,
TransferOperationObservationState,
TransferStepIntent,
TransferStepResult,
TransferStepState,
build_transfer_checkpoint_fingerprint,
build_transfer_operation_id,
)
from app.db.adapters.transfer_execution import (
TransactionalTransferExecutionRepository,
)
from app.db.base import Base
from app.db.models.transferexecutionstep import TransferExecutionStep
from app.db.models.transferhistory import TransferHistory
from app.db.models.transferpending import TransferPending
@pytest.fixture
def execution_store():
"""构造只含整理执行相关表的独立内存数据库。"""
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(
engine,
tables=[
TransferPending.__table__,
TransferHistory.__table__,
TransferExecutionStep.__table__,
],
)
factory = sessionmaker(bind=engine, expire_on_commit=False)
try:
yield factory
finally:
engine.dispose()
def _seed_pending(factory, *, task_id: str = "task-1", lease_token: str = "lease-1"):
"""写入一条带有效租约与合法 planning checkpoint 的待执行任务。"""
with factory() as session:
session.add(TransferPending(
task_id=task_id,
storage="local",
src_path=f"/{task_id}.mkv",
created_at="2026-08-27 09:00:00",
state="planned",
updated_at="2026-08-27 09:00:00",
input_version=1,
planning_input={"schema_version": 1, "source": task_id},
input_fingerprint="input-fingerprint",
checkpoint_version=1,
checkpoint_payload={"schema_version": 1, "task_id": task_id},
planned_at="2026-08-27 09:00:00",
lease_owner="worker-1",
lease_token=lease_token,
lease_expires_at="2099-01-01 00:00:00.000000",
heartbeat_at="2026-08-27 01:00:00.000000",
attempt_count=1,
execution_state="not_started",
retry_generation=0,
retry_count=0,
settlement_revision=0,
))
session.commit()
def _repository(factory, token_values: list[str] | None = None):
"""构造固定时钟与可预测 attempt token 的执行命令。"""
repository = TransactionalTransferExecutionRepository(
factory,
local_clock=lambda: datetime(2026, 8, 27, 9, 30, 0),
lease_clock=lambda: datetime(2026, 8, 27, 1, 30, 0, tzinfo=timezone.utc),
)
values = iter(token_values or ["attempt-1", "attempt-2", "attempt-3"])
return repository, TransferExecutionCommand(
repository,
attempt_token_factory=lambda: next(values),
)
def _intent(*, task_id: str = "task-1", ordinal: int = 0) -> TransferStepIntent:
"""构造稳定且可重复计算身份的测试步骤意图。"""
return TransferStepIntent.create(
task_id=task_id,
checkpoint_fingerprint="plan-fingerprint",
ordinal=ordinal,
phase="transfer",
kind="copy",
payload={"src": f"/{task_id}.mkv", "dest": f"/media/{task_id}.mkv"},
)
def test_stable_operation_and_checkpoint_identities_are_canonical():
"""字段顺序不能改变 operation ID 或执行 checkpoint 指纹。"""
first = build_transfer_operation_id(
task_id="task",
checkpoint_fingerprint="plan",
ordinal=2,
phase="scrape",
kind="write",
intent_payload={"b": 2, "a": 1},
)
second = build_transfer_operation_id(
task_id="task",
checkpoint_fingerprint="plan",
ordinal=2,
phase="scrape",
kind="write",
intent_payload={"a": 1, "b": 2},
)
assert first == second
assert build_transfer_checkpoint_fingerprint({"b": 2, "a": 1}) == (
build_transfer_checkpoint_fingerprint({"a": 1, "b": 2})
)
mutable_payload = {"path": "/original"}
intent = TransferStepIntent.create(
task_id="task",
checkpoint_fingerprint="plan",
ordinal=0,
phase="transfer",
kind="copy",
payload=mutable_payload,
)
mutable_payload["path"] = "/mutated"
assert intent.payload == {"path": "/original"}
def test_success_path_persists_steps_and_execution_checkpoint(execution_store):
"""成功路径应保留每步证据,并提交可供唯一 durable writer 结算的检查点。"""
_seed_pending(execution_store)
repository, command = _repository(execution_store)
prepared = command.prepare(
task_id="task-1",
lease_token="lease-1",
intent=_intent(),
)
assert prepared.state is TransferStepState.PREPARED
started = command.begin(
task_id="task-1",
lease_token="lease-1",
operation_id=prepared.operation_id,
)
assert started.attempt_token == "attempt-1"
succeeded = command.complete(
task_id="task-1",
lease_token="lease-1",
step=started,
result=TransferStepResult(payload={"dest_exists": True}),
)
checkpoint = TransferExecutionCheckpoint.create(
payload={"dest": "/media/task-1.mkv"},
operation_ids=(succeeded.operation_id,),
)
snapshot = command.checkpoint(
task_id="task-1",
lease_token="lease-1",
checkpoint=checkpoint,
)
assert snapshot.state is TransferExecutionState.SETTLING
with execution_store() as session:
pending = session.scalar(select(TransferPending))
assert pending is not None
assert pending.execution_fingerprint == checkpoint.fingerprint
step = session.scalar(select(TransferExecutionStep))
assert step is not None and step.state == "succeeded"
def test_retry_wait_resumes_same_failed_operation_with_new_attempt(execution_store):
"""到期重试必须复用 operation ID、保留失败证据并轮换 attempt token。"""
_seed_pending(execution_store)
repository, command = _repository(execution_store)
prepared = command.prepare(
task_id="task-1", lease_token="lease-1", intent=_intent()
)
started = command.begin(
task_id="task-1",
lease_token="lease-1",
operation_id=prepared.operation_id,
)
deferred = command.defer(
task_id="task-1",
lease_token="lease-1",
step=started,
error="destination unavailable",
retry_due_at="2026-08-27 01:30:01.000000",
evidence=TransferStepResult(payload={"applied": False}),
)
assert deferred.state is TransferExecutionState.RETRY_WAIT
assert deferred.retry_generation == 1
with execution_store() as session:
claimed = TransferPending.claim_task(
session,
task_id="task-1",
states=("planned",),
owner_id="worker-2",
lease_token="lease-2",
now_time="2026-08-27 01:30:02.000000",
lease_expires_at="2099-01-01 00:00:00.000000",
updated_at="2026-08-27 09:30:02",
)
session.commit()
assert claimed == 1
resumed = command.resume_failed(
task_id="task-1",
lease_token="lease-2",
step=deferred.steps[0],
)
assert resumed.operation_id == prepared.operation_id
assert resumed.attempt_token == "attempt-2"
assert resumed.attempt_count == 2
assert resumed.result == TransferStepResult(payload={"applied": False})
assert resumed.last_error == "destination unavailable"
def test_orphan_started_requires_observation_before_attempt_rotation(execution_store):
"""遗留 STARTED 只能凭 NOT_APPLIED 证据轮换 attempt,旧 attempt 随即失效。"""
_seed_pending(execution_store)
repository, command = _repository(execution_store)
prepared = command.prepare(
task_id="task-1", lease_token="lease-1", intent=_intent()
)
started = command.begin(
task_id="task-1",
lease_token="lease-1",
operation_id=prepared.operation_id,
)
observation = TransferOperationObservation(
state=TransferOperationObservationState.NOT_APPLIED,
evidence=TransferStepResult(payload={"dest_exists": False}),
)
restarted = command.restart_after_not_applied(
task_id="task-1",
lease_token="lease-1",
step=started,
evidence=observation.evidence,
)
assert restarted.attempt_token == "attempt-2"
assert restarted.attempt_count == 2
with pytest.raises(TransferExecutionConflictError):
repository.complete_step(
task_id="task-1",
lease_token="lease-1",
operation_id=started.operation_id,
attempt_token="attempt-1",
result=TransferStepResult(payload={"stale": True}),
)
def test_zero_side_effect_checkpoint_is_vacuously_complete(execution_store):
"""纯策略拒绝可在没有步骤行时提交带 skip_reason 的确定执行结果。"""
_seed_pending(execution_store)
_, command = _repository(execution_store)
checkpoint = TransferExecutionCheckpoint.create(
payload={"preview": True, "accepted": False},
operation_ids=(),
skip_reason="preview",
)
snapshot = command.checkpoint(
task_id="task-1",
lease_token="lease-1",
checkpoint=checkpoint,
)
assert snapshot.state is TransferExecutionState.SETTLING
assert snapshot.checkpoint == checkpoint
assert snapshot.steps == ()
def test_exhausted_step_builds_failure_checkpoint_and_keeps_lease(execution_store):
"""预算耗尽应原子建立失败结算检查点,并为 durable writer 保留 lease。"""
_seed_pending(execution_store)
_, command = _repository(execution_store)
prepared = command.prepare(
task_id="task-1", lease_token="lease-1", intent=_intent()
)
started = command.begin(
task_id="task-1",
lease_token="lease-1",
operation_id=prepared.operation_id,
)
snapshot = command.exhaust(
task_id="task-1",
lease_token="lease-1",
step=started,
error="retry budget exhausted",
evidence=TransferStepResult(payload={"applied": False}),
)
assert snapshot.state is TransferExecutionState.SETTLING
assert snapshot.checkpoint is not None
assert snapshot.checkpoint.payload["outcome"] == "failed"
assert snapshot.checkpoint.payload["error"] == "retry budget exhausted"
assert snapshot.checkpoint.operation_ids == (started.operation_id,)
assert snapshot.steps[0].state is TransferStepState.FAILED
with execution_store() as session:
pending = session.scalar(select(TransferPending))
assert pending is not None
assert pending.lease_token == "lease-1"
def test_user_retry_is_single_generation_and_rejects_manual_review(execution_store):
"""FAILED 用户重试只递增一次世代,重复请求幂等且人工复核必须拒绝。"""
_seed_pending(execution_store)
with execution_store() as session:
pending = session.scalar(select(TransferPending))
assert pending is not None
pending.execution_state = "failed"
pending.lease_owner = None
pending.lease_token = None
pending.lease_expires_at = None
pending.terminal_history_id = 42
pending.retry_count = 3
session.commit()
_, command = _repository(execution_store)
first = command.request_retry(
task_id="task-1",
reason="用户确认目标未落地",
requested_by="admin",
)
repeated = command.request_retry(
task_id="task-1",
reason="重复点击",
requested_by="admin",
)
assert first.accepted and repeated.accepted
assert first.retry_generation == repeated.retry_generation == 1
with execution_store() as session:
pending = session.scalar(select(TransferPending))
assert pending is not None
assert pending.execution_state == "retry_wait"
assert pending.retry_count == 3
assert pending.terminal_history_id == 42
assert pending.retry_reason == "用户确认目标未落地"
assert pending.retry_requested_by == "admin"
pending.execution_state = "manual_review"
session.commit()
rejected = command.request_retry(
task_id="task-1",
reason="强制重试",
requested_by="admin",
)
assert not rejected.accepted
assert rejected.state is TransferExecutionState.MANUAL_REVIEW
assert "人工" in rejected.message
def test_manual_not_applied_decision_is_audited_and_schedules_same_step(
execution_store,
) -> None:
"""人工判定未发生应无 lease 地恢复 FAILED,并只交给唯一调度器。"""
_seed_pending(execution_store)
_, command = _repository(execution_store)
prepared = command.prepare(
task_id="task-1", lease_token="lease-1", intent=_intent()
)
started = command.begin(
task_id="task-1",
lease_token="lease-1",
operation_id=prepared.operation_id,
)
manual = command.manual_review(
task_id="task-1",
lease_token="lease-1",
step=started,
error="external result unknown",
)
assert manual.state is TransferExecutionState.MANUAL_REVIEW
resolved = command.resolve_manual_review(
task_id="task-1",
operation_id=started.operation_id,
decision=TransferManualReviewDecision.NOT_APPLIED,
actor="admin",
reason="目标与临时文件均不存在",
result=TransferStepResult(payload={"dest_exists": False}),
)
assert resolved.state is TransferExecutionState.RETRY_WAIT
assert resolved.review_revision == 1
assert resolved.step.state is TransferStepState.FAILED
with execution_store() as session:
pending = session.scalar(select(TransferPending))
assert pending is not None
assert pending.lease_token is None
assert pending.reviewed_by == "admin"
assert pending.review_decision == "not_applied"
assert pending.review_reason == "目标与临时文件均不存在"
with pytest.raises(TransferExecutionConflictError):
command.resolve_manual_review(
task_id="task-1",
operation_id=started.operation_id,
decision=TransferManualReviewDecision.NOT_APPLIED,
actor="admin",
reason="重复判定",
)
def test_manual_applied_requires_result_and_failed_decision_is_rejected(
execution_store,
) -> None:
"""人工判定已发生必须带结果证据,FAILED 不得绕过 lease durable 结算。"""
_seed_pending(execution_store)
_, command = _repository(execution_store)
prepared = command.prepare(
task_id="task-1", lease_token="lease-1", intent=_intent()
)
started = command.begin(
task_id="task-1",
lease_token="lease-1",
operation_id=prepared.operation_id,
)
command.manual_review(
task_id="task-1",
lease_token="lease-1",
step=started,
error="external result unknown",
)
with pytest.raises(ValueError, match="结果证据"):
command.resolve_manual_review(
task_id="task-1",
operation_id=started.operation_id,
decision=TransferManualReviewDecision.APPLIED,
actor="admin",
reason="已确认目标存在",
)
with pytest.raises(TransferExecutionConflictError, match="durable"):
command.resolve_manual_review(
task_id="task-1",
operation_id=started.operation_id,
decision=TransferManualReviewDecision.FAILED,
actor="admin",
reason="确认失败",
)
resolved = command.resolve_manual_review(
task_id="task-1",
operation_id=started.operation_id,
decision=TransferManualReviewDecision.APPLIED,
actor="admin",
reason="目标摘要匹配",
result=TransferStepResult(payload={"dest_exists": True, "hash_match": True}),
)
assert resolved.step.state is TransferStepState.SUCCEEDED
assert resolved.step.result == TransferStepResult(
payload={"dest_exists": True, "hash_match": True}
)
+217
View File
@@ -0,0 +1,217 @@
"""验证 TransferChain 步骤 runner 与文件执行器的崩溃恢复边界。"""
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import Mock
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.application.transfer_execution import (
TransferExecutionCommand,
TransferExecutionState,
TransferOperationObservation,
TransferOperationObservationState,
TransferStepIntent,
TransferStepResult,
)
from app.chain import transfer as transfer_chain_module
from app.db.adapters.transfer_execution import (
TransactionalTransferExecutionRepository,
)
from app.db.base import Base
from app.db.models.transferexecutionstep import TransferExecutionStep
from app.db.models.transferhistory import TransferHistory
from app.db.models.transferpending import TransferPending
from app.modules.filemanager.transhandler import TransHandler
from app.schemas.workflow import FileItem
@pytest.fixture
def execution_repository():
"""构造带有效 pending 租约的独立执行仓储。"""
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(
engine,
tables=[
TransferPending.__table__,
TransferHistory.__table__,
TransferExecutionStep.__table__,
],
)
factory = sessionmaker(bind=engine, expire_on_commit=False)
with factory() as session:
session.add(TransferPending(
task_id="task-runner",
storage="local",
src_path="/source.mkv",
created_at="2026-08-27 09:00:00",
state="planned",
updated_at="2026-08-27 09:00:00",
input_version=1,
planning_input={"schema_version": 1},
input_fingerprint="input",
checkpoint_version=1,
checkpoint_payload={"schema_version": 1},
planned_at="2026-08-27 09:00:00",
lease_owner="worker",
lease_token="lease",
lease_expires_at="2099-01-01 00:00:00.000000",
heartbeat_at="2026-08-27 01:00:00.000000",
attempt_count=1,
execution_state="not_started",
retry_generation=0,
retry_count=0,
settlement_revision=0,
))
session.commit()
repository = TransactionalTransferExecutionRepository(
factory,
local_clock=lambda: datetime(2026, 8, 27, 9, 30, 0),
lease_clock=lambda: datetime(2026, 8, 27, 1, 30, 0, tzinfo=timezone.utc),
)
try:
yield repository
finally:
engine.dispose()
def _runner(repository):
"""构造绑定固定任务、租约与计划身份的 durable runner。"""
return transfer_chain_module._DurableTransferStepRunner(
task_id="task-runner",
lease_token="lease",
checkpoint_fingerprint="plan",
repository=repository,
)
def test_runner_replay_returns_persisted_result_without_repeating_side_effect(
execution_repository,
):
"""成功步骤重放只能回读结果,不能再次调用外部执行函数。"""
calls = []
first = _runner(execution_repository).run(
phase="transfer",
kind="copy",
payload={"source": "/source.mkv", "target": "/target.mkv"},
execute=lambda: calls.append("executed") or TransferStepResult(
payload={"item": {"path": "/target.mkv"}}
),
observe=lambda: pytest.fail("新步骤不应执行恢复探测"),
)
second = _runner(execution_repository).run(
phase="transfer",
kind="copy",
payload={"source": "/source.mkv", "target": "/target.mkv"},
execute=lambda: pytest.fail("已成功步骤不得重复执行"),
observe=lambda: pytest.fail("已成功步骤不得执行恢复探测"),
)
assert first == second
assert calls == ["executed"]
def test_runner_routes_unknown_orphaned_attempt_to_manual_review(
execution_repository,
):
"""遗留 STARTED 无法严格判断时必须隔离,不能再次执行副作用。"""
command = TransferExecutionCommand(execution_repository)
prepared = command.prepare(
task_id="task-runner",
lease_token="lease",
intent=TransferStepIntent.create(
task_id="task-runner",
checkpoint_fingerprint="plan",
ordinal=0,
phase="provider",
kind="opaque",
payload={"provider": "legacy"},
),
)
command.begin(
task_id="task-runner",
lease_token="lease",
operation_id=prepared.operation_id,
)
with pytest.raises(
transfer_chain_module._TransferManualReviewRequired,
match="禁止自动重放",
):
_runner(execution_repository).run(
phase="provider",
kind="opaque",
payload={"provider": "legacy"},
execute=lambda: pytest.fail("未知遗留步骤不得重放"),
observe=lambda: TransferOperationObservation(
state=TransferOperationObservationState.UNKNOWN,
evidence=TransferStepResult(payload={"receipt": None}),
),
)
snapshot = execution_repository.get_snapshot(task_id="task-runner")
assert snapshot is not None
assert snapshot.state is TransferExecutionState.MANUAL_REVIEW
class _ImmediateStepRunner:
"""记录 TransHandler 拆分顺序并立即执行步骤的测试 runner。"""
def __init__(self) -> None:
"""初始化步骤记录。"""
self.steps = []
def run(self, *, phase, kind, payload, execute, observe):
"""记录稳定意图并直接执行,不触发恢复探测。"""
self.steps.append((phase, kind, payload))
return execute()
def test_cross_storage_move_materializes_before_independent_source_delete(tmp_path):
"""跨存储 move 必须先复制目标,再用独立步骤删除源。"""
source_path = tmp_path / "source.mkv"
source_path.write_bytes(b"movie")
source_item = FileItem(
storage="local",
path=source_path.as_posix(),
name=source_path.name,
type="file",
size=source_path.stat().st_size,
extension="mkv",
)
target_item = FileItem(
storage="remote",
path="/library/source.mkv",
name="source.mkv",
type="file",
size=source_item.size,
extension="mkv",
)
source_oper = Mock()
source_oper.delete.return_value = True
target_oper = Mock()
target_oper.get_folder.return_value = FileItem(
storage="remote", path="/library", name="library", type="dir"
)
target_oper.upload.return_value = target_item
runner = _ImmediateStepRunner()
result, error = TransHandler._TransHandler__execute_transfer_with_steps(
step_runner=runner,
fileitem=source_item,
target_storage="remote",
source_oper=source_oper,
target_oper=target_oper,
target_file=Path("/library/source.mkv"),
transfer_type="move",
)
assert error == ""
assert result == target_item
assert [kind for _phase, kind, _payload in runner.steps] == [
"materialize_target",
"delete_move_source",
]
assert runner.steps[0][2]["transfer_type"] == "copy"
target_oper.upload.assert_called_once()
source_oper.delete.assert_called_once_with(source_item)
+23 -1
View File
@@ -16,6 +16,24 @@ LEASE_COLUMNS = {
"heartbeat_at",
"attempt_count",
}
TRANSFER_EXECUTION_COLUMNS = {
"execution_state",
"execution_version",
"execution_payload",
"execution_fingerprint",
"retry_generation",
"retry_count",
"retry_due_at",
"retry_requested_by",
"retry_reason",
"settlement_revision",
"terminal_history_id",
"manual_review_revision",
"reviewed_at",
"reviewed_by",
"review_reason",
"review_decision",
}
def _bind_migration(monkeypatch, connection):
@@ -105,7 +123,11 @@ def test_transfer_lease_upgrade_downgrade_reupgrade(monkeypatch) -> None:
assert {
column["name"]
for column in inspector.get_columns("transferpending")
} == {column.name for column in TransferPending.__table__.columns}
} == {
column.name
for column in TransferPending.__table__.columns
if column.name not in TRANSFER_EXECUTION_COLUMNS
}
assert "ix_transferpending_recovery_lease" in {
index["name"]
for index in inspector.get_indexes("transferpending")
+2
View File
@@ -20,6 +20,7 @@ from app.application.transfer import (
TransferProviderReference,
)
from app.db.adapters.transfer import TransactionalTransferAdmissionRepository
from app.db.models.transferhistory import TransferHistory
from app.db.models.transferpending import TransferPending
from app.db.oper.transferpending import TransferPendingOper
@@ -90,6 +91,7 @@ def repository_factory(tmp_path):
f"sqlite:///{tmp_path / 'transfer-lease.db'}",
connect_args={"check_same_thread": False, "timeout": 10},
)
TransferHistory.__table__.create(engine)
TransferPending.__table__.create(engine)
factory = sessionmaker(bind=engine)
yield lambda: TransactionalTransferAdmissionRepository(factory)
@@ -0,0 +1,264 @@
"""验证旧插件同步整理 ABI 复用 canonical durable 终态写入口。"""
import threading
from types import SimpleNamespace
from unittest.mock import Mock, patch
from app.application.transfer_execution import (
TransferExecutionCheckpoint,
TransferSettlementResult,
)
from app.chain.transfer import TransferChain
from app.domain.context import MediaInfo
from app.domain.meta.metabase import MetaBase
from app.schemas.file import FileItem
from app.schemas.transfer import TransferInfo
from app.schemas.types import MediaType
def _fileitem(*, fileid: str = "source-v1", size: int = 1024) -> FileItem:
"""构造可区分同路径版本的兼容调用文件项。"""
return FileItem(
storage="local",
path="/downloads/Movie.2026.mkv",
type="file",
name="Movie.2026.mkv",
basename="Movie.2026",
extension="mkv",
size=size,
modify_time=1770000000 + size,
fileid=fileid,
)
def _result(task, *, success: bool, overwrite_skipped: bool = False) -> TransferInfo:
"""按当前任务构造足以写历史的兼容整理结果。"""
return TransferInfo(
success=success,
overwrite_skipped=overwrite_skipped,
message=None if success else "copy failed",
fileitem=task.fileitem,
target_item=FileItem(
storage="local",
path="/library/Movie (2026)/Movie.mkv",
type="file",
name="Movie.mkv",
basename="Movie",
extension="mkv",
),
transfer_type="copy",
file_list=[task.fileitem.path],
)
def _compat_chain(result_factory):
"""构造只执行旧同步命令和 task-aware 结算的 TransferChain 骨架。"""
chain = object.__new__(TransferChain)
chain._worker_owner_id = "compat-owner"
chain._owned_leases = {}
chain._queued_lease_tokens = set()
chain._worker_state_lock = threading.RLock()
chain.durable_event_writer = Mock()
executed = []
def execute(task, **_kwargs):
"""模拟外部步骤已完成并建立可独立结算的执行检查点。"""
executed.append(task.fileitem.fileid)
task_id = f"task-{task.fileitem.fileid}"
lease_token = f"lease-{task.fileitem.fileid}"
task.bind_admission_task_id(task_id)
task.bind_execution_lease(
owner_id=chain._worker_owner_id,
lease_token=lease_token,
)
chain._owned_leases[task_id] = (lease_token, float("inf"))
result = result_factory(task)
task.bind_execution_checkpoint(TransferExecutionCheckpoint.create(
payload={
"outcome": (
"succeeded"
if result.success
else "failed"
),
"transferinfo": result.model_dump(mode="json"),
},
operation_ids=(f"operation-{task.fileitem.fileid}",),
))
return result
chain._plan_checkpoint_and_execute = Mock(side_effect=execute)
return chain, executed
def _invoke(chain: TransferChain, fileitem: FileItem) -> TransferInfo:
"""以插件可见参数调用同步兼容入口。"""
return chain.execute_legacy_transfer_command(
fileitem=fileitem,
meta=MetaBase(fileitem.name),
mediainfo=MediaInfo(type=MediaType.MOVIE, title="Movie", year="2026"),
target_storage="local",
target_path="/library",
transfer_type="copy",
)
def _settlement_writer(*, status: bool, history_id: int = 41):
"""返回执行 stage_history 并产出 task-aware 结果的 writer side effect。"""
staged_payloads = []
def write(**kwargs):
"""暂存历史后返回与当前结算修订一致的投影。"""
staging = Mock()
def add_force(**payload):
"""保存历史 payload 并返回 writer 所需的最小记录。"""
staged_payloads.append(payload)
return SimpleNamespace(
id=history_id,
status=bool(payload["status"]),
src=payload["src"],
src_storage=payload["src_storage"],
src_fileitem=payload["src_fileitem"],
)
staging.add_force.side_effect = add_force
staging.get_success_by_src.return_value = SimpleNamespace(
id=history_id,
status=True,
src="/downloads/Movie.2026.mkv",
src_storage="local",
src_fileitem=_fileitem().model_dump(mode="json"),
)
history = kwargs["stage_history"](staging)
assert bool(history.status) is status
return TransferSettlementResult(
history_id=history.id,
settlement_revision=1,
pending_deleted=status,
)
return write, staged_payloads
def test_legacy_failed_result_uses_atomic_terminal_writer() -> None:
"""失败结果也必须原子写失败历史并保留 pending,不得直接注销。"""
chain, executed = _compat_chain(lambda task: _result(task, success=False))
write, staged_payloads = _settlement_writer(status=False)
chain.durable_event_writer.transfer_result.side_effect = write
returned = _invoke(chain, _fileitem())
assert returned.success is False
assert returned.message == "copy failed"
assert executed == ["source-v1"]
assert staged_payloads[0]["status"] == 0
call = chain.durable_event_writer.transfer_result.call_args.kwargs
assert call["topic"] is None
assert call["publish"] is None
assert call["settlement"].outcome == "failed"
assert call["settlement"].error == "copy failed"
def test_legacy_settlement_response_loss_replays_receipt_by_same_task_id() -> None:
"""首次提交后响应丢失只能用同一 task_id 回读,不得重做外部步骤。"""
chain, executed = _compat_chain(lambda task: _result(task, success=True))
write, staged_payloads = _settlement_writer(status=True)
writer_calls = 0
def response_lost_then_receipt(**kwargs):
"""首次提交历史后模拟响应丢失,第二次只返回 immutable receipt。"""
nonlocal writer_calls
writer_calls += 1
if writer_calls == 1:
write(**kwargs)
raise RuntimeError("response lost after commit")
return TransferSettlementResult(
history_id=41,
settlement_revision=1,
pending_deleted=True,
already_settled=True,
)
chain.durable_event_writer.transfer_result.side_effect = response_lost_then_receipt
returned = _invoke(chain, _fileitem())
assert returned.success is True
assert executed == ["source-v1"]
assert writer_calls == 2
assert len(staged_payloads) == 1
first = chain.durable_event_writer.transfer_result.call_args_list[0].kwargs
second = chain.durable_event_writer.transfer_result.call_args_list[1].kwargs
assert second["settlement"] == first["settlement"]
def test_legacy_overwrite_skip_binds_existing_success_in_atomic_writer() -> None:
"""覆盖跳过复用既有成功历史并以 succeeded 终态结算。"""
chain, executed = _compat_chain(
lambda task: _result(task, success=False, overwrite_skipped=True)
)
write, staged_payloads = _settlement_writer(status=True)
chain.durable_event_writer.transfer_result.side_effect = write
success_history = SimpleNamespace(id=41, status=True)
history_port = SimpleNamespace(
get_by_src=lambda _src, storage=None: success_history,
)
with patch(
"app.chain.transfer.get_chain_transfer_history_port",
return_value=history_port,
):
returned = _invoke(chain, _fileitem())
assert returned.success is False
assert returned.overwrite_skipped is True
assert executed == ["source-v1"]
assert staged_payloads == []
settlement = chain.durable_event_writer.transfer_result.call_args.kwargs[
"settlement"
]
assert settlement.outcome == "succeeded"
assert settlement.error is None
def test_legacy_overwrite_skip_without_success_history_settles_failed() -> None:
"""兼容入口没有既有成功历史时按失败结算并保留 pending。"""
chain, executed = _compat_chain(
lambda task: _result(task, success=False, overwrite_skipped=True)
)
write, staged_payloads = _settlement_writer(status=False)
chain.durable_event_writer.transfer_result.side_effect = write
history_port = SimpleNamespace(
get_by_src=lambda _src, storage=None: None,
)
with patch(
"app.chain.transfer.get_chain_transfer_history_port",
return_value=history_port,
):
returned = _invoke(chain, _fileitem())
assert returned.success is False
assert returned.overwrite_skipped is True
assert executed == ["source-v1"]
assert len(staged_payloads) == 1
assert staged_payloads[0]["status"] == 0
settlement = chain.durable_event_writer.transfer_result.call_args.kwargs[
"settlement"
]
assert settlement.outcome == "failed"
assert settlement.error == "copy failed"
def test_legacy_same_path_changed_version_still_executes_new_task() -> None:
"""兼容入口不能仅凭同源历史吞掉 fileid/size/mtime 已变化的新版本。"""
chain, executed = _compat_chain(lambda task: _result(task, success=True))
write, _staged_payloads = _settlement_writer(status=True)
chain.durable_event_writer.transfer_result.side_effect = write
first = _invoke(chain, _fileitem(fileid="source-v1", size=1024))
second = _invoke(chain, _fileitem(fileid="source-v2", size=2048))
assert first.success is True
assert second.success is True
assert executed == ["source-v1", "source-v2"]
+201
View File
@@ -0,0 +1,201 @@
"""验证 durable 整理人工复核 API 的鉴权和公开契约。"""
from __future__ import annotations
import inspect
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from app.api.dependencies.auth import get_current_active_manage_user
from app.api.endpoints import transfer as transfer_endpoint
from app.application.transfer_execution import (
TransferExecutionConflictError,
TransferExecutionState,
TransferManualReviewDecision,
TransferManualReviewResult,
TransferStepResult,
)
from app.schemas.transfer import TransferManualReviewRequest
class _ManualReviewCommand:
"""记录人工复核调用并返回可控结果。"""
calls: list[tuple[object, dict]] = []
result: TransferManualReviewResult | None = None
error: Exception | None = None
def __init__(self, repository: object) -> None:
"""保存端点取得的仓储替身。"""
self._repository = repository
def resolve_manual_review(self, **kwargs) -> TransferManualReviewResult:
"""记录参数,并按测试配置返回或抛出结果。"""
self.calls.append((self._repository, kwargs))
if self.error:
raise self.error
assert self.result is not None
return self.result
def _install_command(monkeypatch) -> object:
"""安装不接触数据库的人工复核命令替身。"""
repository = object()
_ManualReviewCommand.calls = []
_ManualReviewCommand.error = None
monkeypatch.setattr(
transfer_endpoint,
"get_chain_transfer_execution_port",
lambda: repository,
)
monkeypatch.setattr(
transfer_endpoint,
"TransferExecutionCommand",
_ManualReviewCommand,
)
return repository
@pytest.mark.parametrize(
"endpoint",
[
transfer_endpoint.list_transfer_manual_reviews,
transfer_endpoint.get_transfer_manual_review,
transfer_endpoint.resolve_transfer_manual_review,
],
)
def test_manual_review_endpoints_require_manage_permission(endpoint) -> None:
"""人工复核发现、详情与判定必须复用全局 manage 权限依赖。"""
dependency = inspect.signature(
endpoint
).parameters["current_user"].default.dependency
assert dependency is get_current_active_manage_user
@pytest.mark.parametrize(
("payload", "message"),
[
(
{"operation_id": "op-1", "decision": "failed", "reason": "确认失败"},
"Input should be 'not_applied' or 'applied'",
),
(
{"operation_id": "op-1", "decision": "applied", "reason": "已完成"},
"必须提供 result_payload",
),
(
{"operation_id": "op-1", "decision": "not_applied", "reason": " "},
"String should have at least 1 character",
),
],
)
def test_manual_review_request_rejects_unsafe_decisions(
payload: dict,
message: str,
) -> None:
"""公开 schema 不允许 FAILED,且 APPLIED 必须携带结果证据。"""
with pytest.raises(ValidationError, match=message):
TransferManualReviewRequest.model_validate(payload)
def test_manual_review_applied_wraps_result_and_hides_internal_state(
monkeypatch,
) -> None:
"""APPLIED 应包装版本化结果,响应不得泄漏 lease 或 attempt。"""
repository = _install_command(monkeypatch)
_ManualReviewCommand.result = SimpleNamespace(
task_id="task-1",
operation_id="op-1",
decision=TransferManualReviewDecision.APPLIED,
state=TransferExecutionState.RETRY_WAIT,
review_revision=4,
)
response = transfer_endpoint.resolve_transfer_manual_review(
task_id="task-1",
review=TransferManualReviewRequest(
operation_id="op-1",
decision="applied",
reason="目标摘要匹配",
result_payload={"dest_exists": True, "hash_match": True},
),
current_user=SimpleNamespace(name=" admin ", username="other", id=7),
)
assert _ManualReviewCommand.calls == [
(
repository,
{
"task_id": "task-1",
"operation_id": "op-1",
"decision": TransferManualReviewDecision.APPLIED,
"actor": "admin",
"reason": "目标摘要匹配",
"result": TransferStepResult(
payload={"dest_exists": True, "hash_match": True}
),
},
)
]
assert response.data is not None
assert response.data.model_dump() == {
"task_id": "task-1",
"operation_id": "op-1",
"decision": "applied",
"state": "retry_wait",
"review_revision": 4,
}
assert "lease" not in response.model_dump_json()
assert "attempt" not in response.model_dump_json()
def test_manual_review_not_applied_uses_username_fallback(monkeypatch) -> None:
"""名称为空时应稳定回退到 username,并允许安全重新调度。"""
_install_command(monkeypatch)
_ManualReviewCommand.result = SimpleNamespace(
task_id="task-2",
operation_id="op-2",
decision=TransferManualReviewDecision.NOT_APPLIED,
state=TransferExecutionState.RETRY_WAIT,
review_revision=2,
)
response = transfer_endpoint.resolve_transfer_manual_review(
task_id="task-2",
review=TransferManualReviewRequest(
operation_id="op-2",
decision="not_applied",
reason="确认源文件仍存在",
),
current_user=SimpleNamespace(name="", username="reviewer", id=8),
)
assert _ManualReviewCommand.calls[0][1]["actor"] == "reviewer"
assert _ManualReviewCommand.calls[0][1]["result"] is None
assert response.data is not None
assert response.data.state == "retry_wait"
def test_manual_review_conflict_returns_http_409(monkeypatch) -> None:
"""重复或过期人工判定必须返回资源冲突而非伪成功。"""
_install_command(monkeypatch)
_ManualReviewCommand.error = TransferExecutionConflictError("步骤已被判定")
with pytest.raises(HTTPException) as error:
transfer_endpoint.resolve_transfer_manual_review(
task_id="task-1",
review=TransferManualReviewRequest(
operation_id="op-1",
decision="not_applied",
reason="重复判定",
),
current_user=SimpleNamespace(name=None, username=None, id=11),
)
assert error.value.status_code == 409
assert error.value.detail == "步骤已被判定"
assert _ManualReviewCommand.calls[0][1]["actor"] == "11"
@@ -0,0 +1,250 @@
"""验证 durable 人工复核从未知证据到调度恢复的可发现闭环。"""
from datetime import datetime, timezone
from types import SimpleNamespace
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.api.endpoints import transfer as transfer_endpoint
from app.application.transfer_execution import (
TransferExecutionCommand,
TransferExecutionState,
TransferManualReviewQuery,
TransferStepIntent,
TransferStepResult,
)
from app.db.adapters.transfer_execution import (
TransactionalTransferExecutionRepository,
)
from app.db.base import Base
from app.db.models.transferexecutionstep import TransferExecutionStep
from app.db.models.transferhistory import TransferHistory
from app.db.models.transferpending import TransferPending
from app.schemas.transfer import TransferManualReviewRequest
@pytest.fixture
def review_store():
"""构造隔离的 durable 整理人工复核数据库。"""
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(
engine,
tables=[
TransferPending.__table__,
TransferHistory.__table__,
TransferExecutionStep.__table__,
],
)
factory = sessionmaker(bind=engine, expire_on_commit=False)
try:
yield factory
finally:
engine.dispose()
def _repository(factory) -> TransactionalTransferExecutionRepository:
"""构造使用确定时钟的整理执行仓储。"""
return TransactionalTransferExecutionRepository(
factory,
local_clock=lambda: datetime(2026, 8, 27, 9, 30, 0),
lease_clock=lambda: datetime(2026, 8, 27, 1, 30, 0, tzinfo=timezone.utc),
)
def _put_in_manual_review(factory, *, task_id: str) -> tuple[
TransactionalTransferExecutionRepository,
str,
]:
"""建立一个外部结果 UNKNOWN 且已释放租约的人工复核任务。"""
with factory() as session:
session.add(TransferPending(
task_id=task_id,
storage="local",
src_path=f"/downloads/{task_id}.mkv",
created_at="2026-08-27 09:00:00",
state="planned",
updated_at="2026-08-27 09:00:00",
input_version=1,
planning_input={"schema_version": 1, "source": task_id},
input_fingerprint=f"input-{task_id}",
checkpoint_version=1,
checkpoint_payload={"schema_version": 1, "task_id": task_id},
planned_at="2026-08-27 09:00:00",
lease_owner="worker-secret",
lease_token=f"lease-{task_id}",
lease_expires_at="2099-01-01 00:00:00.000000",
heartbeat_at="2026-08-27 01:00:00.000000",
attempt_count=1,
execution_state="not_started",
retry_generation=0,
retry_count=0,
settlement_revision=0,
))
session.commit()
repository = _repository(factory)
command = TransferExecutionCommand(
repository,
attempt_token_factory=lambda: f"attempt-{task_id}",
)
intent = TransferStepIntent.create(
task_id=task_id,
checkpoint_fingerprint=f"checkpoint-{task_id}",
ordinal=0,
phase="transfer",
kind="materialize_target",
payload={
"source": f"/downloads/{task_id}.mkv",
"target": f"/library/{task_id}.mkv",
},
)
prepared = command.prepare(
task_id=task_id,
lease_token=f"lease-{task_id}",
intent=intent,
)
started = command.begin(
task_id=task_id,
lease_token=f"lease-{task_id}",
operation_id=prepared.operation_id,
)
snapshot = command.manual_review(
task_id=task_id,
lease_token=f"lease-{task_id}",
step=started,
error="external result unknown",
evidence=TransferStepResult(payload={
"observation": "unknown",
"target_exists": True,
}),
)
assert snapshot.state is TransferExecutionState.MANUAL_REVIEW
return repository, started.operation_id
def test_manual_review_list_is_database_paginated(review_store) -> None:
"""待复核任务应按稳定顺序在数据库分页,不混入普通任务。"""
repository, _ = _put_in_manual_review(review_store, task_id="task-1")
_put_in_manual_review(review_store, task_id="task-2")
first = repository.list_manual_reviews(
state=TransferExecutionState.MANUAL_REVIEW,
page=1,
page_size=1,
)
second = repository.list_manual_reviews(
state=TransferExecutionState.MANUAL_REVIEW,
page=2,
page_size=1,
)
assert first.total == second.total == 2
assert len(first.items) == len(second.items) == 1
assert first.items[0].task_id != second.items[0].task_id
with pytest.raises(ValueError, match="不支持状态"):
TransferManualReviewQuery(repository).list(
state=TransferExecutionState.RUNNING,
)
@pytest.mark.parametrize(
("decision", "result_payload", "expected_step_state"),
[
("applied", {"target_exists": True, "hash_match": True}, "succeeded"),
("not_applied", None, "failed"),
],
)
def test_unknown_manual_review_is_discoverable_and_resumes_via_api(
monkeypatch,
review_store,
decision: str,
result_payload: dict[str, bool] | None,
expected_step_state: str,
) -> None:
"""UNKNOWN 任务应可发现,人工判定后进入唯一 retry_wait 恢复路径。"""
repository, operation_id = _put_in_manual_review(
review_store,
task_id=f"task-{decision}",
)
monkeypatch.setattr(
transfer_endpoint,
"get_chain_transfer_execution_port",
lambda: repository,
)
listed = transfer_endpoint.list_transfer_manual_reviews(
state_filter="manual_review",
page=1,
page_size=10,
current_user=object(),
)
assert listed.data is not None
assert listed.data.total == 1
discovered = listed.data.items[0]
assert discovered.task_id == f"task-{decision}"
assert discovered.source.model_dump() == {
"storage": "local",
"path": f"/downloads/task-{decision}.mkv",
}
assert discovered.step.operation_id == operation_id
assert discovered.step.kind == "materialize_target"
assert discovered.step.intent["target"] == f"/library/task-{decision}.mkv"
assert discovered.step.evidence == {
"observation": "unknown",
"target_exists": True,
}
assert discovered.step.error == "external result unknown"
assert discovered.review_revision == 0
public_json = listed.model_dump_json()
assert "worker-secret" not in public_json
assert "lease-" not in public_json
assert "attempt-" not in public_json
detail = transfer_endpoint.get_transfer_manual_review(
task_id=f"task-{decision}",
current_user=object(),
)
assert detail.data == discovered
resolved = transfer_endpoint.resolve_transfer_manual_review(
task_id=f"task-{decision}",
review=TransferManualReviewRequest(
operation_id=operation_id,
decision=decision,
reason=f"reviewed-{decision}",
result_payload=result_payload,
),
current_user=SimpleNamespace(name="admin"),
)
assert resolved.data is not None
assert resolved.data.state == "retry_wait"
assert resolved.data.review_revision == 1
waiting = transfer_endpoint.get_transfer_manual_review(
task_id=f"task-{decision}",
current_user=object(),
)
assert waiting.data is not None
assert waiting.data.state == "retry_wait"
assert waiting.data.review_revision == 1
retry_page = transfer_endpoint.list_transfer_manual_reviews(
state_filter="retry_wait",
page=1,
page_size=10,
current_user=object(),
)
assert retry_page.data is not None
assert [item.task_id for item in retry_page.data.items] == [f"task-{decision}"]
snapshot = repository.get_snapshot(task_id=f"task-{decision}")
assert snapshot is not None
assert snapshot.state is TransferExecutionState.RETRY_WAIT
assert snapshot.retry_due_at is not None
assert snapshot.steps[0].state.value == expected_step_state
with review_store() as session:
pending = session.scalar(select(TransferPending))
assert pending is not None
assert pending.lease_owner is None
assert pending.lease_token is None
assert pending.retry_generation == 1
+117
View File
@@ -11,6 +11,10 @@ from unittest.mock import MagicMock, patch
import pytest
from app.application.transfer_execution import (
TransferExecutionCheckpoint,
TransferSettlementResult,
)
from app.chain.transfer import TransferChain
from app.schemas.transfer import TransferInfo
from app.schemas.types import EventType
@@ -121,6 +125,60 @@ def test_overwrite_declined_false_when_query_raises():
assert result is False
def test_overwrite_declined_uses_successful_durable_settlement():
"""覆盖拒绝保留旧成功历史时,durable 终态必须按成功结算。"""
task = make_task(1)
task.bind_admission_task_id("task-overwrite-declined")
task.bind_execution_lease(owner_id="worker", lease_token="lease")
task.bind_execution_checkpoint(TransferExecutionCheckpoint.create(
payload={"outcome": "overwrite_skipped"},
operation_ids=(),
skip_reason="overwrite_declined",
))
transferinfo = TransferInfo(
success=False,
overwrite_skipped=True,
message="目标已存在,按覆盖策略跳过覆盖",
)
settlement = TransferChain._TransferChain__build_transfer_result_settlement(
task,
transferinfo,
overwrite_declined=True,
)
assert settlement is not None
assert settlement.outcome == "succeeded"
assert settlement.error is None
def test_overwrite_skip_without_success_history_uses_failed_settlement():
"""未核实既有成功历史时,覆盖跳过标志不能伪造成功终态。"""
task = make_task(1)
task.bind_admission_task_id("task-overwrite-missing-history")
task.bind_execution_lease(owner_id="worker", lease_token="lease")
task.bind_execution_checkpoint(TransferExecutionCheckpoint.create(
payload={"outcome": "failed"},
operation_ids=(),
skip_reason="overwrite_without_history",
))
transferinfo = TransferInfo(
success=False,
overwrite_skipped=True,
message="目标已存在,按覆盖策略跳过覆盖",
)
settlement = TransferChain._TransferChain__build_transfer_result_settlement(
task,
transferinfo,
overwrite_declined=False,
)
assert settlement is not None
assert settlement.outcome == "failed"
assert settlement.error == transferinfo.message
# ---------------------------------------------------------------------------
# __default_callback 失败分支
# ---------------------------------------------------------------------------
@@ -230,6 +288,65 @@ def test_default_callback_keeps_original_failure_semantics_without_success_histo
assert len(transfer_failed_events) == 1
def test_durable_callback_settles_overwrite_skip_without_history_as_failed():
"""durable 回调没有既有成功历史时必须原子提交失败历史和失败终态。"""
chain = make_transfer_chain()
chain.eventmanager = MagicMock()
chain.post_message = MagicMock()
chain.durable_event_writer = MagicMock()
task = _make_failed_task()
task.bind_admission_task_id("task-overwrite-no-history")
task.bind_execution_lease(owner_id="worker", lease_token="lease")
task.bind_execution_checkpoint(TransferExecutionCheckpoint.create(
payload={"outcome": "failed"},
operation_ids=(),
skip_reason="overwrite_without_history",
))
add_fail_calls = []
transfer_history_oper = make_history_oper(history=None)
transferinfo = TransferInfo(
success=False,
fileitem=task.fileitem,
message="目标已存在,按覆盖策略跳过覆盖",
transfer_type="copy",
overwrite_skipped=True,
need_notify=False,
)
def durable_transfer_result(**kwargs):
"""执行失败历史暂存并返回 task-aware 结算回执。"""
history = kwargs["stage_history"](SimpleNamespace())
assert kwargs["settlement"].outcome == "failed"
return TransferSettlementResult(
history_id=history.id,
settlement_revision=1,
pending_deleted=False,
)
chain.durable_event_writer.transfer_result.side_effect = durable_transfer_result
with patch(
"app.chain.transfer.get_chain_transfer_history_port",
return_value=transfer_history_oper,
), patch(
"app.chain.transfer.add_transfer_fail",
make_fail_recorder(add_fail_calls),
), patch(
"app.runtime.config.settings.AI_AGENT_ENABLE", False
), patch(
"app.runtime.config.settings.AI_AGENT_RETRY_TRANSFER", False
):
state, errmsg = chain._TransferChain__default_callback(task, transferinfo)
assert state is False
assert errmsg == transferinfo.message
assert len(add_fail_calls) == 1
settlement = chain.durable_event_writer.transfer_result.call_args.kwargs[
"settlement"
]
assert settlement.outcome == "failed"
assert settlement.error == transferinfo.message
def test_default_callback_delegates_primary_failure_to_durable_writer():
"""正式上下文存在 writer 时,主要媒体失败历史和事件必须走同一事务端口。"""
chain = make_transfer_chain()
+284 -2
View File
@@ -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"
+49 -11
View File
@@ -14,8 +14,13 @@ from sqlalchemy.orm import sessionmaker
from app.application import transfer as transfer_application
from app.application.transfer import TransferTask
from app.application.transfer_execution import (
TransferExecutionCheckpoint,
TransferSettlementResult,
)
from app.chain.transfer import TransferChain
from app.db.adapters.transfer import TransactionalTransferAdmissionRepository
from app.db.models.transferhistory import TransferHistory
from app.db.models.transferpending import TransferPending
from app.domain.context import MediaInfo
from app.domain.meta.metabase import MetaBase
@@ -831,7 +836,7 @@ def test_provider_pending_crash_replay_executes_snapshot_without_host_planning()
def test_legacy_transfer_command_uses_durable_pipeline_and_settles_pending():
"""旧同步调用必须经过 admission、checkpoint、执行和终态注销"""
"""旧同步调用必须经过 admission、checkpoint、执行和原子终态结算"""
calls = []
repository = Mock()
repository.admit.side_effect = (
@@ -845,18 +850,47 @@ def test_legacy_transfer_command_uses_durable_pipeline_and_settles_pending():
return SimpleNamespace(checkpoint=kwargs["checkpoint"])
repository.checkpoint_plan.side_effect = checkpoint_plan
repository.discard_claimed.side_effect = (
lambda **_kwargs: calls.append("discard") or 1
)
result = TransferInfo(
success=True,
fileitem=_task().fileitem,
transfer_type="copy",
)
chain = _chain(repository=repository, checkpoint=_checkpoint(), result=result)
chain.execute_transfer_plan.side_effect = (
lambda *_args, **_kwargs: calls.append("execute") or result
execution_checkpoint = TransferExecutionCheckpoint.create(
payload={
"outcome": "succeeded",
"transferinfo": result.model_dump(mode="json"),
},
operation_ids=("operation-legacy-command",),
)
step_runner = Mock()
step_runner.checkpoint.return_value = execution_checkpoint
chain._TransferChain__build_durable_step_runner = Mock(return_value=step_runner)
chain.run_module = Mock(
side_effect=lambda *_args, **_kwargs: calls.append("execute") or result
)
chain.durable_event_writer = Mock()
def settle_result(**kwargs):
"""执行历史暂存并模拟 writer 返回 task-aware 结算投影。"""
staging = Mock()
staging.add_force.return_value = SimpleNamespace(
id=31,
status=True,
src=result.fileitem.path,
src_storage=result.fileitem.storage,
src_fileitem=result.fileitem.model_dump(mode="json"),
)
history = kwargs["stage_history"](staging)
assert history.status is True
calls.append("settle")
return TransferSettlementResult(
history_id=history.id,
settlement_revision=1,
pending_deleted=True,
)
chain.durable_event_writer.transfer_result.side_effect = settle_result
meta = MetaBase("Movie.2026.mkv")
mediainfo = MediaInfo()
@@ -870,11 +904,13 @@ def test_legacy_transfer_command_uses_durable_pipeline_and_settles_pending():
)
assert returned is result
assert calls == ["admit", "checkpoint", "execute", "discard"]
repository.discard_claimed.assert_called_once_with(
task_id="task-legacy-command",
lease_token="lease-task-legacy-command",
)
assert calls == ["admit", "checkpoint", "execute", "settle"]
repository.discard_claimed.assert_not_called()
writer_call = chain.durable_event_writer.transfer_result.call_args.kwargs
assert writer_call["topic"] is None
assert writer_call["publish"] is None
assert writer_call["settlement"].task_id == "task-legacy-command"
assert writer_call["settlement"].outcome == "succeeded"
def test_cleanup_destination_is_idempotent_and_uses_storage_safety_policy():
@@ -932,6 +968,7 @@ def test_planning_payload_round_trip_is_self_contained_and_stable():
def test_repository_rejects_checkpoint_with_mismatched_planning_fingerprint(tmp_path):
"""checkpoint 内嵌输入与 accepted 指纹不一致时必须拒绝状态跃迁。"""
engine = create_engine(f"sqlite:///{tmp_path / 'fingerprint.db'}")
TransferHistory.__table__.create(engine)
TransferPending.__table__.create(engine)
repository = TransactionalTransferAdmissionRepository(sessionmaker(bind=engine))
accepted_input = _planning_input(target_path="/library/A")
@@ -975,6 +1012,7 @@ def test_repository_rejects_checkpoint_with_mismatched_planning_fingerprint(tmp_
def test_repository_round_trips_accepted_and_planned_recovery_states(tmp_path):
"""仓储必须同时恢复 accepted 输入和 planned 自包含 checkpoint。"""
engine = create_engine(f"sqlite:///{tmp_path / 'recoverable.db'}")
TransferHistory.__table__.create(engine)
TransferPending.__table__.create(engine)
repository = TransactionalTransferAdmissionRepository(sessionmaker(bind=engine))
planning_input = _planning_input()
+28 -2
View File
@@ -26,6 +26,24 @@ except ModuleNotFoundError:
PLANNING_MIGRATION = "database.versions.c2f8a4d6e1b3_3_0_14"
LEASE_MIGRATION = "database.versions.d3a9e5f7b2c4_3_0_15"
POST_LEASE_EXECUTION_COLUMNS = {
"execution_state",
"execution_version",
"execution_payload",
"execution_fingerprint",
"retry_generation",
"retry_count",
"retry_due_at",
"retry_requested_by",
"retry_reason",
"settlement_revision",
"terminal_history_id",
"manual_review_revision",
"reviewed_at",
"reviewed_by",
"review_reason",
"review_decision",
}
def _bind_migration(monkeypatch, connection, module_name=PLANNING_MIGRATION):
@@ -104,7 +122,11 @@ def _assert_upgrade_downgrade_reupgrade(connection, monkeypatch) -> None:
assert {
column["name"]
for column in inspector.get_columns("transferpending")
} == {column.name for column in TransferPending.__table__.columns}
} == {
column.name
for column in TransferPending.__table__.columns
if column.name not in POST_LEASE_EXECUTION_COLUMNS
}
upgraded = _planning_row(connection)
planning_payload = upgraded["planning_input"]
if isinstance(planning_payload, str):
@@ -168,7 +190,11 @@ def _assert_upgrade_downgrade_reupgrade(connection, monkeypatch) -> None:
assert {
column["name"]
for column in sa.inspect(connection).get_columns("transferpending")
} == {column.name for column in TransferPending.__table__.columns}
} == {
column.name
for column in TransferPending.__table__.columns
if column.name not in POST_LEASE_EXECUTION_COLUMNS
}
def test_transfer_planning_upgrade_downgrade_reupgrade(monkeypatch) -> None:
@@ -20,6 +20,7 @@ from app.application.transfer import (
TransferProviderReference,
)
from app.db.adapters.transfer import TransactionalTransferAdmissionRepository
from app.db.models.transferhistory import TransferHistory
from app.db.models.transferpending import TransferPending
@@ -27,6 +28,7 @@ from app.db.models.transferpending import TransferPending
def repository(tmp_path):
"""创建只服务单个测试的 SQLite 整理计划仓储。"""
engine = create_engine(f"sqlite:///{tmp_path / 'transfer-planning.db'}")
TransferHistory.__table__.create(engine)
TransferPending.__table__.create(engine)
return TransactionalTransferAdmissionRepository(sessionmaker(bind=engine))
@@ -347,6 +349,30 @@ def test_admit_reuses_identical_input_and_rejects_conflict(repository) -> None:
)
def test_admit_allows_new_generation_when_history_has_previous_task(repository) -> None:
"""历史保留上一代任务投影时,同源新事实仍可形成新任务世代。"""
with repository._session_factory() as session: # noqa: SLF001
session.add(TransferHistory(
transfer_task_id="settled-task",
transfer_settlement_revision=1,
src="/downloads/Movie.2026.mkv",
src_storage="local",
status=True,
))
session.commit()
admission = repository.admit(
storage="local",
src_path="/downloads/Movie.2026.mkv",
planning_input=_planning_input(),
)
with repository._session_factory() as session: # noqa: SLF001
pending = session.execute(select(TransferPending)).scalar_one()
assert admission.task_id == pending.task_id
assert admission.task_id != "settled-task"
def test_checkpoint_atomically_advances_and_is_idempotent(repository) -> None:
"""完整计划和 planned 状态应同事务提交且允许相同检查点重试。"""
planning_input = _planning_input()
@@ -556,6 +582,7 @@ def test_projection_rejects_input_version_and_fingerprint_corruption(tmp_path) -
"""列版本、JSON 和指纹任一不一致时都不得返回伪冻结 DTO。"""
engine = create_engine(f"sqlite:///{tmp_path / 'input-corruption.db'}")
factory = sessionmaker(bind=engine)
TransferHistory.__table__.create(engine)
TransferPending.__table__.create(engine)
repository = TransactionalTransferAdmissionRepository(factory)
planning_input = _planning_input()
@@ -601,6 +628,7 @@ def test_projection_rejects_checkpoint_version_corruption(tmp_path) -> None:
"""planned 行的列版本与自包含 checkpoint JSON 必须严格一致。"""
engine = create_engine(f"sqlite:///{tmp_path / 'checkpoint-corruption.db'}")
factory = sessionmaker(bind=engine)
TransferHistory.__table__.create(engine)
TransferPending.__table__.create(engine)
repository = TransactionalTransferAdmissionRepository(factory)
planning_input = _planning_input()
+2
View File
@@ -7,6 +7,7 @@ from sqlalchemy.orm import sessionmaker
from app.application.transfer import TransferAdmission, TransferQueueService
from app.db.adapters.transfer import TransactionalTransferAdmissionRepository
from app.db.models.transferhistory import TransferHistory
from app.db.models.transferpending import TransferPending
from app.schemas.file import FileItem
from tests.test_transfer_job_manager import make_task, make_transfer_chain
@@ -117,6 +118,7 @@ def test_transfer_queue_service_cleans_up_when_batch_registration_fails():
def test_transfer_queue_service_commits_admission_before_failed_enqueue(tmp_path):
"""真实仓储已提交后即使内存入队失败,任务也必须带原因留待恢复。"""
engine = create_engine(f"sqlite:///{tmp_path / 'durable-admission.db'}")
TransferHistory.__table__.create(engine)
TransferPending.__table__.create(engine)
factory = sessionmaker(bind=engine)
repository = TransactionalTransferAdmissionRepository(factory)
+456
View File
@@ -0,0 +1,456 @@
"""验证 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.transfer import (
TransferAdmission,
TransferPlanCheckpoint,
TransferPlanningInput,
TransferTask,
)
from app.application.transfer_execution import (
TransferExecutionCheckpoint,
TransferExecutionSnapshot,
TransferExecutionState,
)
from app.chain.transfer import TransferChain
from app.db.adapters.transfer import TransactionalTransferAdmissionRepository
from app.db.models.transferhistory import TransferHistory
from app.db.models.transferpending import TransferPending
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)
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._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,
)
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.discard_claimed.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()