mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
refactor: complete durable transfer execution settlement
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
},
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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``)。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 风格对象。"""
|
||||
|
||||
|
||||
@@ -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 == "人工复核任务必须先完成专门判定"
|
||||
@@ -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}
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user