Merge upstream/v3 into codex/feat/plugin-data-query-sdk-v3

This commit is contained in:
InfinityPacer
2026-08-28 00:43:57 +08:00
49 changed files with 5423 additions and 1143 deletions
+3 -3
View File
@@ -1,8 +1,8 @@
{
"application": {
"covered_lines": 10098,
"percent": 78.74,
"statements": 12825
"covered_lines": 10068,
"percent": 78.81,
"statements": 12775
},
"domain": {
"covered_lines": 3392,
+2 -2
View File
@@ -1442,7 +1442,7 @@
}
},
"edge_count": 6940,
"edge_sha256": "8ff91be099f1230655ceeb006bd1bf604063054ddb0721b3e887c956ea1251fb",
"edge_sha256": "9e3e8485c94c46a75eb577ccfc24708c80d4296b6422ec66ab9f23ef9964568f",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -4454,7 +4454,6 @@
"app.application.transfer.workflow -> app.adapters.system",
"app.application.transfer.workflow -> app.adapters.system.host",
"app.application.transfer.workflow -> app.application",
"app.application.transfer.workflow -> app.application.agent",
"app.application.transfer.workflow -> app.application.transfer",
"app.application.transfer.workflow -> app.application.transfer.execution",
"app.application.transfer.workflow -> app.domain",
@@ -5201,6 +5200,7 @@
"app.db.adapters.transfer.execution -> app.application",
"app.db.adapters.transfer.execution -> app.application.transfer",
"app.db.adapters.transfer.execution -> app.application.transfer.execution",
"app.db.adapters.transfer.execution -> app.application.transfer.workflow",
"app.db.adapters.transfer.execution -> app.db",
"app.db.adapters.transfer.execution -> app.db.models",
"app.db.adapters.transfer.execution -> app.db.models.transferexecutionstep",
+3 -3
View File
@@ -1568,19 +1568,19 @@
"union-attr": 2
},
"app/chain/transfer.py": {
"arg-type": 57,
"arg-type": 53,
"assignment": 25,
"attr-defined": 4,
"func-returns-value": 1,
"misc": 2,
"no-any-return": 3,
"no-any-return": 2,
"no-untyped-call": 8,
"no-untyped-def": 12,
"operator": 3,
"return-value": 2,
"truthy-function": 5,
"type-arg": 10,
"union-attr": 33,
"union-attr": 31,
"var-annotated": 4
},
"app/chain/user.py": {
+95
View File
@@ -3,6 +3,7 @@ import json
from functools import lru_cache
from pathlib import Path
from app.runtime.compat.manifest import SYMBOL_ALIASES
from scripts.architecture.baseline import (
collect_current_event_facts as _collect_current_event_facts,
)
@@ -200,6 +201,58 @@ def _legacy_imports(path: Path) -> set[str]:
return imports
def _attribute_parts(node: ast.Attribute) -> list[str]:
"""将静态属性访问还原为从根名称开始的完整路径片段。"""
parts = [node.attr]
value = node.value
while isinstance(value, ast.Attribute):
parts.append(value.attr)
value = value.value
if not isinstance(value, ast.Name):
return []
parts.append(value.id)
return list(reversed(parts))
def _compat_symbol_references(tree: ast.AST) -> set[tuple[int, str]]:
"""收集显式导入或静态属性访问命中的兼容符号。"""
compatibility_symbols = {
(module_name, symbol_name)
for module_name, symbols in SYMBOL_ALIASES.items()
for symbol_name in symbols
}
module_bindings: dict[str, str] = {}
references: set[tuple[int, str]] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
binding = alias.asname or alias.name.split(".", maxsplit=1)[0]
module_bindings[binding] = alias.name if alias.asname else binding
elif isinstance(node, ast.ImportFrom) and node.module:
for alias in node.names:
if (node.module, alias.name) in compatibility_symbols:
references.add((node.lineno, f"{node.module}.{alias.name}"))
continue
binding = alias.asname or alias.name
module_bindings[binding] = f"{node.module}.{alias.name}"
for node in ast.walk(tree):
if not isinstance(node, ast.Attribute):
continue
parts = _attribute_parts(node)
if len(parts) < 2:
continue
resolved_root = module_bindings.get(parts[0], parts[0])
resolved = [*resolved_root.split("."), *parts[1:]]
module_name = ".".join(resolved[:-1])
symbol_name = resolved[-1]
if (module_name, symbol_name) in compatibility_symbols:
references.add((node.lineno, f"{module_name}.{symbol_name}"))
return references
def test_legacy_roots_contain_no_python_sources():
"""旧目录只能作为运行时虚拟包存在,仓库中不得重新出现源码。"""
leftovers = sorted(
@@ -270,6 +323,48 @@ def test_host_code_does_not_import_legacy_roots():
assert violations == {}
def test_compat_symbol_scanner_covers_static_import_shapes() -> None:
"""兼容符号扫描必须覆盖显式导入、模块别名和完整属性链。"""
tree = ast.parse(
"""
from app.schemas import TransferTask
import app.schemas as schema_alias
schema_alias.TransferQueue
import app.schemas
app.schemas.TransferTask
from app.application import transfer as transfer_package
transfer_package.TransferQueue
"""
)
assert _compat_symbol_references(tree) == {
(2, "app.schemas.TransferTask"),
(4, "app.schemas.TransferQueue"),
(6, "app.schemas.TransferTask"),
(8, "app.application.transfer.TransferQueue"),
}
def test_host_code_does_not_use_compat_symbol_aliases() -> None:
"""宿主必须导入 canonical 符号,不得反向消费插件兼容覆盖。"""
violations: list[str] = []
for path in APP_ROOT.rglob("*.py"):
relative = path.relative_to(APP_ROOT)
if (
relative.parts[0] == "plugins"
or relative.parts[:2] == ("runtime", "compat")
or relative.parts[:2] == ("sdk", "_legacy")
):
continue
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
violations.extend(
f"{relative.as_posix()}:{line}:{symbol}"
for line, symbol in sorted(_compat_symbol_references(tree))
)
assert violations == []
def test_host_code_uses_explicit_runtime_facade_getters():
"""宿主消费者必须显式调用 getter,不得把兼容 Facade 当作新代码入口。"""
forbidden_imports = {
+244 -19
View File
@@ -22,8 +22,11 @@ from app.application.chain.events import (
)
from app.application.history import TransferHistoryMutationCommand
from app.application.transfer.execution import (
TransferExecutionCheckpoint,
TransferExecutionConflictError,
TransferExecutionLeaseLostError,
TransferSettlementResult,
build_transfer_checkpoint_fingerprint,
)
from app.db.adapters.chain import TransactionalChainDurableEventWriter
from app.db.base import Base
@@ -81,16 +84,44 @@ def _objects():
return meta, media, context, fileitem, transferinfo
def _execution_checkpoint(
*,
outcome: str,
identity: str = "execution-1",
) -> TransferExecutionCheckpoint:
"""构造 outcome 与完整指纹一致的测试执行检查点。"""
overwrite_skipped = outcome == "overwrite_skipped"
transferinfo = (
TransferInfo(success=False, overwrite_skipped=True).model_dump(mode="json")
if overwrite_skipped
else None
)
return TransferExecutionCheckpoint.create(
payload={
"outcome": outcome,
"test_identity": identity,
**({"transferinfo": transferinfo} if transferinfo else {}),
},
operation_ids=() if overwrite_skipped else ("operation-1",),
skip_reason="overwrite_skipped" if overwrite_skipped else None,
)
def _add_settling_pending(
factory,
*,
task_id: str = "task-1",
lease_token: str = "lease-1",
execution_fingerprint: str = "execution-1",
execution_outcome: str = "succeeded",
execution_identity: str = "execution-1",
settlement_revision: int = 0,
src_path: str | None = None,
) -> None:
) -> TransferExecutionCheckpoint:
"""写入具备有效长租约和执行检查点的待结算任务。"""
checkpoint = _execution_checkpoint(
outcome=execution_outcome,
identity=execution_identity,
)
with factory() as session:
session.add(TransferPending(
task_id=task_id,
@@ -111,14 +142,15 @@ def _add_settling_pending(
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,
execution_version=checkpoint.version,
execution_payload=checkpoint.to_payload(),
execution_fingerprint=checkpoint.fingerprint,
retry_generation=0,
retry_count=0,
settlement_revision=settlement_revision,
))
session.commit()
return checkpoint
def _settlement(
@@ -126,13 +158,18 @@ def _settlement(
outcome: str,
task_id: str = "task-1",
lease_token: str = "lease-1",
execution_fingerprint: str = "execution-1",
checkpoint_outcome: str | None = None,
execution_identity: str = "execution-1",
) -> TransferResultSettlement:
"""构造测试使用的稳定终态结算身份。"""
checkpoint = _execution_checkpoint(
outcome=checkpoint_outcome or outcome,
identity=execution_identity,
)
return TransferResultSettlement(
task_id=task_id,
lease_token=lease_token,
execution_fingerprint=execution_fingerprint,
execution_fingerprint=checkpoint.fingerprint,
outcome=outcome,
error="目标文件校验失败" if outcome == "failed" else None,
)
@@ -519,7 +556,9 @@ def test_task_success_settlement_atomically_deletes_pending_and_steps():
assert receipt.task_id == "task-1"
assert receipt.history_id == history.id
assert receipt.outcome == "succeeded"
assert receipt.execution_fingerprint == "execution-1"
assert receipt.execution_fingerprint == _execution_checkpoint(
outcome="succeeded"
).fingerprint
assert receipt.lease_token == "lease-1"
assert receipt.history_status is True
assert receipt.src == "/downloads/task-1.mkv"
@@ -599,7 +638,7 @@ def test_multiple_same_source_tasks_keep_independent_replay_receipts():
factory,
task_id="new-task",
lease_token="lease-2",
execution_fingerprint="execution-2",
execution_identity="execution-2",
src_path=shared_src,
)
@@ -617,7 +656,7 @@ def test_multiple_same_source_tasks_keep_independent_replay_receipts():
outcome="succeeded",
task_id="new-task",
lease_token="lease-2",
execution_fingerprint="execution-2",
execution_identity="execution-2",
),
)
old_replay = writer.transfer_result(
@@ -684,7 +723,11 @@ def test_task_settlement_without_public_topic_commits_no_outbox():
def test_task_settlement_binds_receipt_without_overwriting_success_history():
"""不覆盖裁决只绑定任务回执,保留旧成功历史的全部业务字段。"""
factory = _session_factory()
_add_settling_pending(factory, task_id="declined-task")
_add_settling_pending(
factory,
task_id="declined-task",
execution_outcome="overwrite_skipped",
)
with factory() as session:
session.add(TransferHistory(
src="/downloads/declined-task.mkv",
@@ -699,6 +742,7 @@ def test_task_settlement_binds_receipt_without_overwriting_success_history():
settlement = _settlement(
outcome="succeeded",
task_id="declined-task",
checkpoint_outcome="overwrite_skipped",
)
first = writer.transfer_result(
@@ -738,6 +782,170 @@ def test_task_settlement_binds_receipt_without_overwriting_success_history():
assert history.transfer_settlement_revision is None
def test_task_overwrite_skip_without_success_history_settles_failed():
"""覆盖跳过找不到成功历史时,显式执行事实仍可裁决为失败。"""
factory = _session_factory()
_add_settling_pending(factory, execution_outcome="overwrite_skipped")
writer = TransactionalChainDurableEventWriter(factory)
result = 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=_settlement(
outcome="failed",
checkpoint_outcome="overwrite_skipped",
),
)
assert isinstance(result, TransferSettlementResult)
assert result.pending_deleted is False
with factory() as session:
pending = session.execute(select(TransferPending)).scalar_one()
history = session.execute(select(TransferHistory)).scalar_one()
receipt = session.execute(select(TransferSettlementReceipt)).scalar_one()
assert pending.execution_state == "failed"
assert history.status is False
assert receipt.outcome == "failed"
@pytest.mark.parametrize(
("checkpoint_outcome", "settlement_outcome"),
[
("failed", "succeeded"),
("succeeded", "failed"),
],
)
def test_task_settlement_rejects_checkpoint_outcome_conflicts_before_writes(
checkpoint_outcome,
settlement_outcome,
):
"""执行证据与结算方向冲突或未知时,不得进入历史暂存。"""
factory = _session_factory()
_add_settling_pending(
factory,
execution_outcome=checkpoint_outcome,
)
writer = TransactionalChainDurableEventWriter(factory)
with pytest.raises(TransferExecutionConflictError):
writer.transfer_result(
topic="transfer.completed",
stage_history=lambda _repository: pytest.fail("冲突结算不得写历史"),
event_payload={},
publish=lambda _payload: pytest.fail("冲突结算不得发布"),
settlement=_settlement(
outcome=settlement_outcome,
checkpoint_outcome=checkpoint_outcome,
),
)
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.settlement_revision == 0
def test_task_settlement_rejects_corrupted_checkpoint_outcome_before_writes():
"""持久层出现未知执行 outcome 时必须隔离,不得构造历史或事件。"""
factory = _session_factory()
_add_settling_pending(factory)
corrupted_payload = {
"schema_version": 1,
"payload": {"outcome": "unknown", "test_identity": "execution-1"},
"operation_ids": ["operation-1"],
"skip_reason": None,
}
corrupted_fingerprint = build_transfer_checkpoint_fingerprint(
corrupted_payload
)
with factory() as session:
pending = session.execute(select(TransferPending)).scalar_one()
pending.execution_payload = corrupted_payload
pending.execution_fingerprint = corrupted_fingerprint
session.commit()
writer = TransactionalChainDurableEventWriter(factory)
settlement = TransferResultSettlement(
task_id="task-1",
lease_token="lease-1",
execution_fingerprint=corrupted_fingerprint,
outcome="succeeded",
)
with pytest.raises(TransferExecutionConflictError):
writer.transfer_result(
topic="transfer.completed",
stage_history=lambda _repository: pytest.fail("损坏检查点不得写历史"),
event_payload={},
publish=lambda _payload: pytest.fail("损坏检查点不得发布"),
settlement=settlement,
)
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.settlement_revision == 0
def test_task_settlement_rejects_malformed_checkpoint_before_writes():
"""指纹自洽但结构损坏的数据库检查点也不得驱动终态写入。"""
factory = _session_factory()
_add_settling_pending(factory)
malformed_payload = {
"schema_version": 1,
"payload": {"outcome": "succeeded"},
"operation_ids": "operation-1",
"skip_reason": None,
}
fingerprint = build_transfer_checkpoint_fingerprint(malformed_payload)
with factory() as session:
pending = session.execute(select(TransferPending)).scalar_one()
pending.execution_payload = malformed_payload
pending.execution_fingerprint = fingerprint
session.commit()
writer = TransactionalChainDurableEventWriter(factory)
settlement = TransferResultSettlement(
task_id="task-1",
lease_token="lease-1",
execution_fingerprint=fingerprint,
outcome="succeeded",
)
with pytest.raises(TransferExecutionConflictError):
writer.transfer_result(
topic="transfer.completed",
stage_history=lambda _repository: pytest.fail("损坏检查点不得写历史"),
event_payload={},
publish=lambda _payload: pytest.fail("损坏检查点不得发布"),
settlement=settlement,
)
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.settlement_revision == 0
@pytest.mark.parametrize("cleanup", ["delete", "truncate"])
def test_receipt_replay_survives_real_history_command_cleanup(cleanup):
"""真实历史删除或清空命令执行后,独立回执仍可重放成功终态。"""
@@ -857,7 +1065,7 @@ def test_success_receipt_allows_expiry_and_legacy_same_source_replace():
def test_task_failure_settlement_is_replayable_and_retry_advances_revision():
"""失败保留终态证据,重复调用幂等,显式重试后才递增修订号。"""
factory = _session_factory()
_add_settling_pending(factory)
_add_settling_pending(factory, execution_outcome="failed")
writer = TransactionalChainDurableEventWriter(factory)
calls = []
first_settlement = _settlement(outcome="failed")
@@ -890,8 +1098,14 @@ def test_task_failure_settlement_is_replayable_and_retry_advances_revision():
assert pending.lease_token is None
assert pending.settlement_revision == 1
assert pending.terminal_history_id == first.history_id
retry_checkpoint = _execution_checkpoint(
outcome="failed",
identity="execution-2",
)
pending.execution_state = "settling"
pending.execution_fingerprint = "execution-2"
pending.execution_version = retry_checkpoint.version
pending.execution_payload = retry_checkpoint.to_payload()
pending.execution_fingerprint = retry_checkpoint.fingerprint
pending.lease_owner = "worker-2"
pending.lease_token = "lease-2"
pending.lease_expires_at = "2099-01-01 00:00:00.000000"
@@ -909,7 +1123,7 @@ def test_task_failure_settlement_is_replayable_and_retry_advances_revision():
settlement=_settlement(
outcome="failed",
lease_token="lease-2",
execution_fingerprint="execution-2",
execution_identity="execution-2",
),
)
@@ -949,10 +1163,15 @@ def test_task_failure_settlement_is_replayable_and_retry_advances_revision():
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].execution_fingerprint == _execution_checkpoint(
outcome="failed"
).fingerprint
assert receipts[0].lease_token == "lease-1"
assert receipts[1].outcome == "failed"
assert receipts[1].execution_fingerprint == "execution-2"
assert receipts[1].execution_fingerprint == _execution_checkpoint(
outcome="failed",
identity="execution-2",
).fingerprint
assert receipts[1].lease_token == "lease-2"
assert receipts[1].pending_deleted is False
assert receipts[1].error == "目标文件校验失败"
@@ -966,7 +1185,7 @@ def test_task_failure_settlement_is_replayable_and_retry_advances_revision():
def test_failed_revision_replays_after_later_success_deleted_pending():
"""后续重试成功删除 pending 后,旧失败修订仍按原执行身份幂等回读。"""
factory = _session_factory()
_add_settling_pending(factory)
_add_settling_pending(factory, execution_outcome="failed")
writer = TransactionalChainDurableEventWriter(factory)
failed_settlement = _settlement(outcome="failed")
failed = writer.transfer_result(
@@ -982,8 +1201,14 @@ def test_failed_revision_replays_after_later_success_deleted_pending():
)
with factory() as session:
pending = session.execute(select(TransferPending)).scalar_one()
retry_checkpoint = _execution_checkpoint(
outcome="succeeded",
identity="execution-2",
)
pending.execution_state = "settling"
pending.execution_fingerprint = "execution-2"
pending.execution_version = retry_checkpoint.version
pending.execution_payload = retry_checkpoint.to_payload()
pending.execution_fingerprint = retry_checkpoint.fingerprint
pending.lease_owner = "worker-2"
pending.lease_token = "lease-2"
pending.lease_expires_at = "2099-01-01 00:00:00.000000"
@@ -991,7 +1216,7 @@ def test_failed_revision_replays_after_later_success_deleted_pending():
succeeded_settlement = _settlement(
outcome="succeeded",
lease_token="lease-2",
execution_fingerprint="execution-2",
execution_identity="execution-2",
)
succeeded = writer.transfer_result(
topic="transfer.completed",
@@ -11,6 +11,7 @@ flush 前的事件。因此这里断言的是「绕过 Oper 直接建模写库
"""
import pytest
from app.application.transfer.workflow import TransferPlanningInput
from app.db.models.transferhistory import TransferHistory
from app.db.models.transferpending import TransferPending
from app.schemas.types import MediaSource
@@ -158,6 +159,12 @@ def test_tables_without_identity_columns_are_untouched(db):
不带身份列的表不受影响——事件挂在 Mapper 上覆盖全部映射,必须靠列名检查收窄,
否则会去动一张根本没有这两列的表。
"""
planning_input = TransferPlanningInput(source_fileitem={
"storage": "local",
"path": "/mnt/a.mkv",
"type": "file",
"name": "a.mkv",
})
TransferPending.stage_admit(
db.session,
task_id="identity-free-table",
@@ -165,6 +172,9 @@ def test_tables_without_identity_columns_are_untouched(db):
src_path="/mnt/a.mkv",
state="accepted",
now_time="2026-08-14 10:00:00",
input_version=planning_input.schema_version,
planning_input=planning_input.to_payload(),
input_fingerprint=planning_input.fingerprint,
)
row = TransferPending.get_by_identity(
+180 -1
View File
@@ -11,8 +11,10 @@ import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.application.transfer.workflow import TransferPlanningInput
from app.db import base as db_base
from app.db.adapters.transfer.admission import TransactionalTransferAdmissionRepository
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
@@ -22,6 +24,60 @@ from app.db.oper.transferpending import TransferPendingOper
def _track(db):
"""把待整理表纳入用例级回收。"""
db.watermark(TransferPending)
db.watermark(TransferExecutionStep)
def _leased_pending(
*,
task_id: str,
execution_state: str,
admission_state: str = "accepted",
) -> TransferPending:
"""构造持有有效租约且其余执行证据为空的 pending。"""
return TransferPending(
task_id=task_id,
storage="local",
src_path=f"/mnt/{task_id}.mkv",
state=admission_state,
created_at="2026-08-27 10:00:00",
updated_at="2026-08-27 10:00:00",
input_version=1,
planning_input={"schema_version": 1},
input_fingerprint="input",
lease_owner="worker",
lease_token=f"lease-{task_id}",
lease_expires_at="2099-01-01 00:00:00.000000",
heartbeat_at="2026-08-27 10:00:00.000000",
attempt_count=1,
execution_state=execution_state,
retry_generation=0,
retry_count=0,
settlement_revision=0,
)
def _planning_input(path: str) -> TransferPlanningInput:
"""构造准入仓储要求的真实版本化规划输入。"""
return TransferPlanningInput(
source_fileitem={
"storage": "local",
"path": path,
"type": "file",
"name": path.rsplit("/", 1)[-1],
},
meta=None,
mediainfo=None,
)
def _planning_fields(path: str) -> dict[str, object]:
"""返回 direct model/Oper 准入所需的显式版本化字段。"""
planning_input = _planning_input(path)
return {
"input_version": planning_input.schema_version,
"planning_input": planning_input.to_payload(),
"input_fingerprint": planning_input.fingerprint,
}
def test_stage_admit_is_idempotent_and_keeps_stable_task_id(db):
@@ -33,6 +89,7 @@ def test_stage_admit_is_idempotent_and_keeps_stable_task_id(db):
src_path="/mnt/durable.mkv",
state="accepted",
now_time="2026-08-27 10:00:00",
**_planning_fields("/mnt/durable.mkv"),
)
second = TransferPending.stage_admit(
db.session,
@@ -41,6 +98,7 @@ def test_stage_admit_is_idempotent_and_keeps_stable_task_id(db):
src_path="/mnt/durable.mkv",
state="accepted",
now_time="2026-08-27 11:00:00",
**_planning_fields("/mnt/durable.mkv"),
)
assert first is second
@@ -81,7 +139,9 @@ def test_state_queries_and_failure_record_share_stable_identity(db):
src_path="/mnt/accepted.mkv",
state="accepted",
now_time="2026-08-27 10:00:00",
**_planning_fields("/mnt/accepted.mkv"),
)
other_fields = _planning_fields("/mnt/other.mkv")
db.add(TransferPending(
task_id="task-other",
storage="local",
@@ -89,6 +149,7 @@ def test_state_queries_and_failure_record_share_stable_identity(db):
state="other",
created_at="2026-08-27 10:00:01",
updated_at="2026-08-27 10:00:01",
**other_fields,
))
db.session.flush()
@@ -130,6 +191,7 @@ def test_oper_staging_reuses_explicit_write_session(db, monkeypatch):
src_path="/mnt/explicit-stage.mkv",
state="accepted",
now_time="2026-08-27 10:00:00",
**_planning_fields("/mnt/explicit-stage.mkv"),
)
assert pending.task_id == "task-explicit"
assert oper.get_by_task_id(task_id="task-explicit").state == "accepted"
@@ -146,16 +208,19 @@ def test_transactional_repository_commits_frozen_projections(tmp_path):
engine = create_engine(f"sqlite:///{tmp_path / 'transfer.db'}")
TransferHistory.__table__.create(engine)
TransferPending.__table__.create(engine)
TransferExecutionStep.__table__.create(engine)
factory = sessionmaker(bind=engine)
repository = TransactionalTransferAdmissionRepository(factory)
admitted = repository.admit(
storage="local",
src_path="/mnt/repository.mkv",
planning_input=_planning_input("/mnt/repository.mkv"),
)
repeated = repository.admit(
storage="local",
src_path="/mnt/repository.mkv",
planning_input=_planning_input("/mnt/repository.mkv"),
)
assert repeated == admitted
assert admitted.task_id
@@ -178,7 +243,7 @@ def test_transactional_repository_commits_frozen_projections(tmp_path):
lease_seconds=60,
)
assert claimed is not None
assert repository.discard_claimed(
assert repository.abandon_unstarted(
task_id=admitted.task_id,
lease_token=claimed.lease_token,
) == 1
@@ -191,6 +256,120 @@ def test_transactional_repository_commits_frozen_projections(tmp_path):
engine.dispose()
@pytest.mark.parametrize(
("execution_state", "expected_deleted"),
[
("not_started", 1),
("running", 0),
("retry_wait", 0),
("settling", 0),
("failed", 0),
("manual_review", 0),
],
)
def test_abandon_unstarted_allows_only_pristine_execution_state(
db,
execution_state,
expected_deleted,
) -> None:
"""缺失源注销必须拒绝所有已开始、待重试、结算和人工状态。"""
task_id = f"abandon-{execution_state}"
db.add(_leased_pending(task_id=task_id, execution_state=execution_state))
db.session.flush()
deleted = TransferPending.abandon_unstarted(
db.session,
task_id=task_id,
lease_token=f"lease-{task_id}",
now_time="2026-08-27 10:01:00.000000",
)
db.session.flush()
assert deleted == expected_deleted
remaining = db.session.execute(
select(TransferPending).where(TransferPending.task_id == task_id)
).scalar_one_or_none()
assert (remaining is None) is bool(expected_deleted)
@pytest.mark.parametrize("admission_state", ["planned", "provider_pending"])
def test_abandon_unstarted_rejects_nonaccepted_admission_state(
db,
admission_state,
) -> None:
"""已进入 provider 或计划态的任务即使无步骤也不得按缺失源删除。"""
task_id = f"abandon-{admission_state}"
db.add(_leased_pending(
task_id=task_id,
execution_state="not_started",
admission_state=admission_state,
))
deleted = TransferPending.abandon_unstarted(
db.session,
task_id=task_id,
lease_token=f"lease-{task_id}",
now_time="2026-08-27 10:01:00.000000",
)
assert deleted == 0
def test_abandon_unstarted_rejects_task_with_any_step_evidence(db) -> None:
"""即使聚合状态尚未推进,已落库步骤也必须阻止删除 pending。"""
task_id = "abandon-with-step"
db.add(_leased_pending(task_id=task_id, execution_state="not_started"))
db.add(TransferExecutionStep(
task_id=task_id,
operation_id="operation-with-step",
checkpoint_fingerprint="plan",
ordinal=0,
phase="transfer",
kind="move",
state="prepared",
attempt_count=0,
intent_version=1,
intent_payload={"source": "/mnt/source.mkv"},
prepared_at="2026-08-27 10:00:00",
updated_at="2026-08-27 10:00:00",
))
db.session.flush()
deleted = TransferPending.abandon_unstarted(
db.session,
task_id=task_id,
lease_token=f"lease-{task_id}",
now_time="2026-08-27 10:01:00.000000",
)
assert deleted == 0
assert db.session.execute(
select(TransferPending).where(TransferPending.task_id == task_id)
).scalar_one_or_none() is not None
def test_abandon_unstarted_rejects_task_with_terminal_history_evidence(db) -> None:
"""已有任务关联历史时不得删除 pending,避免掩盖未闭环结算。"""
task_id = "abandon-with-history"
db.add(_leased_pending(task_id=task_id, execution_state="not_started"))
db.add(TransferHistory(
transfer_task_id=task_id,
transfer_settlement_revision=1,
src="/mnt/abandon-with-history.mkv",
src_storage="local",
status=False,
))
deleted = TransferPending.abandon_unstarted(
db.session,
task_id=task_id,
lease_token=f"lease-{task_id}",
now_time="2026-08-27 10:01:00.000000",
)
assert deleted == 0
def test_transactional_repository_rolls_back_failed_write(monkeypatch):
"""适配器写入异常时必须回滚自身 UoW 并传播原异常。"""
class SessionContext:
+52
View File
@@ -220,6 +220,58 @@ def test_transfer_package_exposes_plugin_symbols_only_through_overlay() -> None:
reset_legacy_import_diagnostics()
def test_transfer_legacy_symbols_support_all_explicit_imports() -> None:
"""六个旧整理符号显式导入应在隔离进程中共享同一兼容类型。"""
code = """
from app.application.transfer import TransferTask as ApplicationTask
from app.application.transfer import TransferQueue as ApplicationQueue
from app.schemas import TransferTask as SchemaTask
from app.schemas import TransferQueue as SchemaQueue
from app.schemas.transfer import TransferTask as TransferSchemaTask
from app.schemas.transfer import TransferQueue as TransferSchemaQueue
import app.application.transfer as application_package
import app.schemas as schemas_package
import app.schemas.transfer as transfer_schema
from app.application.transfer.workflow import TransferQueue as CanonicalQueue
from app.application.transfer.workflow import TransferTask as CanonicalTask
from app.sdk._legacy.transfer import TransferQueue as LegacyQueue
from app.sdk._legacy.transfer import TransferTask as LegacyTask
class LegacyPayload:
def model_dump(self):
return {"kind": "legacy"}
task_types = (ApplicationTask, SchemaTask, TransferSchemaTask)
queue_types = (ApplicationQueue, SchemaQueue, TransferSchemaQueue)
assert all(task_type is LegacyTask for task_type in task_types)
assert all(queue_type is LegacyQueue for queue_type in queue_types)
assert issubclass(LegacyTask, CanonicalTask)
assert issubclass(LegacyQueue, CanonicalQueue)
task = ApplicationTask(
fileitem={"storage": "local", "path": "/downloads/movie.mkv", "type": "file"},
meta=LegacyPayload(),
)
assert isinstance(task, CanonicalTask)
assert task.to_dict()["meta"] == {"kind": "legacy"}
for queue_type in queue_types:
queue = queue_type(task=task)
assert queue.task is task
for package in (application_package, schemas_package, transfer_schema):
assert "TransferTask" not in package.__all__
assert "TransferQueue" not in package.__all__
"""
subprocess.run(
[sys.executable, "-c", code],
cwd=Path(__file__).parents[1],
check=True,
)
def test_virtual_package_exports_resolve_exact_manifest_symbols():
"""合成旧包仅公开 manifest 声明的符号,并记录 DEBUG 兼容警告。"""
legacy_package = "app.core.meta"
+31 -2
View File
@@ -5,6 +5,10 @@ from unittest.mock import Mock
from jinja2 import Template
from app.application.messaging.message import TemplateHelper
from app.application.transfer.execution import (
TransferExecutionCheckpoint,
TransferSettlementResult,
)
from app.application.transfer.workflow import TransferTask
from app.chain.media import MediaChain
from app.chain.transfer import JobManager, TransferChain
@@ -494,6 +498,18 @@ def test_success_file_aggregation_is_isolated_between_music_jobs_in_same_directo
chain.eventmanager = Mock()
chain.transfer_completed = Mock()
chain.send_transfer_message = Mock()
def transfer_result(**kwargs):
"""执行测试历史暂存并返回 task-aware 原子结算回执。"""
history = kwargs["stage_history"](SimpleNamespace())
return TransferSettlementResult(
history_id=history.id,
settlement_revision=1,
pending_deleted=True,
)
chain.durable_event_writer = Mock()
chain.durable_event_writer.transfer_result.side_effect = transfer_result
album_infos = [
MusicInfo(
music_type="album",
@@ -544,8 +560,21 @@ def test_success_file_aggregation_is_isolated_between_music_jobs_in_same_directo
lambda **kwargs: SimpleNamespace(id=1),
)
for task in tasks:
chain._TransferChain__default_callback(task, transfer_info(task))
for sequence, task in enumerate(tasks):
result = transfer_info(task)
task.bind_admission_task_id(f"music-terminal-{sequence}")
task.bind_execution_lease(
owner_id="music-test-owner",
lease_token=f"music-lease-{sequence}",
)
task.bind_execution_checkpoint(TransferExecutionCheckpoint.create(
payload={
"outcome": "succeeded",
"transferinfo": result.model_dump(mode="json"),
},
operation_ids=(f"music-operation-{sequence}",),
))
chain._TransferChain__default_callback(task, result)
notified_lists = [
call.kwargs["transferinfo"].file_list_new
@@ -170,6 +170,46 @@ def test_transfer_admission_upgrade_downgrade_reupgrade(
}
def test_replayed_upgrade_repairs_named_constraint_and_index(monkeypatch) -> None:
"""同名但列或唯一性错误的准入约束与索引必须精确重建。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
_create_legacy_table(connection)
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
with migration.op.batch_alter_table("transferpending") as batch_op:
batch_op.drop_constraint(
"uq_transferpending_task_id",
type_="unique",
)
batch_op.create_unique_constraint(
"uq_transferpending_task_id",
["src_path"],
)
connection.execute(sa.text(
"DROP INDEX ix_transferpending_state_created"
))
connection.execute(sa.text(
"CREATE UNIQUE INDEX ix_transferpending_state_created "
"ON transferpending (task_id)"
))
migration.upgrade()
inspector = sa.inspect(connection)
constraint = next(
item for item in inspector.get_unique_constraints("transferpending")
if item["name"] == "uq_transferpending_task_id"
)
index = next(
item for item in inspector.get_indexes("transferpending")
if item["name"] == "ix_transferpending_state_created"
)
assert constraint["column_names"] == ["task_id"]
assert index["column_names"] == ["state", "created_at", "id"]
assert index["unique"] == 0
def test_transfer_admission_migration_runs_on_postgresql(monkeypatch) -> None:
"""隔离 PostgreSQL 应真实执行准入字段、约束、索引和可逆回滚。"""
prefix = "MOVIEPILOT_TEST_POSTGRESQL_"
+105 -13
View File
@@ -378,6 +378,83 @@ def test_downgrade_marks_step_evidence_then_reupgrade_keeps_manual_review(
engine.dispose()
def test_downgrade_archives_and_reupgrade_restores_settlement_receipts(
monkeypatch,
) -> None:
"""降级必须归档 append-only 终态证据,重复降级和重升均不得丢失。"""
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 ("
"'settled', 42, 1, 'succeeded', 'fingerprint', 'lease', 1, "
"'/source', 'local', 1, NULL, "
"'2026-08-27 12:00:00', '2026-08-27 12:00:00'"
")"
))
migration.downgrade()
migration.downgrade()
tables = sa.inspect(connection).get_table_names()
assert "transfersettlementreceipt" not in tables
assert "transfersettlementreceipt_3_0_16_archive" in tables
migration.upgrade()
migration.upgrade()
receipt = connection.execute(sa.text(
"SELECT task_id, outcome, settlement_revision "
"FROM transfersettlementreceipt"
)).one()
assert receipt == ("settled", "succeeded", 1)
assert "transfersettlementreceipt_3_0_16_archive" not in (
sa.inspect(connection).get_table_names()
)
def test_upgrade_repairs_owned_indexes_by_columns_and_uniqueness(monkeypatch) -> None:
"""关键索引同名但列或唯一性错误时必须按 ORM 契约重建。"""
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._add_pending_columns()
migration._backfill_pending()
migration._add_history_columns()
connection.execute(sa.text(
"CREATE UNIQUE INDEX ix_transferpending_execution_due "
"ON transferpending (task_id)"
))
connection.execute(sa.text(
"CREATE INDEX ux_transferhistory_transfer_task_id "
"ON transferhistory (src_storage)"
))
migration.upgrade()
inspector = sa.inspect(connection)
pending_index = next(
item for item in inspector.get_indexes("transferpending")
if item["name"] == "ix_transferpending_execution_due"
)
history_index = next(
item for item in inspector.get_indexes("transferhistory")
if item["name"] == "ux_transferhistory_transfer_task_id"
)
assert pending_index["column_names"] == [
"execution_state", "retry_due_at", "state", "created_at", "id",
]
assert pending_index["unique"] == 0
assert history_index["column_names"] == ["transfer_task_id"]
assert history_index["unique"] == 1
def test_upgrade_without_pending_table_is_a_safe_noop(monkeypatch):
"""全新数据库尚未执行前置迁移时本版本应安全等待迁移链建表。"""
engine = sa.create_engine("sqlite://")
@@ -659,7 +736,7 @@ def test_upgrade_adds_synthetic_review_when_nonmanual_step_already_exists(
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)
@@ -753,17 +830,32 @@ def test_migrated_legacy_reviews_are_discoverable_resolvable_and_retryable(
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(
with factory() as session:
pending = session.scalar(
sa.select(TransferPending).where(
TransferPending.task_id == task_id
)
)
assert pending is not None
assert pending.state in {"accepted", "planned"}
with pytest.raises(
TransferExecutionConflictError,
match="可执行规划状态|完整计划检查点|无法恢复",
):
command.prepare(
task_id=task_id,
checkpoint_fingerprint="f" * 64,
ordinal=0,
phase="transfer",
kind="copy",
payload={"source": task_id},
),
)
assert prepared.ordinal == 0
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},
),
)
current = repository.get_snapshot(task_id=task_id)
assert current is not None
assert current.state is TransferExecutionState.RETRY_WAIT
assert current.steps == ()
engine.dispose()
+449 -12
View File
@@ -1,5 +1,6 @@
"""验证整理执行证据、CAS fencing 与终态结算持久化。"""
from dataclasses import replace
from datetime import datetime, timezone
import pytest
@@ -20,6 +21,13 @@ from app.application.transfer.execution import (
build_transfer_checkpoint_fingerprint,
build_transfer_operation_id,
)
from app.application.transfer.workflow import (
TransferPlanCheckpoint,
TransferPlanItem,
TransferPlanningInput,
TransferProviderInvocationSnapshot,
TransferProviderReference,
)
from app.db.adapters.transfer.execution import (
TransactionalTransferExecutionRepository,
)
@@ -48,22 +56,70 @@ def execution_store():
engine.dispose()
def _seed_pending(factory, *, task_id: str = "task-1", lease_token: str = "lease-1"):
def _planning_input(task_id: str) -> TransferPlanningInput:
"""构造与测试源文件一致的持久规划输入。"""
return TransferPlanningInput(
source_fileitem={
"storage": "local",
"path": f"/{task_id}.mkv",
"type": "file",
},
target_storage="local",
target_path="/media",
requested_transfer_type="copy",
)
def _plan_checkpoint(task_id: str) -> TransferPlanCheckpoint:
"""构造包含一个叶子文件计划的完整宿主检查点。"""
planning_input = _planning_input(task_id)
return TransferPlanCheckpoint(
planning_input=planning_input,
target_storage="local",
root_target_path="/media",
final_target_path=f"/media/{task_id}.mkv",
resolved_transfer_type="copy",
items=(TransferPlanItem(
sequence=0,
source_fileitem=planning_input.source_fileitem,
target_storage="local",
target_path=f"/media/{task_id}.mkv",
),),
)
def _plan_fingerprint(task_id: str) -> str:
"""返回测试冻结计划使用的 canonical 指纹。"""
return build_transfer_checkpoint_fingerprint(
_plan_checkpoint(task_id).to_payload()
)
def _seed_pending(
factory,
*,
task_id: str = "task-1",
lease_token: str = "lease-1",
state: str = "planned",
with_checkpoint: bool = True,
):
"""写入一条带有效租约与合法 planning checkpoint 的待执行任务。"""
planning_input = _planning_input(task_id)
checkpoint = _plan_checkpoint(task_id) if with_checkpoint else None
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",
state=state,
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",
planning_input=planning_input.to_payload(),
input_fingerprint=planning_input.fingerprint,
checkpoint_version=checkpoint.schema_version if checkpoint else None,
checkpoint_payload=checkpoint.to_payload() if checkpoint else None,
planned_at="2026-08-27 09:00:00" if checkpoint else None,
lease_owner="worker-1",
lease_token=lease_token,
lease_expires_at="2099-01-01 00:00:00.000000",
@@ -93,13 +149,19 @@ def _repository(factory, token_values: list[str] | None = None):
def _intent(*, task_id: str = "task-1", ordinal: int = 0) -> TransferStepIntent:
"""构造稳定且可重复计算身份的测试步骤意图。"""
planning_input = _planning_input(task_id)
return TransferStepIntent.create(
task_id=task_id,
checkpoint_fingerprint="plan-fingerprint",
checkpoint_fingerprint=_plan_fingerprint(task_id),
ordinal=ordinal,
phase="transfer",
kind="copy",
payload={"src": f"/{task_id}.mkv", "dest": f"/media/{task_id}.mkv"},
kind="materialize_target",
payload={
"source": planning_input.source_fileitem,
"target_storage": "local",
"target_path": f"/media/{task_id}.mkv",
"transfer_type": "copy",
},
)
@@ -138,6 +200,143 @@ def test_stable_operation_and_checkpoint_identities_are_canonical():
assert intent.payload == {"path": "/original"}
@pytest.mark.parametrize(
("state", "with_checkpoint"),
(("accepted", False), ("planned", False)),
)
def test_prepare_rejects_task_without_executable_plan(
execution_store,
state,
with_checkpoint,
):
"""接纳态或缺失完整 checkpoint 的任务不得进入外部步骤准备。"""
_seed_pending(
execution_store,
state=state,
with_checkpoint=with_checkpoint,
)
_, command = _repository(execution_store)
with pytest.raises(TransferExecutionConflictError):
command.prepare(
task_id="task-1",
lease_token="lease-1",
intent=_intent(),
)
with execution_store() as session:
pending = session.scalar(select(TransferPending))
assert pending is not None
assert pending.execution_state == "not_started"
assert session.scalar(select(TransferExecutionStep)) is None
def test_prepare_rejects_noncanonical_plan_fingerprint(execution_store):
"""步骤 intent 必须精确绑定当前持久计划的 canonical 指纹。"""
_seed_pending(execution_store)
_, command = _repository(execution_store)
intent = TransferStepIntent.create(
task_id="task-1",
checkpoint_fingerprint="0" * 64,
ordinal=0,
phase="transfer",
kind="materialize_target",
payload=_intent().payload,
)
with pytest.raises(TransferExecutionConflictError, match="当前冻结计划指纹"):
command.prepare(
task_id="task-1",
lease_token="lease-1",
intent=intent,
)
def test_prepare_rejects_forged_operation_id(execution_store):
"""调用方手工构造的伪 operation ID 不得绕过稳定身份计算。"""
_seed_pending(execution_store)
_, command = _repository(execution_store)
valid = _intent()
forged = TransferStepIntent(
operation_id="f" * 64,
checkpoint_fingerprint=valid.checkpoint_fingerprint,
ordinal=valid.ordinal,
phase=valid.phase,
kind=valid.kind,
payload=valid.payload,
)
with pytest.raises(TransferExecutionConflictError, match="operation ID 不可信"):
command.prepare(
task_id="task-1",
lease_token="lease-1",
intent=forged,
)
def test_prepare_rejects_arbitrary_intent_with_known_plan_fingerprint(
execution_store,
):
"""已知计划指纹也不能构造计划未授权的操作类型或参数。"""
_seed_pending(execution_store)
_, command = _repository(execution_store)
arbitrary = TransferStepIntent.create(
task_id="task-1",
checkpoint_fingerprint=_plan_fingerprint("task-1"),
ordinal=0,
phase="transfer",
kind="delete_move_source",
payload={
"source": _planning_input("task-1").source_fileitem,
"target_storage": "local",
"target_path": "/media/task-1.mkv",
},
)
with pytest.raises(TransferExecutionConflictError, match="冻结计划导出"):
command.prepare(
task_id="task-1",
lease_token="lease-1",
intent=arbitrary,
)
def test_prepare_rejects_noncontiguous_ordinal(execution_store):
"""新步骤只能在完整既有序列尾部连续追加。"""
_seed_pending(execution_store)
_, command = _repository(execution_store)
with pytest.raises(TransferExecutionConflictError, match="连续追加"):
command.prepare(
task_id="task-1",
lease_token="lease-1",
intent=_intent(ordinal=1),
)
def test_stage_execution_running_cas_binds_exact_plan_identity(execution_store):
"""running CAS 必须拒绝读取后已变化的准入状态或 checkpoint payload。"""
_seed_pending(execution_store)
checkpoint = _plan_checkpoint("task-1")
with execution_store() as session:
stale = TransferPending.stage_execution_running(
session,
task_id="task-1",
lease_token="lease-1",
admission_state="planned",
checkpoint_version=checkpoint.schema_version,
checkpoint_payload={"schema_version": checkpoint.schema_version},
now_utc="2026-08-27 01:30:00.000000",
updated_at="2026-08-27 09:30:00",
)
assert stale == 0
current = TransferPending.stage_execution_running(
session,
task_id="task-1",
lease_token="lease-1",
admission_state="planned",
checkpoint_version=checkpoint.schema_version,
checkpoint_payload=checkpoint.to_payload(),
now_utc="2026-08-27 01:30:00.000000",
updated_at="2026-08-27 09:30:00",
)
assert current == 1
def test_success_path_persists_steps_and_execution_checkpoint(execution_store):
"""成功路径应保留每步证据,并提交可供唯一 durable writer 结算的检查点。"""
_seed_pending(execution_store)
@@ -161,7 +360,7 @@ def test_success_path_persists_steps_and_execution_checkpoint(execution_store):
result=TransferStepResult(payload={"dest_exists": True}),
)
checkpoint = TransferExecutionCheckpoint.create(
payload={"dest": "/media/task-1.mkv"},
payload={"outcome": "succeeded", "dest": "/media/task-1.mkv"},
operation_ids=(succeeded.operation_id,),
)
snapshot = command.checkpoint(
@@ -178,6 +377,244 @@ def test_success_path_persists_steps_and_execution_checkpoint(execution_store):
assert step is not None and step.state == "succeeded"
def test_provider_predecessor_remains_owned_after_host_plan_promotion(
execution_store,
):
"""provider 回退升级宿主计划后,序号零证据仍应被严格重建并纳入 checkpoint。"""
task_id = "task-1"
planning_input = _planning_input(task_id)
provider = TransferProviderReference(
plugin_id="provider-a",
plugin_name="Provider A",
)
invocation = TransferProviderInvocationSnapshot(
fileitem=planning_input.source_fileitem,
meta={"title": "Movie"},
meta_kind="MetaVideo",
mediainfo={"title": "Movie"},
mediainfo_kind="MediaInfo",
)
provider_checkpoint = TransferPlanCheckpoint(
planning_input=planning_input,
target_storage="",
root_target_path="",
final_target_path="",
resolved_transfer_type="",
items=(),
resolved_meta=invocation.meta,
resolved_meta_kind=invocation.meta_kind,
resolved_mediainfo=invocation.mediainfo,
resolved_mediainfo_kind=invocation.mediainfo_kind,
legacy_transfer_providers=(provider,),
provider_invocation=invocation,
)
_seed_pending(execution_store)
with execution_store() as session:
pending = session.scalar(select(TransferPending))
assert pending is not None
pending.state = "provider_pending"
pending.checkpoint_payload = provider_checkpoint.to_payload()
session.commit()
_, command = _repository(execution_store)
provider_intent = TransferStepIntent.create(
task_id=task_id,
checkpoint_fingerprint=build_transfer_checkpoint_fingerprint(
provider_checkpoint.to_payload()
),
ordinal=0,
phase="provider",
kind="legacy_transfer_provider_sequence",
payload={
"providers": [provider.to_payload()],
"invocation": invocation.to_payload(),
},
)
prepared = command.prepare(
task_id=task_id,
lease_token="lease-1",
intent=provider_intent,
)
started = command.begin(
task_id=task_id,
lease_token="lease-1",
operation_id=prepared.operation_id,
)
provider_succeeded = command.complete(
task_id=task_id,
lease_token="lease-1",
step=started,
result=TransferStepResult(payload={"handled": False}),
)
promoted_checkpoint = replace(
_plan_checkpoint(task_id),
pre_execution_cleanup_completed=True,
)
with execution_store() as session:
pending = session.scalar(select(TransferPending))
assert pending is not None
pending.state = "planned"
pending.checkpoint_payload = promoted_checkpoint.to_payload()
session.commit()
promoted_fingerprint = build_transfer_checkpoint_fingerprint(
promoted_checkpoint.to_payload()
)
host_intent = TransferStepIntent.create(
task_id=task_id,
checkpoint_fingerprint=promoted_fingerprint,
ordinal=1,
phase="transfer",
kind="materialize_target",
payload=_intent().payload,
)
host_prepared = command.prepare(
task_id=task_id,
lease_token="lease-1",
intent=host_intent,
)
host_started = command.begin(
task_id=task_id,
lease_token="lease-1",
operation_id=host_prepared.operation_id,
)
host_succeeded = command.complete(
task_id=task_id,
lease_token="lease-1",
step=host_started,
result=TransferStepResult(payload={"dest_exists": True}),
)
execution_checkpoint = TransferExecutionCheckpoint.create(
payload={"outcome": "succeeded", "dest": "/media/task-1.mkv"},
operation_ids=(
provider_succeeded.operation_id,
host_succeeded.operation_id,
),
)
snapshot = command.checkpoint(
task_id=task_id,
lease_token="lease-1",
checkpoint=execution_checkpoint,
)
assert snapshot.state is TransferExecutionState.SETTLING
assert tuple(step.ordinal for step in snapshot.steps) == (0, 1)
def test_checkpoint_rejects_operation_ids_out_of_ordinal_order(execution_store):
"""执行检查点必须按严格 ordinal 保存操作身份,集合相同也不能乱序。"""
_seed_pending(execution_store)
_, command = _repository(execution_store)
completed = []
for ordinal in range(2):
prepared = command.prepare(
task_id="task-1",
lease_token="lease-1",
intent=_intent(ordinal=ordinal),
)
started = command.begin(
task_id="task-1",
lease_token="lease-1",
operation_id=prepared.operation_id,
)
completed.append(command.complete(
task_id="task-1",
lease_token="lease-1",
step=started,
result=TransferStepResult(payload={"ordinal": ordinal}),
))
checkpoint = TransferExecutionCheckpoint.create(
payload={"outcome": "succeeded", "dest": "/media/task-1.mkv"},
operation_ids=tuple(step.operation_id for step in reversed(completed)),
)
with pytest.raises(TransferExecutionConflictError, match="步骤顺序"):
command.checkpoint(
task_id="task-1",
lease_token="lease-1",
checkpoint=checkpoint,
)
def test_checkpoint_rejects_corrupted_persisted_operation_id(execution_store):
"""checkpoint 前必须重新计算每个持久步骤的 operation ID。"""
_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.complete(
task_id="task-1",
lease_token="lease-1",
step=started,
result=TransferStepResult(payload={"dest_exists": True}),
)
with execution_store() as session:
step = session.scalar(select(TransferExecutionStep))
assert step is not None
step.operation_id = "e" * 64
session.commit()
checkpoint = TransferExecutionCheckpoint.create(
payload={"outcome": "succeeded", "dest": "/media/task-1.mkv"},
operation_ids=("e" * 64,),
)
with pytest.raises(TransferExecutionConflictError, match="冻结意图"):
command.checkpoint(
task_id="task-1",
lease_token="lease-1",
checkpoint=checkpoint,
)
def test_checkpoint_rejects_noncontiguous_persisted_ordinals(execution_store):
"""checkpoint 前必须拒绝缺口或从非零开始的持久步骤序列。"""
_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.complete(
task_id="task-1",
lease_token="lease-1",
step=started,
result=TransferStepResult(payload={"dest_exists": True}),
)
with execution_store() as session:
step = session.scalar(select(TransferExecutionStep))
assert step is not None
step.ordinal = 2
step.operation_id = build_transfer_operation_id(
task_id=step.task_id,
checkpoint_fingerprint=step.checkpoint_fingerprint,
ordinal=step.ordinal,
phase=step.phase,
kind=step.kind,
intent_payload=step.intent_payload,
)
corrupted_operation_id = step.operation_id
session.commit()
checkpoint = TransferExecutionCheckpoint.create(
payload={"outcome": "succeeded", "dest": "/media/task-1.mkv"},
operation_ids=(corrupted_operation_id,),
)
with pytest.raises(TransferExecutionConflictError, match="全局序号不连续"):
command.checkpoint(
task_id="task-1",
lease_token="lease-1",
checkpoint=checkpoint,
)
def test_retry_wait_resumes_same_failed_operation_with_new_attempt(execution_store):
"""到期重试必须复用 operation ID、保留失败证据并轮换 attempt token。"""
_seed_pending(execution_store)
@@ -264,7 +701,7 @@ def test_zero_side_effect_checkpoint_is_vacuously_complete(execution_store):
_seed_pending(execution_store)
_, command = _repository(execution_store)
checkpoint = TransferExecutionCheckpoint.create(
payload={"preview": True, "accepted": False},
payload={"outcome": "failed", "preview": True, "accepted": False},
operation_ids=(),
skip_reason="preview",
)
+202 -16
View File
@@ -15,6 +15,12 @@ from app.application.transfer.execution import (
TransferOperationObservationState,
TransferStepIntent,
TransferStepResult,
build_transfer_checkpoint_fingerprint,
)
from app.application.transfer.workflow import (
TransferPlanCheckpoint,
TransferPlanItem,
TransferPlanningInput,
)
from app.chain import transfer as transfer_chain_module
from app.db.adapters.transfer.execution import (
@@ -28,6 +34,33 @@ from app.modules.filemanager.transhandler import TransHandler
from app.schemas.workflow import FileItem
def _runner_plan_checkpoint() -> TransferPlanCheckpoint:
"""构造 runner fixture 使用的完整冻结计划。"""
planning_input = TransferPlanningInput(
source_fileitem={
"storage": "local",
"path": "/source.mkv",
"type": "file",
},
target_storage="local",
target_path="/target.mkv",
requested_transfer_type="copy",
)
return TransferPlanCheckpoint(
planning_input=planning_input,
target_storage="local",
root_target_path="/",
final_target_path="/target.mkv",
resolved_transfer_type="copy",
items=(TransferPlanItem(
sequence=0,
source_fileitem=planning_input.source_fileitem,
target_storage="local",
target_path="/target.mkv",
),),
)
@pytest.fixture
def execution_repository():
"""构造带有效 pending 租约的独立执行仓储。"""
@@ -41,6 +74,8 @@ def execution_repository():
],
)
factory = sessionmaker(bind=engine, expire_on_commit=False)
plan_checkpoint = _runner_plan_checkpoint()
planning_input = plan_checkpoint.planning_input
with factory() as session:
session.add(TransferPending(
task_id="task-runner",
@@ -50,10 +85,10 @@ def execution_repository():
state="planned",
updated_at="2026-08-27 09:00:00",
input_version=1,
planning_input={"schema_version": 1},
input_fingerprint="input",
planning_input=planning_input.to_payload(),
input_fingerprint=planning_input.fingerprint,
checkpoint_version=1,
checkpoint_payload={"schema_version": 1},
checkpoint_payload=plan_checkpoint.to_payload(),
planned_at="2026-08-27 09:00:00",
lease_owner="worker",
lease_token="lease",
@@ -82,11 +117,30 @@ def _runner(repository):
return transfer_chain_module._DurableTransferStepRunner(
task_id="task-runner",
lease_token="lease",
checkpoint_fingerprint="plan",
checkpoint_fingerprint=_runner_plan_fingerprint(),
repository=repository,
)
def _runner_plan_fingerprint() -> str:
"""返回 runner fixture 中完整冻结计划的 canonical 指纹。"""
return build_transfer_checkpoint_fingerprint(
_runner_plan_checkpoint().to_payload()
)
def _runner_step_payload() -> dict:
"""返回由 runner 冻结计划唯一叶操作导出的目标落地参数。"""
checkpoint = _runner_plan_checkpoint()
item = checkpoint.items[0]
return {
"source": item.source_fileitem,
"target_storage": item.target_storage,
"target_path": item.target_path,
"transfer_type": checkpoint.resolved_transfer_type,
}
def test_runner_replay_returns_persisted_result_without_repeating_side_effect(
execution_repository,
):
@@ -94,8 +148,8 @@ def test_runner_replay_returns_persisted_result_without_repeating_side_effect(
calls = []
first = _runner(execution_repository).run(
phase="transfer",
kind="copy",
payload={"source": "/source.mkv", "target": "/target.mkv"},
kind="materialize_target",
payload=_runner_step_payload(),
execute=lambda: calls.append("executed") or TransferStepResult(
payload={"item": {"path": "/target.mkv"}}
),
@@ -103,8 +157,8 @@ def test_runner_replay_returns_persisted_result_without_repeating_side_effect(
)
second = _runner(execution_repository).run(
phase="transfer",
kind="copy",
payload={"source": "/source.mkv", "target": "/target.mkv"},
kind="materialize_target",
payload=_runner_step_payload(),
execute=lambda: pytest.fail("已成功步骤不得重复执行"),
observe=lambda: pytest.fail("已成功步骤不得执行恢复探测"),
)
@@ -122,11 +176,11 @@ def test_runner_routes_unknown_orphaned_attempt_to_manual_review(
lease_token="lease",
intent=TransferStepIntent.create(
task_id="task-runner",
checkpoint_fingerprint="plan",
ordinal=0,
phase="provider",
kind="opaque",
payload={"provider": "legacy"},
checkpoint_fingerprint=_runner_plan_fingerprint(),
ordinal=0,
phase="transfer",
kind="materialize_target",
payload=_runner_step_payload(),
),
)
command.begin(
@@ -140,9 +194,9 @@ def test_runner_routes_unknown_orphaned_attempt_to_manual_review(
match="禁止自动重放",
):
_runner(execution_repository).run(
phase="provider",
kind="opaque",
payload={"provider": "legacy"},
phase="transfer",
kind="materialize_target",
payload=_runner_step_payload(),
execute=lambda: pytest.fail("未知遗留步骤不得重放"),
observe=lambda: TransferOperationObservation(
state=TransferOperationObservationState.UNKNOWN,
@@ -154,6 +208,138 @@ def test_runner_routes_unknown_orphaned_attempt_to_manual_review(
assert snapshot.state is TransferExecutionState.MANUAL_REVIEW
def test_runner_observes_applied_after_execute_error_and_completes_step(
execution_repository,
) -> None:
"""execute 抛错后若外部事实已生效,必须以观察证据完成而非重放。"""
evidence = TransferStepResult(payload={"target": "/target.mkv"})
def execute() -> TransferStepResult:
"""模拟副作用成功后调用方在返回前崩溃。"""
raise OSError("connection reset after apply")
result = _runner(execution_repository).run(
phase="transfer",
kind="materialize_target",
payload=_runner_step_payload(),
execute=execute,
observe=lambda: TransferOperationObservation(
state=TransferOperationObservationState.APPLIED,
evidence=evidence,
),
)
snapshot = execution_repository.get_snapshot(task_id="task-runner")
assert result == evidence
assert snapshot is not None
assert snapshot.state is TransferExecutionState.RUNNING
assert snapshot.steps[0].result == evidence
assert snapshot.steps[0].state.value == "succeeded"
def test_runner_defers_after_execute_error_observed_not_applied(
execution_repository,
) -> None:
"""execute 抛错且确认未生效时只能进入持久退避,不得误判成功。"""
evidence = TransferStepResult(payload={"target_exists": False})
def execute() -> TransferStepResult:
"""模拟外部操作在应用前失败。"""
raise OSError("write rejected")
with pytest.raises(transfer_chain_module._TransferRetryDeferred):
_runner(execution_repository).run(
phase="transfer",
kind="materialize_target",
payload=_runner_step_payload(),
execute=execute,
observe=lambda: TransferOperationObservation(
state=TransferOperationObservationState.NOT_APPLIED,
evidence=evidence,
),
)
snapshot = execution_repository.get_snapshot(task_id="task-runner")
assert snapshot is not None
assert snapshot.state is TransferExecutionState.RETRY_WAIT
assert snapshot.steps[0].result == evidence
assert snapshot.steps[0].state.value == "failed"
@pytest.mark.parametrize(
"observation_state",
[
TransferOperationObservationState.UNKNOWN,
TransferOperationObservationState.CONFLICT,
],
)
def test_runner_freezes_uncertain_execute_error_for_manual_review(
execution_repository,
observation_state,
) -> None:
"""execute 异常后的未知或冲突结果必须冻结,禁止自动重放。"""
evidence = TransferStepResult(payload={"receipt": "ambiguous"})
def execute() -> TransferStepResult:
"""模拟外部结果未知的执行异常。"""
raise TimeoutError("provider timeout")
with pytest.raises(
transfer_chain_module._TransferManualReviewRequired,
match=observation_state.value,
):
_runner(execution_repository).run(
phase="transfer",
kind="materialize_target",
payload=_runner_step_payload(),
execute=execute,
observe=lambda: TransferOperationObservation(
state=observation_state,
evidence=evidence,
),
)
snapshot = execution_repository.get_snapshot(task_id="task-runner")
assert snapshot is not None
assert snapshot.state is TransferExecutionState.MANUAL_REVIEW
assert snapshot.steps[0].result == evidence
assert snapshot.steps[0].state.value == "manual_review"
def test_runner_freezes_when_observer_errors_after_execute_error(
execution_repository,
) -> None:
"""execute 与 observer 同时失败时必须保留双重证据并进入人工复核。"""
def execute() -> TransferStepResult:
"""模拟调用结果未知的执行超时。"""
raise TimeoutError("execute timeout")
def observe() -> TransferOperationObservation:
"""模拟外部状态查询端点同时不可用。"""
raise ConnectionError("observer unavailable")
with pytest.raises(
transfer_chain_module._TransferManualReviewRequired,
match="unknown",
):
_runner(execution_repository).run(
phase="transfer",
kind="materialize_target",
payload=_runner_step_payload(),
execute=execute,
observe=observe,
)
snapshot = execution_repository.get_snapshot(task_id="task-runner")
assert snapshot is not None
assert snapshot.state is TransferExecutionState.MANUAL_REVIEW
assert snapshot.steps[0].result is not None
assert snapshot.steps[0].result.payload == {
"execute_error": "execute timeout",
"observe_error": "observer unavailable",
}
class _ImmediateStepRunner:
"""记录 TransHandler 拆分顺序并立即执行步骤的测试 runner。"""
@@ -1,126 +0,0 @@
"""失败整理 AI 重试调度器的生命周期测试。"""
import asyncio
from unittest.mock import Mock, patch
import pytest
from app.application.transfer.workflow import FailedRetryScheduler
def test_retry_scheduler_close_cancels_buffered_timer_and_rejects_new_work():
"""关闭应取消尚未触发的 timer、清空缓冲并拒绝新增记录。"""
async def exercise() -> None:
"""在独立事件循环内验证 timer 与关闭状态。"""
scheduler = FailedRetryScheduler()
scheduler.RETRY_TRANSFER_DEBOUNCE_SECONDS = 60
await scheduler.schedule_retry(11, group_key="media:test")
timer = scheduler._retry_transfer_timers["media:test"]
await scheduler.close()
await scheduler.close()
assert timer.cancelled()
assert scheduler._retry_transfer_buffer == {}
assert scheduler._retry_transfer_timers == {}
with pytest.raises(RuntimeError, match="正在关闭"):
await scheduler.schedule_retry(12, group_key="media:test")
asyncio.run(exercise())
def test_retry_scheduler_close_cancels_and_waits_for_active_flush_task():
"""关闭返回前应等待已经启动的 flush 任务完成取消收尾。"""
async def exercise() -> None:
"""启动一个不会自行结束的 flush,并通过关闭流程取消它。"""
scheduler = FailedRetryScheduler()
scheduler.RETRY_TRANSFER_DEBOUNCE_SECONDS = 0
started = asyncio.Event()
stopped = asyncio.Event()
async def blocking_flush(_group_key: str, _generation: int) -> None:
"""等待取消信号,并在 finally 中证明收尾已经完成。"""
started.set()
try:
await asyncio.Event().wait()
finally:
stopped.set()
scheduler._flush_retry_transfer = blocking_flush
await scheduler.schedule_retry(11, group_key="media:test")
await asyncio.wait_for(started.wait(), timeout=1)
task = next(iter(scheduler._retry_transfer_tasks))
await scheduler.close()
assert stopped.is_set()
assert task.cancelled()
assert task.get_name() == "transfer.failed_retry.flush"
assert scheduler._retry_transfer_tasks == set()
asyncio.run(exercise())
def test_retry_scheduler_observes_unexpected_background_task_error():
"""flush 协程越过自身防线的异常仍应由任务 owner 统一观察。"""
async def exercise() -> None:
"""让受管 flush 任务直接失败,并等待完成回调处理异常。"""
scheduler = FailedRetryScheduler()
scheduler.RETRY_TRANSFER_DEBOUNCE_SECONDS = 0
async def failing_flush(_group_key: str, _generation: int) -> None:
"""模拟 flush 外层出现未处理异常。"""
raise RuntimeError("flush failed")
scheduler._flush_retry_transfer = failing_flush
with patch("app.application.transfer.workflow.logger.error", Mock()) as log_error:
await scheduler.schedule_retry(11, group_key="media:test")
for _ in range(5):
await asyncio.sleep(0)
if scheduler._retry_transfer_tasks:
break
assert scheduler._retry_transfer_tasks
for _ in range(5):
await asyncio.sleep(0)
if not scheduler._retry_transfer_tasks:
break
assert scheduler._retry_transfer_tasks == set()
log_error.assert_called_once()
assert "flush failed" in log_error.call_args.args[0]
await scheduler.close()
asyncio.run(exercise())
def test_retry_scheduler_old_flush_cannot_consume_renewed_generation():
"""旧 timer 已建 task 后的新失败应续期,不能被旧 flush 提前取走。"""
async def exercise() -> None:
"""稳定复现 timer callback 与同组新 schedule 交错的窗口。"""
scheduler = FailedRetryScheduler()
scheduler.RETRY_TRANSFER_DEBOUNCE_SECONDS = 3600
await scheduler.schedule_retry(11, group_key="media:test")
old_timer = scheduler._retry_transfer_timers["media:test"]
old_generation = scheduler._retry_transfer_generations["media:test"]
# 模拟旧 timer callback 已进入事件循环,但 flush task 尚未取得分组锁。
scheduler._start_retry_transfer_task("media:test", old_generation)
await scheduler.schedule_retry(12, group_key="media:test")
renewed_timer = scheduler._retry_transfer_timers["media:test"]
await asyncio.sleep(0)
await asyncio.sleep(0)
assert old_timer.cancelled()
assert renewed_timer.cancelled() is False
assert scheduler._retry_transfer_buffer["media:test"] == [11, 12]
assert scheduler._retry_transfer_timers["media:test"] is renewed_timer
assert scheduler._retry_transfer_tasks == set()
await scheduler.close()
assert renewed_timer.cancelled()
asyncio.run(exercise())
+94 -40
View File
@@ -9,6 +9,10 @@ from app.application.history import (
failed_retry_count,
record_transfer_failure,
)
from app.application.transfer.execution import (
TransferExecutionCheckpoint,
TransferSettlementResult,
)
from app.application.transfer.workflow import (
TransferAdmission,
TransferPlanningInput,
@@ -223,13 +227,80 @@ def make_transfer_chain() -> TransferChain:
admissions.admit.side_effect = admit
admissions.claim_task.side_effect = claim_task
admissions.checkpoint_plan.side_effect = checkpoint_plan
admissions.discard_claimed.return_value = 1
admissions.abandon_unstarted.return_value = 1
admissions.release_claim.return_value = True
chain._transfer_admissions = admissions
chain._TransferChain__ensure_lease_heartbeat_owner = MagicMock()
class ImmediateStepRunner:
"""为 JobManager 测试提交纯内存步骤与聚合执行检查点。"""
def run(self, *, phase, kind, payload, execute, observe):
"""立即执行确定性测试步骤,不使用恢复探测。"""
del phase, kind, payload, observe
return execute()
def checkpoint(self, transferinfo):
"""把测试整理结果冻结为可供 task-aware writer 使用的检查点。"""
return TransferExecutionCheckpoint.create(
payload={
"outcome": "succeeded" if transferinfo.success else "failed",
"transferinfo": transferinfo.model_dump(mode="json"),
},
operation_ids=("job-test-operation",),
)
step_runner = ImmediateStepRunner()
chain._TransferChain__build_durable_step_runner = MagicMock(
return_value=step_runner
)
def transfer_result(**kwargs):
"""执行测试历史暂存与发布,并返回已删除 pending 的原子回执。"""
staging = SimpleNamespace(
get_success_by_src=lambda *_args, **_kwargs: SimpleNamespace(
id=99,
status=True,
)
)
history = kwargs["stage_history"](staging)
if kwargs["publish"] is not None:
kwargs["publish"](kwargs["event_payload"])
return TransferSettlementResult(
history_id=getattr(history, "id", 1) if history is not None else 1,
settlement_revision=1,
pending_deleted=True,
)
chain.durable_event_writer = MagicMock()
chain.durable_event_writer.transfer_result.side_effect = transfer_result
return chain
def bind_terminal_checkpoint(
task: TransferTask,
transferinfo: TransferInfo,
) -> None:
"""为直接回调测试绑定 task identity、lease 与聚合执行检查点。"""
task_id = f"terminal-{abs(hash(task.fileitem.path))}"
task.bind_admission_task_id(task_id)
task.bind_execution_lease(
owner_id="job-test-owner",
lease_token=f"lease-{task_id}",
)
task.bind_execution_checkpoint(TransferExecutionCheckpoint.create(
payload={
"outcome": (
"overwrite_skipped"
if transferinfo.overwrite_skipped
else "succeeded" if transferinfo.success else "failed"
),
"transferinfo": transferinfo.model_dump(mode="json"),
},
operation_ids=("job-test-operation",),
))
def make_fileitem(path: str, size: int = 1024) -> FileItem:
file_path = path
name = file_path.rsplit("/", 1)[-1]
@@ -545,6 +616,7 @@ class TransferJobManagerTest(unittest.TestCase):
need_scrape=True,
need_notify=False,
)
bind_terminal_checkpoint(task, transferinfo)
with patch(
"app.chain.transfer.get_chain_transfer_history_port", return_value=SimpleNamespace()
@@ -969,6 +1041,7 @@ class TransferJobManagerTest(unittest.TestCase):
transfer_type="copy",
need_notify=False,
)
bind_terminal_checkpoint(task, failed_transferinfo)
failed_history_oper = SimpleNamespace()
with patch(
"app.chain.transfer.get_chain_transfer_history_port",
@@ -1010,6 +1083,7 @@ class TransferJobManagerTest(unittest.TestCase):
need_scrape=False,
need_notify=False,
)
bind_terminal_checkpoint(task, success_transferinfo)
with patch(
"app.chain.transfer.get_chain_transfer_history_port", return_value=SimpleNamespace()
), patch(
@@ -1023,7 +1097,8 @@ class TransferJobManagerTest(unittest.TestCase):
finally:
_reset_failed_retries(src_path, storage)
def test_unrecognized_task_marks_downloader_hash_completed(self):
def test_unrecognized_task_waits_for_durable_settlement_before_completion(self):
"""拒绝检查点建立后、writer 结算前不得提前完成下载种子或移除作业。"""
chain = make_transfer_chain()
chain.post_message = lambda *_args, **_kwargs: None
completed = []
@@ -1058,15 +1133,14 @@ class TransferJobManagerTest(unittest.TestCase):
self.assertFalse(state)
self.assertEqual("未识别到媒体信息", errmsg)
self.assertEqual([("abc123", "qbittorrent")], completed)
self.assertEqual([], chain.jobview.list_jobs())
self.assertEqual([], completed)
self.assertIsNotNone(task.plan_checkpoint)
self.assertIsNotNone(task.execution_checkpoint)
self.assertEqual(1, len(chain.jobview.list_jobs()))
chain.durable_event_writer.transfer_result.assert_not_called()
def test_unrecognized_task_survives_missing_failure_history(self):
"""
写整理历史失败(``add_transfer_fail`` 返回 None)时,未识别分支仍须走完
通知、作业清理与种子完成标记:历史落库是通知的附属信息,不是前置条件。
通知正文只省去 ``/redo`` 指引,不得因读取 ``his.id`` 抛 NoneType。
"""
def test_unrecognized_task_does_not_read_history_before_writer(self):
"""拒绝步骤完成但 writer 未调用时,不得读取失败历史或发送通知。"""
chain = make_transfer_chain()
notifications = []
chain.post_message = lambda message, **_kwargs: notifications.append(message)
@@ -1100,21 +1174,13 @@ class TransferJobManagerTest(unittest.TestCase):
self.assertFalse(state)
self.assertEqual("未识别到媒体信息", errmsg)
# 种子完成标记与作业清理都排在通知之后,通知崩掉会把它们一并跳过
self.assertEqual([("abc123", "qbittorrent")], completed)
self.assertEqual([], chain.jobview.list_jobs())
# 通知照发,但不含无法使用的 /redo 指引
self.assertEqual(1, len(notifications))
notification = notifications[0]
self.assertIn("未识别到媒体信息", notification.text)
self.assertNotIn("/redo", notification.text)
self.assertIsNone(notification.buttons)
self.assertEqual([], completed)
self.assertEqual([], notifications)
self.assertEqual(1, len(chain.jobview.list_jobs()))
chain.durable_event_writer.transfer_result.assert_not_called()
def test_unrecognized_task_keeps_redo_hint_when_history_written(self):
"""
整理历史正常落库时,未识别通知须保留两条 ``/redo`` 指引与操作按钮,
防止上一条用例被「一律删掉 /redo」这种偷懒实现蒙混过关。
"""
def test_unrecognized_task_does_not_publish_redo_before_writer(self):
"""即使历史函数可用,未经过 task-aware writer 也不得发布 redo。"""
chain = make_transfer_chain()
notifications = []
chain.post_message = lambda message, **_kwargs: notifications.append(message)
@@ -1141,22 +1207,8 @@ class TransferJobManagerTest(unittest.TestCase):
media_chain_cls.return_value.recognize_by_meta.return_value = None
chain._TransferChain__handle_transfer(task)
self.assertEqual(1, len(notifications))
notification = notifications[0]
self.assertIn("/redo 77\n", notification.text)
self.assertIn("/redo 77 [media_source]|[media_id]|[类型]", notification.text)
self.assertEqual(
[
[
{"text": "重试", "callback_data": "transfer_retry_77"},
{
"text": "智能助手接管",
"callback_data": "transfer_ai_retry_77",
},
]
],
notification.buttons,
)
self.assertEqual([], notifications)
chain.durable_event_writer.transfer_result.assert_not_called()
def test_do_transfer_syncs_same_stem_extra_files_by_default(self):
chain = make_transfer_chain()
@@ -1594,6 +1646,7 @@ class TransferJobManagerTest(unittest.TestCase):
) as storage_chain_cls:
storage_chain_cls.return_value.is_bluray_folder.return_value = False
for task, transferinfo in zip(tasks, transferinfos):
bind_terminal_checkpoint(task, transferinfo)
chain._TransferChain__default_callback(task, transferinfo)
chain._finish_scrape_batch_task(task)
@@ -1649,6 +1702,7 @@ class TransferJobManagerTest(unittest.TestCase):
need_scrape=True,
need_notify=False,
)
bind_terminal_checkpoint(task, transferinfo)
with patch(
"app.chain.transfer.get_chain_transfer_history_port", return_value=SimpleNamespace()
+27
View File
@@ -185,6 +185,33 @@ def test_transfer_lease_upgrade_downgrade_reupgrade(monkeypatch) -> None:
engine.dispose()
def test_replayed_upgrade_repairs_named_lease_index(monkeypatch) -> None:
"""同名但列和唯一性错误的租约索引必须精确重建。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
_create_planning_table(connection)
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
connection.execute(sa.text(
"DROP INDEX ix_transferpending_recovery_lease"
))
connection.execute(sa.text(
"CREATE UNIQUE INDEX ix_transferpending_recovery_lease "
"ON transferpending (task_id)"
))
migration.upgrade()
index = next(
item for item in sa.inspect(connection).get_indexes("transferpending")
if item["name"] == "ix_transferpending_recovery_lease"
)
assert index["column_names"] == [
"state", "lease_expires_at", "created_at", "id",
]
assert index["unique"] == 0
def test_partial_transfer_lease_upgrade_preserves_existing_owner(
monkeypatch,
) -> None:
+5 -3
View File
@@ -20,6 +20,7 @@ from app.application.transfer.workflow import (
TransferProviderReference,
)
from app.db.adapters.transfer.admission import TransactionalTransferAdmissionRepository
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
@@ -93,6 +94,7 @@ def repository_factory(tmp_path):
)
TransferHistory.__table__.create(engine)
TransferPending.__table__.create(engine)
TransferExecutionStep.__table__.create(engine)
factory = sessionmaker(bind=engine)
yield lambda: TransactionalTransferAdmissionRepository(factory)
engine.dispose()
@@ -207,7 +209,7 @@ def test_claim_heartbeat_expired_takeover_and_stale_token_guards(
lease_token=first.lease_token,
error="expired worker",
) is False
assert repository.discard_claimed(
assert repository.abandon_unstarted(
task_id=admitted.task_id,
lease_token=first.lease_token,
) == 0
@@ -231,7 +233,7 @@ def test_claim_heartbeat_expired_takeover_and_stale_token_guards(
lease_token=first.lease_token,
error="stale worker",
) is False
assert repository.discard_claimed(
assert repository.abandon_unstarted(
task_id=admitted.task_id,
lease_token=first.lease_token,
) == 0
@@ -257,7 +259,7 @@ def test_claim_heartbeat_expired_takeover_and_stale_token_guards(
)
assert third is not None
assert third.attempt_count == 3
assert repository.discard_claimed(
assert repository.abandon_unstarted(
task_id=admitted.task_id,
lease_token=third.lease_token,
) == 1
+31 -3
View File
@@ -76,9 +76,9 @@ def _compat_chain(result_factory):
task.bind_execution_checkpoint(TransferExecutionCheckpoint.create(
payload={
"outcome": (
"succeeded"
if result.success
else "failed"
"overwrite_skipped"
if result.overwrite_skipped
else "succeeded" if result.success else "failed"
),
"transferinfo": result.model_dump(mode="json"),
},
@@ -192,6 +192,34 @@ def test_legacy_settlement_response_loss_replays_receipt_by_same_task_id() -> No
assert second["settlement"] == first["settlement"]
def test_legacy_settlement_double_failure_releases_claim_without_deleting_evidence(
) -> None:
"""两次 writer 均失败时必须释放 lease,并保留 pending 与步骤供恢复。"""
chain, executed = _compat_chain(lambda task: _result(task, success=True))
chain._transfer_admissions = Mock()
chain._transfer_admissions.release_claim.return_value = True
chain._TransferChain__ensure_recovery_scheduler = Mock()
chain.durable_event_writer.transfer_result.side_effect = RuntimeError(
"writer unavailable"
)
returned = _invoke(chain, _fileitem())
assert returned.success is False
assert "writer unavailable" in (returned.message or "")
assert executed == ["source-v1"]
assert chain.durable_event_writer.transfer_result.call_count == 2
chain._transfer_admissions.release_claim.assert_called_once_with(
task_id="task-source-v1",
lease_token="lease-source-v1",
error=(
"旧整理兼容命令 durable 终态结算失败:writer unavailable"
),
)
chain._transfer_admissions.abandon_unstarted.assert_not_called()
assert chain._owned_leases == {}
def test_legacy_overwrite_skip_binds_existing_success_in_atomic_writer() -> None:
"""覆盖跳过复用既有成功历史并以 succeeded 终态结算。"""
chain, executed = _compat_chain(
+43 -8
View File
@@ -15,6 +15,11 @@ from app.application.transfer.execution import (
TransferStepIntent,
TransferStepResult,
)
from app.application.transfer.workflow import (
TransferPlanCheckpoint,
TransferPlanItem,
TransferPlanningInput,
)
from app.db.adapters.transfer.execution import (
TransactionalTransferExecutionRepository,
)
@@ -58,19 +63,44 @@ def _put_in_manual_review(factory, *, task_id: str) -> tuple[
str,
]:
"""建立一个外部结果 UNKNOWN 且已释放租约的人工复核任务。"""
source_path = f"/downloads/{task_id}.mkv"
target_path = f"/library/{task_id}.mkv"
planning_input = TransferPlanningInput(
source_fileitem={
"storage": "local",
"path": source_path,
"type": "file",
},
target_storage="local",
target_path=target_path,
requested_transfer_type="copy",
)
checkpoint = TransferPlanCheckpoint(
planning_input=planning_input,
target_storage="local",
root_target_path="/library",
final_target_path=target_path,
resolved_transfer_type="copy",
items=(TransferPlanItem(
sequence=0,
source_fileitem=planning_input.source_fileitem,
target_storage="local",
target_path=target_path,
),),
)
with factory() as session:
session.add(TransferPending(
task_id=task_id,
storage="local",
src_path=f"/downloads/{task_id}.mkv",
src_path=source_path,
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}",
planning_input=planning_input.to_payload(),
input_fingerprint=planning_input.fingerprint,
checkpoint_version=1,
checkpoint_payload={"schema_version": 1, "task_id": task_id},
checkpoint_payload=checkpoint.to_payload(),
planned_at="2026-08-27 09:00:00",
lease_owner="worker-secret",
lease_token=f"lease-{task_id}",
@@ -90,13 +120,15 @@ def _put_in_manual_review(factory, *, task_id: str) -> tuple[
)
intent = TransferStepIntent.create(
task_id=task_id,
checkpoint_fingerprint=f"checkpoint-{task_id}",
checkpoint_fingerprint=checkpoint.fingerprint,
ordinal=0,
phase="transfer",
kind="materialize_target",
payload={
"source": f"/downloads/{task_id}.mkv",
"target": f"/library/{task_id}.mkv",
"source": planning_input.source_fileitem,
"target_storage": "local",
"target_path": target_path,
"transfer_type": "copy",
},
)
prepared = command.prepare(
@@ -189,7 +221,10 @@ def test_unknown_manual_review_is_discoverable_and_resumes_via_api(
}
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.intent["target_path"]
== f"/library/task-{decision}.mkv"
)
assert discovered.step.evidence == {
"observation": "unknown",
"target_exists": True,
+39 -8
View File
@@ -15,11 +15,12 @@ from app.application.transfer.execution import (
TransferExecutionCheckpoint,
TransferSettlementResult,
)
from app.chain.transfer import TransferChain
from app.chain.transfer import TransferChain, _DurableTransferStepRunner
from app.schemas.transfer import TransferInfo
from app.schemas.types import EventType
from tests.test_transfer_job_manager import (
FakeMedia,
bind_terminal_checkpoint,
make_fileitem,
make_task,
make_transfer_chain,
@@ -130,16 +131,19 @@ def test_overwrite_declined_uses_successful_durable_settlement():
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="目标已存在,按覆盖策略跳过覆盖",
)
task.bind_execution_checkpoint(TransferExecutionCheckpoint.create(
payload={
"outcome": "overwrite_skipped",
"transferinfo": transferinfo.model_dump(mode="json"),
},
operation_ids=(),
skip_reason="overwrite_declined",
))
settlement = TransferChain._TransferChain__build_transfer_result_settlement(
task,
@@ -152,6 +156,27 @@ def test_overwrite_declined_uses_successful_durable_settlement():
assert settlement.error is None
def test_durable_step_runner_records_overwrite_skip_as_explicit_outcome():
"""生产 runner 必须冻结覆盖跳过事实,不能提前把它记成普通失败。"""
runner = object.__new__(_DurableTransferStepRunner)
runner._task_id = "task-overwrite-skipped"
runner._lease_token = "lease"
runner._operation_ids = []
runner._command = MagicMock()
runner._command.checkpoint.side_effect = lambda **kwargs: SimpleNamespace(
checkpoint=kwargs["checkpoint"]
)
transferinfo = TransferInfo(
success=False,
overwrite_skipped=True,
message="目标已存在,按覆盖策略跳过覆盖",
)
checkpoint = runner.checkpoint(transferinfo)
assert checkpoint.payload["outcome"] == "overwrite_skipped"
def test_overwrite_skip_without_success_history_uses_failed_settlement():
"""未核实既有成功历史时,覆盖跳过标志不能伪造成功终态。"""
task = make_task(1)
@@ -216,6 +241,7 @@ def test_default_callback_skips_history_and_notification_when_overwrite_declined
overwrite_skipped=True,
need_notify=False,
)
bind_terminal_checkpoint(task, transferinfo)
with patch(
"app.chain.transfer.get_chain_transfer_history_port",
@@ -262,6 +288,7 @@ def test_default_callback_keeps_original_failure_semantics_without_success_histo
overwrite_skipped=True,
need_notify=False,
)
bind_terminal_checkpoint(task, transferinfo)
with patch(
"app.chain.transfer.get_chain_transfer_history_port",
@@ -312,7 +339,6 @@ def test_durable_callback_settles_overwrite_skip_without_history_as_failed():
overwrite_skipped=True,
need_notify=False,
)
def durable_transfer_result(**kwargs):
"""执行失败历史暂存并返回 task-aware 结算回执。"""
history = kwargs["stage_history"](SimpleNamespace())
@@ -363,6 +389,7 @@ def test_default_callback_delegates_primary_failure_to_durable_writer():
transfer_type="copy",
need_notify=False,
)
bind_terminal_checkpoint(task, transferinfo)
def durable_transfer_result(**kwargs):
"""执行 writer 收到的历史暂存与提交后发布回调。"""
@@ -371,7 +398,11 @@ def test_default_callback_delegates_primary_failure_to_durable_writer():
payload["transfer_history_id"] = history.id
payload["idempotency_key"] = f"transfer.failed:{history.id}:v1"
kwargs["publish"](payload)
return history
return TransferSettlementResult(
history_id=history.id,
settlement_revision=1,
pending_deleted=True,
)
chain.durable_event_writer.transfer_result.side_effect = durable_transfer_result
with patch(
@@ -0,0 +1,97 @@
"""整理待处理表 3.0.4 初始迁移的中断恢复测试。"""
import importlib
import pytest
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
MIGRATION = "database.versions.e3d9f4b7c806_3_0_4"
def _bind_migration(monkeypatch, connection):
"""把 3.0.4 迁移绑定到隔离 SQLite 连接。"""
migration = importlib.import_module(MIGRATION)
monkeypatch.setattr(
migration,
"op",
Operations(MigrationContext.configure(connection)),
)
return migration
def test_upgrade_repairs_missing_and_malformed_identity_index(monkeypatch) -> None:
"""建表后中断或同名错误索引都必须收敛为精确唯一索引。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
connection.execute(sa.text(
"CREATE TABLE transferpending ("
"id INTEGER PRIMARY KEY, storage VARCHAR NOT NULL, "
"src_path VARCHAR NOT NULL, created_at VARCHAR)"
))
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
connection.execute(sa.text(
"DROP INDEX ux_transferpending_storage_path"
))
connection.execute(sa.text(
"CREATE INDEX ux_transferpending_storage_path "
"ON transferpending (src_path)"
))
migration.upgrade()
index = sa.inspect(connection).get_indexes("transferpending")[0]
assert index["name"] == "ux_transferpending_storage_path"
assert index["column_names"] == ["storage", "src_path"]
assert index["unique"] == 1
def test_upgrade_recreates_empty_partial_table(monkeypatch) -> None:
"""中断留下的空残表可以无损重建为完整 3.0.4 结构。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
connection.execute(sa.text(
"CREATE TABLE transferpending (id INTEGER PRIMARY KEY)"
))
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
assert {
column["name"]
for column in sa.inspect(connection).get_columns("transferpending")
} == {"id", "storage", "src_path", "created_at"}
def test_upgrade_rejects_nonempty_partial_table(monkeypatch) -> None:
"""含数据残表无法可靠推断源身份时必须显式拒绝迁移。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
connection.execute(sa.text(
"CREATE TABLE transferpending (id INTEGER PRIMARY KEY)"
))
connection.execute(sa.text(
"INSERT INTO transferpending (id) VALUES (1)"
))
migration = _bind_migration(monkeypatch, connection)
with pytest.raises(RuntimeError, match="含数据的不完整 transferpending"):
migration.upgrade()
def test_downgrade_tolerates_interrupted_missing_index(monkeypatch) -> None:
"""索引创建前中断时降级仍应安全删除残留表。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
connection.execute(sa.text(
"CREATE TABLE transferpending ("
"id INTEGER PRIMARY KEY, storage VARCHAR NOT NULL, "
"src_path VARCHAR NOT NULL, created_at VARCHAR)"
))
migration = _bind_migration(monkeypatch, connection)
migration.downgrade()
assert "transferpending" not in sa.inspect(connection).get_table_names()
+146 -13
View File
@@ -11,9 +11,20 @@ import threading
import time
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
from app.application.transfer.workflow import TransferAdmission, TransferPlanningInput, TransferTask
import pytest
from app.application.transfer.execution import (
TransferExecutionSnapshot,
TransferExecutionState,
)
from app.application.transfer.workflow import (
TransferAdmission,
TransferPlanningInput,
TransferTask,
)
from app.chain.transfer import TransferChain
from app.schemas.file import FileItem
@@ -26,6 +37,10 @@ def _build_chain(admissions) -> TransferChain:
"""
chain = object.__new__(TransferChain)
chain._transfer_admissions = admissions
chain._transfer_executions = MagicMock()
chain._transfer_executions.get_snapshot.side_effect = (
lambda *, task_id: _execution_snapshot(task_id=task_id)
)
chain._worker_owner_id = "test-owner"
chain._owned_leases = {}
chain._queued_lease_tokens = set()
@@ -40,8 +55,34 @@ def _build_chain(admissions) -> TransferChain:
return chain
def _execution_snapshot(
*,
task_id: str = "task-1",
state: TransferExecutionState = TransferExecutionState.NOT_STARTED,
steps: tuple[object, ...] = (),
) -> TransferExecutionSnapshot:
"""构造回放判定所需的最小执行状态投影。"""
return TransferExecutionSnapshot(
task_id=task_id,
state=state,
checkpoint=None,
retry_generation=0,
retry_count=0,
retry_due_at=None,
settlement_revision=0,
terminal_history_id=None,
last_error=None,
steps=steps,
)
def _admission(path: str, task_id: str = "task-1") -> TransferAdmission:
"""构造一条可脱离数据库会话使用的准入快照。"""
planning_input = TransferPlanningInput(
source_fileitem=_task(path).fileitem.model_dump(mode="json"),
meta=None,
mediainfo=None,
)
return TransferAdmission(
task_id=task_id,
storage="local",
@@ -49,6 +90,7 @@ def _admission(path: str, task_id: str = "task-1") -> TransferAdmission:
state="accepted",
created_at="2026-08-27 10:00:00",
updated_at="2026-08-27 10:00:00",
planning_input=planning_input,
lease_owner="test-owner",
lease_token=f"lease-{task_id}",
lease_expires_at="2026-08-27 10:02:00.000000",
@@ -97,12 +139,12 @@ def test_admit_transfer_records_storage_and_path():
assert result.task_id == "task-1"
def test_discard_pending_on_terminal_state():
"""
整理到达终态后必须注销登记否则每次重启都会重复回放
"""
def test_terminal_without_settlement_releases_claim_and_keeps_pending():
"""缺少原子终态回执时必须释放租约并保留 pending 供恢复。"""
admissions = MagicMock()
admissions.release_claim.return_value = True
chain = _build_chain(admissions)
chain.jobview = MagicMock()
task = _task("/mnt/cd2/downloads/Movie.2024.mkv")
task.bind_admission_task_id("task-1")
task.bind_execution_lease(owner_id="test-owner", lease_token="lease-task-1")
@@ -110,14 +152,18 @@ def test_discard_pending_on_terminal_state():
chain._owned_leases = {
"task-1": ("lease-task-1", time.monotonic() + 120)
}
admissions.discard_claimed.return_value = 1
assert chain._TransferChain__finish_job_execution(
task,
terminal=True,
terminal_settlement=None,
) is False
assert chain._TransferChain__discard_pending(task) is True
admissions.discard_claimed.assert_called_once_with(
admissions.release_claim.assert_called_once_with(
task_id="task-1",
lease_token="lease-task-1",
error="整理终态未完成 durable 原子结算",
)
admissions.abandon_unstarted.assert_not_called()
def test_replay_resends_pending_files_to_transfer(tmp_path, monkeypatch):
@@ -156,17 +202,104 @@ def test_replay_discards_vanished_files(tmp_path):
admissions = MagicMock()
missing = tmp_path / "gone.mkv"
admissions.claim_recoverable.return_value = [_admission(str(missing))]
admissions.discard_claimed.return_value = 1
admissions.abandon_unstarted.return_value = 1
chain = _build_chain(admissions)
chain._execute_transfer = MagicMock()
chain._TransferChain__replay_pending()
chain._execute_transfer.assert_not_called()
admissions.discard_claimed.assert_called_once_with(
admissions.abandon_unstarted.assert_called_once_with(
task_id="task-1",
lease_token="lease-task-1",
)
admissions.release_claim.assert_not_called()
assert chain._owned_leases == {}
def test_replay_releases_claim_when_vanished_source_abandon_is_rejected(
tmp_path,
) -> None:
"""注销 CAS 被执行证据拒绝时必须释放 claim,不能留下无人续期的毒任务。"""
admissions = MagicMock()
missing = tmp_path / "state-changed.mkv"
admission = _admission(str(missing))
admissions.claim_recoverable.return_value = [admission]
admissions.abandon_unstarted.return_value = 0
admissions.release_claim.return_value = True
chain = _build_chain(admissions)
chain._execute_transfer = MagicMock()
chain._TransferChain__replay_pending()
admissions.abandon_unstarted.assert_called_once_with(
task_id="task-1",
lease_token="lease-task-1",
)
admissions.release_claim.assert_called_once_with(
task_id="task-1",
lease_token="lease-task-1",
error="源已消失但任务状态已变化,保留登记供恢复",
)
assert chain._owned_leases == {}
@pytest.mark.parametrize(
("execution_state", "steps"),
[
(TransferExecutionState.NOT_STARTED, ()),
(TransferExecutionState.RUNNING, ()),
(TransferExecutionState.RETRY_WAIT, ()),
(TransferExecutionState.NOT_STARTED, (SimpleNamespace(),)),
],
)
def test_replay_with_execution_evidence_uses_frozen_source_when_source_vanished(
tmp_path,
monkeypatch,
execution_state,
steps,
) -> None:
"""已有执行状态或步骤证据时不得用源消失推断任务可删除。"""
missing = tmp_path / "already-moved.mkv"
planning_input = TransferPlanningInput(
source_fileitem=_task(str(missing)).fileitem.model_dump(mode="json"),
meta=None,
mediainfo=None,
requested_transfer_type="move",
)
admission = replace(
_admission(str(missing)),
state="planned",
planning_input=planning_input,
checkpoint=MagicMock(),
)
admissions = MagicMock()
admissions.claim_recoverable.return_value = [admission]
chain = _build_chain(admissions)
chain._transfer_executions.get_snapshot.return_value = _execution_snapshot(
state=execution_state,
steps=steps,
)
chain._transfer_executions.get_snapshot.side_effect = None
queue_planned = MagicMock(return_value=True)
monkeypatch.setattr(
chain,
"_TransferChain__queue_planned_replay",
queue_planned,
)
def reject_stat(*_args, **_kwargs):
"""冻结恢复触碰源文件即判定测试失败。"""
pytest.fail("已有执行证据的恢复不得探测已经消失的源文件")
monkeypatch.setattr(Path, "stat", reject_stat)
chain._TransferChain__replay_pending()
queued_fileitem = queue_planned.call_args.args[0]
assert queued_fileitem.path == str(missing)
admissions.abandon_unstarted.assert_not_called()
admissions.release_claim.assert_not_called()
def test_replay_keeps_registration_when_mount_unreadable(tmp_path, monkeypatch):
@@ -195,7 +328,7 @@ def test_replay_keeps_registration_when_mount_unreadable(tmp_path, monkeypatch):
chain._TransferChain__replay_pending()
chain._execute_transfer.assert_not_called()
admissions.discard_claimed.assert_not_called()
admissions.abandon_unstarted.assert_not_called()
admissions.release_claim.assert_called_once_with(
task_id="task-1",
lease_token="lease-task-1",
@@ -316,7 +449,7 @@ def test_replay_stop_keeps_unprocessed_registrations(tmp_path, monkeypatch):
chain._TransferChain__replay_pending(stop_event)
assert transferred == [first.as_posix()]
admissions.discard_claimed.assert_not_called()
admissions.abandon_unstarted.assert_not_called()
assert admissions.release_claim.call_count == 2
+283 -28
View File
@@ -15,7 +15,11 @@ from sqlalchemy.orm import sessionmaker
from app.application.transfer import workflow as transfer_application
from app.application.transfer.execution import (
TransferExecutionCheckpoint,
TransferExecutionSnapshot,
TransferExecutionState,
TransferExecutionStep,
TransferSettlementResult,
TransferStepState,
)
from app.application.transfer.workflow import TransferTask
from app.chain.transfer import TransferChain
@@ -27,7 +31,6 @@ from app.domain.meta.metabase import MetaBase
from app.modules.filemanager.module import FileManagerModule
from app.modules.filemanager.transhandler import TransHandler
from app.runtime.extensions.module.dispatcher import (
FrozenModuleProviderMissingError,
ModuleInvocationDispatcher,
)
from app.schemas.exception import StorageQueryError
@@ -204,10 +207,136 @@ def _planned_admission(task: TransferTask, checkpoint):
)
class _ExecutionRepositoryStub:
"""为规划编排测试提供严格但内存化的 execution repository。"""
def __init__(self) -> None:
"""初始化空步骤集合与未启动执行态。"""
self.steps = {}
self.state = TransferExecutionState.NOT_STARTED
self.checkpoint = None
def get_snapshot(self, *, task_id):
"""返回当前任务的类型化执行投影。"""
return TransferExecutionSnapshot(
task_id=task_id,
state=self.state,
checkpoint=self.checkpoint,
retry_generation=0,
retry_count=0,
retry_due_at=None,
settlement_revision=0,
terminal_history_id=None,
last_error=None,
steps=tuple(self.steps.values()),
)
def prepare_step(self, *, task_id, lease_token, intent):
"""幂等保存准备态步骤。"""
del lease_token
existing = self.steps.get(intent.operation_id)
if existing is not None:
return existing
step = TransferExecutionStep(
task_id=task_id,
operation_id=intent.operation_id,
checkpoint_fingerprint=intent.checkpoint_fingerprint,
ordinal=intent.ordinal,
phase=intent.phase,
kind=intent.kind,
state=TransferStepState.PREPARED,
attempt_token=None,
attempt_count=0,
intent=intent,
result=None,
last_error=None,
prepared_at="2026-08-27 10:00:00",
started_at=None,
completed_at=None,
updated_at="2026-08-27 10:00:00",
)
self.steps[intent.operation_id] = step
return step
def start_step(
self,
*,
task_id,
lease_token,
operation_id,
attempt_token,
):
"""把准备态步骤推进到已开始。"""
del task_id, lease_token
step = replace(
self.steps[operation_id],
state=TransferStepState.STARTED,
attempt_token=attempt_token,
attempt_count=1,
started_at="2026-08-27 10:00:01",
)
self.steps[operation_id] = step
self.state = TransferExecutionState.RUNNING
return step
def complete_step(
self,
*,
task_id,
lease_token,
operation_id,
attempt_token,
result,
):
"""以当前 attempt 提交成功证据。"""
del task_id, lease_token
assert self.steps[operation_id].attempt_token == attempt_token
step = replace(
self.steps[operation_id],
state=TransferStepState.SUCCEEDED,
result=result,
completed_at="2026-08-27 10:00:02",
)
self.steps[operation_id] = step
return step
def checkpoint_execution(self, *, task_id, lease_token, checkpoint):
"""保存可重放终态的聚合检查点。"""
del lease_token
self.state = TransferExecutionState.SETTLING
self.checkpoint = checkpoint
return self.get_snapshot(task_id=task_id)
def mark_manual_review(
self,
*,
task_id,
lease_token,
operation_id,
attempt_token,
error,
evidence,
):
"""把执行结果不确定的步骤隔离到人工复核态。"""
del lease_token
step = self.steps[operation_id]
assert step.attempt_token == attempt_token
self.steps[operation_id] = replace(
step,
state=TransferStepState.MANUAL_REVIEW,
result=evidence,
last_error=error,
)
self.state = TransferExecutionState.MANUAL_REVIEW
return self.get_snapshot(task_id=task_id)
def _chain(*, repository=None, checkpoint=None, result=None) -> TransferChain:
"""构造只保留规划编排依赖的 TransferChain 骨架。"""
chain = object.__new__(TransferChain)
chain._transfer_admissions = repository or Mock()
chain._transfer_executions = _ExecutionRepositoryStub()
chain.durable_event_writer = Mock()
chain._worker_owner_id = "planning-owner"
chain._owned_leases = {}
chain._queued_lease_tokens = set()
@@ -225,6 +354,7 @@ def _chain(*, repository=None, checkpoint=None, result=None) -> TransferChain:
state="accepted",
created_at="2026-08-27 10:00:00",
updated_at="2026-08-27 10:00:00",
planning_input=_planning_input(),
lease_owner=kwargs["owner_id"],
lease_token=f"lease-{kwargs['task_id']}",
lease_expires_at="2026-08-27 10:02:00.000000",
@@ -245,6 +375,14 @@ def _chain(*, repository=None, checkpoint=None, result=None) -> TransferChain:
transfer_type="copy",
)
)
def run_module(method, *args, **kwargs):
"""让规划测试沿正式模块入口调用其可观察的宿主执行替身。"""
assert method == "execute_transfer_plan"
checkpoint_arg = kwargs.pop("checkpoint")
return chain.execute_transfer_plan(checkpoint_arg, *args, **kwargs)
chain.run_module = Mock(side_effect=run_module)
return chain
@@ -252,6 +390,21 @@ def _replay_chain(repository) -> TransferChain:
"""构造绑定固定恢复 owner 且不启动真实 heartbeat 线程的测试链。"""
chain = object.__new__(TransferChain)
chain._transfer_admissions = repository
chain._transfer_executions = Mock()
chain._transfer_executions.get_snapshot.side_effect = (
lambda *, task_id: TransferExecutionSnapshot(
task_id=task_id,
state=TransferExecutionState.NOT_STARTED,
checkpoint=None,
retry_generation=0,
retry_count=0,
retry_due_at=None,
settlement_revision=0,
terminal_history_id=None,
last_error=None,
steps=(),
)
)
chain._worker_owner_id = "replay-owner"
chain._owned_leases = {}
chain._queued_lease_tokens = set()
@@ -275,6 +428,43 @@ def _real_dispatcher(plugins: dict) -> ModuleInvocationDispatcher:
)
def test_non_preview_missing_durable_writer_stops_before_planning_or_execution():
"""缺少原子 writer 时,持久任务取得租约后也不得开始任何外部流程。"""
task = _task()
task.bind_admission_task_id("task-missing-writer")
_bind_planning_input(task, _planning_input())
chain = _chain()
chain.durable_event_writer = None
with pytest.raises(RuntimeError, match="缺少 durable 原子写入端口"):
chain._plan_checkpoint_and_execute(task)
chain._module_dispatcher.freeze_plugin_providers.assert_not_called()
chain.plan_transfer.assert_not_called()
chain.execute_transfer_plan.assert_not_called()
def test_non_preview_missing_execution_repository_stops_before_side_effects():
"""缺少 execution repository 时不得调用 provider 或文件执行器。"""
task = _task()
task.bind_admission_task_id("task-missing-execution-repository")
_bind_planning_input(task, _planning_input())
_bind_checkpoint(task, _checkpoint())
chain = _chain()
chain._transfer_executions = None
chain._TransferChain__restore_planned_task = Mock()
with pytest.raises(RuntimeError, match="缺少 execution repository"):
chain._plan_checkpoint_and_execute(
task,
source_oper=object(),
target_oper=object(),
)
chain._module_dispatcher.execute_frozen_plugin_providers.assert_not_called()
chain.execute_transfer_plan.assert_not_called()
def test_legacy_provider_runs_only_after_checkpoint_commit_and_short_circuits_host():
"""旧插件 provider 必须随计划冻结,并在 CAS 提交后才能接管执行。"""
calls = []
@@ -314,7 +504,7 @@ def test_legacy_provider_runs_only_after_checkpoint_commit_and_short_circuits_ho
returned = chain._plan_checkpoint_and_execute(task)
assert returned is plugin_result
assert returned == plugin_result
assert calls == ["checkpoint", "plugin"]
chain.plan_transfer.assert_not_called()
chain.execute_transfer_plan.assert_not_called()
@@ -548,8 +738,8 @@ def test_missing_frozen_provider_keeps_pending_and_skips_cleanup() -> None:
chain._transfer_storage_chain = Mock(return_value=storage_chain)
with pytest.raises(
FrozenModuleProviderMissingError,
match=r"ProviderTwo/插件二\.transfer",
RuntimeError,
match=r"禁止自动重放.*ProviderTwo/插件二\.transfer",
):
chain._plan_checkpoint_and_execute(task)
@@ -824,7 +1014,7 @@ def test_provider_pending_crash_replay_executes_snapshot_without_host_planning()
returned = recovered_chain._plan_checkpoint_and_execute(recovered_task)
assert returned is recovered_result
assert returned == recovered_result
recovered_chain._module_dispatcher.freeze_plugin_providers.assert_not_called()
recovered_chain.plan_transfer.assert_not_called()
recovered_chain._transfer_admissions.checkpoint_plan.assert_not_called()
@@ -905,7 +1095,7 @@ def test_legacy_transfer_command_uses_durable_pipeline_and_settles_pending():
assert returned is result
assert calls == ["admit", "checkpoint", "execute", "settle"]
repository.discard_claimed.assert_not_called()
repository.abandon_unstarted.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
@@ -1561,31 +1751,22 @@ def test_filemanager_resolves_drifted_target_from_checkpoint(monkeypatch):
def test_pre_checkpoint_recognition_failure_records_retryable_error(monkeypatch):
"""准入后、checkpoint 前的业务失败必须写 last_error 并保持 accepted"""
"""未识别拒绝必须先建立 plan/execution checkpoint,且不提前写终态副作用"""
task = _task()
task.meta = MetaBase("Unrecognized.Movie.2026.mkv")
task.bind_admission_task_id("task-before-checkpoint")
task.bind_execution_lease(
owner_id="recognition-owner",
lease_token="lease-task-before-checkpoint",
)
chain = object.__new__(TransferChain)
chain._transfer_admissions = Mock()
chain = _chain()
chain._worker_owner_id = "recognition-owner"
chain._owned_leases = {
"task-before-checkpoint": (
"lease-task-before-checkpoint",
float("inf"),
)
}
chain._worker_state_lock = threading.RLock()
chain.jobview = Mock()
chain.queue_failed_transfer_notification = Mock()
chain.runtime_config = SimpleNamespace(
ai_agent_enable=False,
ai_agent_retry_transfer=False,
ai_agent_enable=True,
ai_agent_retry_transfer=True,
)
chain._TransferChain__mark_torrent_completed_if_done = Mock()
chain._transfer_admissions.checkpoint_plan.side_effect = (
lambda **kwargs: _planned_admission(task, kwargs["checkpoint"])
)
media_chain = Mock()
media_chain.recognize_by_meta.return_value = None
monkeypatch.setattr("app.chain.transfer.MediaChain", lambda: media_chain)
@@ -1593,17 +1774,91 @@ def test_pre_checkpoint_recognition_failure_records_retryable_error(monkeypatch)
"app.chain.transfer.get_chain_transfer_history_port",
lambda: SimpleNamespace(),
)
monkeypatch.setattr("app.chain.transfer.record_transfer_failure", Mock())
monkeypatch.setattr("app.chain.transfer.add_transfer_fail", lambda **_kwargs: None)
record_transfer_failure = Mock()
add_transfer_fail = Mock()
monkeypatch.setattr(
"app.chain.transfer.record_transfer_failure",
record_transfer_failure,
)
monkeypatch.setattr("app.chain.transfer.add_transfer_fail", add_transfer_fail)
result = chain._TransferChain__handle_transfer(task)
assert result == (False, "未识别到媒体信息")
chain._transfer_admissions.record_planning_failure.assert_called_once_with(
task_id="task-before-checkpoint",
lease_token="lease-task-before-checkpoint",
error="未识别到媒体信息",
assert task.plan_checkpoint is not None
assert task.plan_checkpoint.rejection_error == "未识别到媒体信息"
assert task.plan_checkpoint.items == ()
assert task.execution_checkpoint is not None
assert task.execution_checkpoint.payload["outcome"] == "failed"
assert [step.kind for step in chain._transfer_executions.steps.values()] == [
"reject"
]
chain._transfer_admissions.record_planning_failure.assert_not_called()
record_transfer_failure.assert_not_called()
add_transfer_fail.assert_not_called()
chain.queue_failed_transfer_notification.assert_not_called()
chain._TransferChain__mark_torrent_completed_if_done.assert_not_called()
def test_recognition_rejection_without_writer_has_zero_terminal_side_effects(
monkeypatch,
) -> None:
"""缺 writer 时未识别拒绝不得提交计划、历史、通知或 AI 重试。"""
task = _task()
task.meta = MetaBase("Unrecognized.Movie.2026.mkv")
task.bind_admission_task_id("task-rejection-missing-writer")
chain = _chain()
chain.durable_event_writer = None
chain.jobview = Mock()
chain.queue_failed_transfer_notification = Mock()
chain._TransferChain__mark_torrent_completed_if_done = Mock()
chain.runtime_config = SimpleNamespace(
ai_agent_enable=True,
ai_agent_retry_transfer=True,
)
media_chain = Mock()
media_chain.recognize_by_meta.return_value = None
monkeypatch.setattr("app.chain.transfer.MediaChain", lambda: media_chain)
monkeypatch.setattr(
"app.chain.transfer.get_chain_transfer_history_port",
lambda: SimpleNamespace(),
)
record_transfer_failure = Mock()
add_transfer_fail = Mock()
monkeypatch.setattr(
"app.chain.transfer.record_transfer_failure",
record_transfer_failure,
)
monkeypatch.setattr("app.chain.transfer.add_transfer_fail", add_transfer_fail)
with pytest.raises(RuntimeError, match="缺少 durable 原子写入端口"):
chain._TransferChain__handle_transfer(task)
assert task.plan_checkpoint is None
assert task.execution_checkpoint is None
chain._transfer_admissions.checkpoint_plan.assert_not_called()
record_transfer_failure.assert_not_called()
add_transfer_fail.assert_not_called()
chain.queue_failed_transfer_notification.assert_not_called()
chain._TransferChain__mark_torrent_completed_if_done.assert_not_called()
def test_planning_rejection_checkpoint_round_trips_and_rejects_file_steps():
"""拒绝原因必须稳定序列化,且不能与真实文件步骤同时存在。"""
checkpoint = replace(
_checkpoint(),
items=(),
rejection_error="未识别到媒体信息",
)
restored = transfer_application.TransferPlanCheckpoint.from_payload(
checkpoint.to_payload()
)
assert restored == checkpoint
assert restored.rejection_error == "未识别到媒体信息"
with pytest.raises(ValueError, match="不得包含文件步骤"):
replace(checkpoint, items=_checkpoint().items)
def test_preview_plans_without_persistence_or_file_side_effects():
+57 -4
View File
@@ -132,10 +132,11 @@ def _assert_upgrade_downgrade_reupgrade(connection, monkeypatch) -> None:
if isinstance(planning_payload, str):
planning_payload = json.loads(planning_payload)
planning_input = TransferPlanningInput.from_payload(planning_payload)
assert planning_input == TransferPlanningInput.legacy(
storage="local",
src_path="/downloads/Movie.mkv",
)
assert planning_input.source_fileitem == {
"storage": "local",
"path": "/downloads/Movie.mkv",
}
assert planning_input.options == {"legacy_replan": True}
assert upgraded["input_version"] == 1
assert upgraded["input_fingerprint"] == planning_input.fingerprint
assert upgraded["checkpoint_payload"] is None
@@ -282,6 +283,58 @@ def test_partial_upgrade_preserves_existing_planning_json(monkeypatch) -> None:
)).scalar_one() == "future-state"
def test_partial_upgrade_recomputes_inconsistent_planning_identity(monkeypatch) -> None:
"""部分升级留下的版本和指纹必须按最终 JSON 重算,不能保留伪身份。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
_create_admission_table(connection)
connection.execute(sa.text(
"ALTER TABLE transferpending ADD COLUMN input_version INTEGER"
))
connection.execute(sa.text(
"ALTER TABLE transferpending ADD COLUMN input_fingerprint VARCHAR(64)"
))
connection.execute(sa.text(
"UPDATE transferpending SET input_version = 99, "
"input_fingerprint = 'bogus' WHERE id = 1"
))
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
upgraded = _planning_row(connection)
payload = upgraded["planning_input"]
if isinstance(payload, str):
payload = json.loads(payload)
planning_input = TransferPlanningInput.from_payload(payload)
assert upgraded["input_version"] == planning_input.schema_version == 1
assert upgraded["input_fingerprint"] == planning_input.fingerprint
assert upgraded["input_fingerprint"] != "bogus"
def test_replayed_upgrade_repairs_complete_but_mismatched_identity(monkeypatch) -> None:
"""重复执行升级也必须修复完整三元组中与 payload 不一致的旧值。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
_create_admission_table(connection)
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
connection.execute(sa.text(
"UPDATE transferpending SET input_version = 7, "
"input_fingerprint = 'stale' WHERE id = 1"
))
migration.upgrade()
upgraded = _planning_row(connection)
payload = upgraded["planning_input"]
if isinstance(payload, str):
payload = json.loads(payload)
planning_input = TransferPlanningInput.from_payload(payload)
assert upgraded["input_version"] == 1
assert upgraded["input_fingerprint"] == planning_input.fingerprint
def test_transfer_planning_migration_runs_on_postgresql(monkeypatch) -> None:
"""配置隔离 PostgreSQL 时真实验证规划字段的完整可逆迁移。"""
prefix = "MOVIEPILOT_TEST_POSTGRESQL_"
+21 -12
View File
@@ -4,6 +4,7 @@ from dataclasses import replace
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker
from app.application.transfer.workflow import (
@@ -545,8 +546,10 @@ def test_checkpoint_rejects_missing_task(repository) -> None:
)
def test_direct_orm_defaults_create_valid_legacy_projection(tmp_path) -> None:
"""兼容直接构造 ORM 行时也必须生成匹配路径的版本化输入与指纹。"""
def test_canonical_admission_requires_explicit_versioned_planning_input(
tmp_path,
) -> None:
"""直接 ORM 写入不得伪造默认输入,canonical 仓储必须显式保存完整快照。"""
engine = create_engine(f"sqlite:///{tmp_path / 'orm-defaults.db'}")
factory = sessionmaker(bind=engine)
TransferPending.__table__.create(engine)
@@ -559,21 +562,27 @@ def test_direct_orm_defaults_create_valid_legacy_projection(tmp_path) -> None:
updated_at="2026-08-27 10:00:00",
)
session.add(pending)
session.commit()
task_id = pending.task_id
with pytest.raises(IntegrityError):
session.commit()
session.rollback()
repository = TransactionalTransferAdmissionRepository(factory)
admitted = repository.claim_task(
task_id=task_id,
owner_id="legacy-projection-worker",
lease_seconds=3600,
planning_input = replace(
_planning_input(),
source_fileitem={
"storage": "local",
"path": "/downloads/legacy.mkv",
"type": "file",
},
)
admitted = repository.admit(
storage="local",
src_path="/downloads/legacy.mkv",
planning_input=planning_input,
)
assert admitted is not None
assert admitted.planning_input == TransferPlanningInput.legacy(
storage="local",
src_path="/downloads/legacy.mkv",
)
assert admitted.planning_input == planning_input
assert admitted.input_fingerprint == admitted.planning_input.fingerprint
engine.dispose()
+23 -1
View File
@@ -5,7 +5,11 @@ import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.application.transfer.workflow import TransferAdmission, TransferQueueService
from app.application.transfer.workflow import (
TransferAdmission,
TransferPlanningInput,
TransferQueueService,
)
from app.db.adapters.transfer.admission import TransactionalTransferAdmissionRepository
from app.db.models.transferhistory import TransferHistory
from app.db.models.transferpending import TransferPending
@@ -13,6 +17,20 @@ from app.schemas.file import FileItem
from tests.test_transfer_job_manager import make_task, make_transfer_chain
def _planning_input(path: str = "/tmp/demo.mkv") -> TransferPlanningInput:
"""构造队列准入测试要求的显式版本化输入。"""
return TransferPlanningInput(
source_fileitem={
"storage": "local",
"path": path,
"type": "file",
"name": path.rsplit("/", 1)[-1],
},
meta=None,
mediainfo=None,
)
def _service(**overrides):
"""构造可观测整理队列服务及其默认依赖。"""
dependencies = {
@@ -24,6 +42,7 @@ def _service(**overrides):
state="accepted",
created_at="2026-08-27 10:00:00",
updated_at="2026-08-27 10:00:00",
planning_input=_planning_input(),
)),
"enqueue": Mock(),
"before_enqueue": Mock(),
@@ -48,6 +67,7 @@ def test_transfer_queue_service_put_preserves_registration_order():
state="accepted",
created_at="2026-08-27 10:00:00",
updated_at="2026-08-27 10:00:00",
planning_input=_planning_input(),
),
before_enqueue=lambda _task: calls.append("batch"),
enqueue=lambda _item: calls.append("queue"),
@@ -123,10 +143,12 @@ def test_transfer_queue_service_commits_admission_before_failed_enqueue(tmp_path
factory = sessionmaker(bind=engine)
repository = TransactionalTransferAdmissionRepository(factory)
task = make_task(1)
task.bind_planning_input(_planning_input(task.fileitem.path))
service, _ = _service(
admit_task=lambda item: repository.admit(
storage=item.fileitem.storage,
src_path=item.fileitem.path,
planning_input=item.planning_input,
),
enqueue=Mock(side_effect=RuntimeError("queue closed")),
enqueue_failed=lambda item, error: repository.record_enqueue_failure(
@@ -0,0 +1,796 @@
"""整理状态 3.0.17 数据收口与完整迁移链测试。"""
import hashlib
import importlib
import json
import os
import uuid
from datetime import datetime, timezone
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,
TransferExecutionState,
TransferManualReviewDecision,
TransferManualReviewQuery,
TransferStepResult,
)
from app.application.transfer.workflow import (
TransferPlanCheckpoint,
TransferPlanItem,
TransferPlanningInput,
TransferProviderInvocationSnapshot,
TransferProviderReference,
)
from app.db.adapters.transfer.admission import (
TransactionalTransferAdmissionRepository,
)
from app.db.adapters.transfer.execution import (
TransactionalTransferExecutionRepository,
)
try:
import psycopg2 as postgres_driver
from psycopg2 import sql
POSTGRESQL_DIALECT = "postgresql+psycopg2"
except ModuleNotFoundError:
import psycopg as postgres_driver
from psycopg import sql
POSTGRESQL_DIALECT = "postgresql+psycopg"
INITIAL_MIGRATION = "database.versions.e3d9f4b7c806_3_0_4"
ADMISSION_MIGRATION = "database.versions.b1e7d3f5a9c2_3_0_13"
PLANNING_MIGRATION = "database.versions.c2f8a4d6e1b3_3_0_14"
LEASE_MIGRATION = "database.versions.d3a9e5f7b2c4_3_0_15"
EXECUTION_MIGRATION = "database.versions.e5c7a9b1d3f6_3_0_16"
RECONCILIATION_MIGRATION = "database.versions.f6d8b0c2e4a7_3_0_17"
def _bind_migration(monkeypatch, connection, module_name: str):
"""把指定整理迁移绑定到隔离数据库连接。"""
migration = importlib.import_module(module_name)
monkeypatch.setattr(
migration,
"op",
Operations(MigrationContext.configure(connection)),
)
return migration
def _canonical_fingerprint(payload: dict[str, object]) -> str:
"""按运行时规范计算测试检查点指纹。"""
canonical = json.dumps(
payload,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def _create_3_0_15_tables(connection) -> sa.Table:
"""创建执行迁移前的 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), nullable=False),
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"),
)
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),
sa.Column("status", sa.Boolean()),
)
metadata.create_all(connection)
return pending
def _seed_execution_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,
"state": "accepted",
"input_version": 1,
"planning_input": {
"schema_version": 1,
"source_fileitem": {"storage": "local", "path": "/source"},
},
"input_fingerprint": "input",
"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,
}
task_ids = (
"unknown",
"settling-missing",
"failed-missing",
"retry-missing-due",
"partial-checkpoint",
"completed",
"manual-lease",
"settling-valid",
"failed-valid",
"accepted-checkpoint",
"rejection-checkpoint",
"invalid-outcome",
"invalid-overwrite",
)
connection.execute(pending.insert(), [
{
**base,
"id": index,
"task_id": task_id,
"src_path": f"/{task_id}",
}
for index, task_id in enumerate(task_ids, start=1)
])
def _execution_checkpoint() -> tuple[dict[str, object], str]:
"""构造合法且可由运行时恢复的零副作用执行检查点。"""
payload = {
"schema_version": 1,
"payload": {"outcome": "succeeded", "preview": True},
"operation_ids": [],
"skip_reason": "preview",
}
return payload, _canonical_fingerprint(payload)
def test_upgrade_reconciles_invalid_execution_combinations(monkeypatch) -> None:
"""非法执行组合必须留证转人工态,合法结算与失败终态不得被破坏。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
pending = _create_3_0_15_tables(connection)
_seed_execution_rows(connection, pending)
execution = _bind_migration(monkeypatch, connection, EXECUTION_MIGRATION)
execution.upgrade()
payload, fingerprint = _execution_checkpoint()
connection.execute(sa.text(
"UPDATE transferpending SET execution_state = 'future' "
"WHERE task_id = 'unknown'"
))
connection.execute(sa.text(
"UPDATE transferpending SET execution_state = 'settling' "
"WHERE task_id = 'settling-missing'"
))
connection.execute(sa.text(
"UPDATE transferpending SET execution_state = 'failed' "
"WHERE task_id = 'failed-missing'"
))
connection.execute(sa.text(
"UPDATE transferpending SET execution_state = 'retry_wait', "
"retry_due_at = NULL WHERE task_id = 'retry-missing-due'"
))
connection.execute(sa.text(
"UPDATE transferpending SET execution_state = 'running', "
"execution_version = 1 WHERE task_id = 'partial-checkpoint'"
))
connection.execute(sa.text(
"UPDATE transferpending SET execution_state = 'completed' "
"WHERE task_id = 'completed'"
))
connection.execute(sa.text(
"UPDATE transferpending SET execution_state = 'manual_review', "
"lease_owner = 'old-worker', lease_token = 'old-lease', "
"lease_expires_at = '2099-01-01 00:00:00.000000', "
"heartbeat_at = '2026-08-27 10:00:00.000000' "
"WHERE task_id = 'manual-lease'"
))
current = sa.table(
"transferpending",
sa.column("task_id", sa.String(64)),
sa.column("execution_state", sa.String(32)),
sa.column("state", sa.String(32)),
sa.column("input_version", sa.Integer()),
sa.column("planning_input", sa.JSON()),
sa.column("input_fingerprint", sa.String(64)),
sa.column("checkpoint_version", sa.Integer()),
sa.column("checkpoint_payload", sa.JSON()),
sa.column("planned_at", sa.String(40)),
sa.column("execution_version", sa.Integer()),
sa.column("execution_payload", sa.JSON()),
sa.column("execution_fingerprint", sa.String(64)),
sa.column("settlement_revision", sa.Integer()),
sa.column("terminal_history_id", sa.Integer()),
sa.column("lease_owner", sa.String(128)),
sa.column("lease_token", sa.String(64)),
sa.column("lease_expires_at", sa.String(40)),
)
host_input = _planning_input("/settling-valid")
host_checkpoint = _host_checkpoint(host_input)
failed_input = _planning_input("/failed-valid")
failed_checkpoint = _host_checkpoint(failed_input)
accepted_input = _planning_input("/accepted-checkpoint")
accepted_checkpoint = _host_checkpoint(accepted_input)
rejection_input = _planning_input("/rejection-checkpoint")
rejection_checkpoint = TransferPlanCheckpoint(
planning_input=rejection_input,
target_storage="local",
root_target_path="/library",
final_target_path="/library/Movies",
resolved_transfer_type="copy",
items=(),
rejection_error="未识别到媒体信息",
)
invalid_outcome_input = _planning_input("/invalid-outcome")
invalid_outcome_checkpoint = _host_checkpoint(invalid_outcome_input)
invalid_overwrite_input = _planning_input("/invalid-overwrite")
invalid_overwrite_checkpoint = _host_checkpoint(invalid_overwrite_input)
for task_id, planning_input, checkpoint, state in (
(
"settling-valid",
host_input,
host_checkpoint,
"planned",
),
(
"failed-valid",
failed_input,
failed_checkpoint,
"planned",
),
(
"accepted-checkpoint",
accepted_input,
accepted_checkpoint,
"accepted",
),
(
"rejection-checkpoint",
rejection_input,
rejection_checkpoint,
"accepted",
),
(
"invalid-outcome",
invalid_outcome_input,
invalid_outcome_checkpoint,
"planned",
),
(
"invalid-overwrite",
invalid_overwrite_input,
invalid_overwrite_checkpoint,
"planned",
),
):
connection.execute(
current.update()
.where(current.c.task_id == task_id)
.values(
state=state,
input_version=1,
planning_input=planning_input.to_payload(),
input_fingerprint=planning_input.fingerprint,
checkpoint_version=1,
checkpoint_payload=checkpoint.to_payload(),
planned_at="2026-08-27 10:30:00",
)
)
connection.execute(
current.update()
.where(current.c.task_id == "settling-valid")
.values(
execution_state="settling",
execution_version=1,
execution_payload=payload,
execution_fingerprint=fingerprint,
lease_owner="active-worker",
lease_token="active-lease",
lease_expires_at="2099-01-01 00:00:00.000000",
)
)
invalid_outcome_payload = {
**payload,
"payload": {"outcome": "future", "preview": True},
}
invalid_overwrite_payload = {
**payload,
"payload": {"outcome": "overwrite_skipped", "preview": True},
}
for task_id, invalid_payload in (
("invalid-outcome", invalid_outcome_payload),
("invalid-overwrite", invalid_overwrite_payload),
):
connection.execute(
current.update()
.where(current.c.task_id == task_id)
.values(
execution_state="settling",
execution_version=1,
execution_payload=invalid_payload,
execution_fingerprint=_canonical_fingerprint(invalid_payload),
)
)
connection.execute(sa.text(
"INSERT INTO transferhistory (id, src, src_storage, status) "
"VALUES (42, '/failed-valid', 'local', 0)"
))
connection.execute(
current.update()
.where(current.c.task_id == "failed-valid")
.values(
execution_state="failed",
execution_version=1,
execution_payload=payload,
execution_fingerprint=fingerprint,
settlement_revision=1,
terminal_history_id=42,
)
)
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 ("
"'failed-valid', 42, 1, 'failed', :fingerprint, 'failed-lease', 0, "
"'/failed-valid', 'local', 0, 'failed', "
"'2026-08-27 11:00:00', '2026-08-27 11:00:00'"
")"
), {"fingerprint": fingerprint})
reconciliation = _bind_migration(
monkeypatch,
connection,
RECONCILIATION_MIGRATION,
)
reconciliation.upgrade()
reconciliation.upgrade()
rows = {
row["task_id"]: dict(row)
for row in connection.execute(sa.text(
"SELECT task_id, execution_state, execution_version, "
"execution_payload, execution_fingerprint, lease_owner, lease_token "
"FROM transferpending"
)).mappings()
}
invalid = {
"unknown",
"settling-missing",
"failed-missing",
"retry-missing-due",
"partial-checkpoint",
"completed",
"invalid-outcome",
"invalid-overwrite",
}
assert {rows[task_id]["execution_state"] for task_id in invalid} == {
"manual_review"
}
assert all(
rows[task_id]["execution_version"] is None
and rows[task_id]["execution_payload"] is None
and rows[task_id]["execution_fingerprint"] is None
for task_id in invalid
)
assert rows["manual-lease"]["execution_state"] == "manual_review"
assert rows["manual-lease"]["lease_owner"] is None
assert rows["manual-lease"]["lease_token"] is None
assert rows["settling-valid"]["execution_state"] == "settling"
assert rows["settling-valid"]["lease_token"] == "active-lease"
assert rows["failed-valid"]["execution_state"] == "failed"
planning_states = dict(connection.execute(sa.text(
"SELECT task_id, state FROM transferpending "
"WHERE task_id IN ('accepted-checkpoint', 'rejection-checkpoint')"
)).all())
assert planning_states == {
"accepted-checkpoint": "planned",
"rejection-checkpoint": "planned",
}
assert connection.execute(sa.text(
"SELECT COUNT(*) FROM transferexecutionstep "
"WHERE kind = 'legacy_execution_review' "
"AND task_id IN ('unknown', 'settling-missing', 'failed-missing', "
"'retry-missing-due', 'partial-checkpoint', 'completed', "
"'invalid-outcome', 'invalid-overwrite')"
)).scalar_one() == len(invalid)
factory = sessionmaker(bind=engine, expire_on_commit=False)
reviews = TransferManualReviewQuery(
TransactionalTransferExecutionRepository(factory)
).list(page=1, page_size=20)
assert reviews.total == len(invalid) + 1
assert {item.task_id for item in reviews.items} == invalid | {"manual-lease"}
engine.dispose()
def test_reconciliation_migration_runs_on_postgresql(monkeypatch) -> None:
"""配置隔离 PostgreSQL 时真实验证人工态归一、租约清理与可逆 DDL。"""
prefix = "MOVIEPILOT_TEST_POSTGRESQL_"
host = os.getenv(f"{prefix}HOST")
database = os.getenv(f"{prefix}DATABASE")
username = os.getenv(f"{prefix}USERNAME")
if not host or not database or not username:
pytest.skip("未配置隔离 PostgreSQL migration 测试库")
port = os.getenv(f"{prefix}PORT", "5432")
password = os.getenv(f"{prefix}PASSWORD", "")
schema = f"transfer_reconciliation_{uuid.uuid4().hex}"
with postgres_driver.connect(
host=host,
port=port,
dbname=database,
user=username,
password=password,
) as connection:
connection.autocommit = True
with connection.cursor() as cursor:
cursor.execute(sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema)))
engine = None
try:
engine = sa.create_engine(
sa.URL.create(
POSTGRESQL_DIALECT,
username=username,
password=password,
host=host,
port=int(port),
database=database,
),
connect_args={"options": f"-csearch_path={schema}"},
)
with engine.begin() as connection:
pending = _create_3_0_15_tables(connection)
_seed_execution_rows(connection, pending)
execution = _bind_migration(
monkeypatch,
connection,
EXECUTION_MIGRATION,
)
reconciliation = _bind_migration(
monkeypatch,
connection,
RECONCILIATION_MIGRATION,
)
execution.upgrade()
connection.execute(sa.text(
"UPDATE transferpending SET state = 'manual_review', "
"execution_state = 'manual_review', lease_owner = 'old-worker', "
"lease_token = 'old-token', "
"lease_expires_at = '2099-01-01 00:00:00.000000' "
"WHERE task_id = 'manual-lease'"
))
reconciliation.upgrade()
row = connection.execute(sa.text(
"SELECT state, execution_state, lease_owner, lease_token "
"FROM transferpending WHERE task_id = 'manual-lease'"
)).one()
assert row == ("accepted", "manual_review", None, None)
reconciliation.downgrade()
execution.downgrade()
assert "execution_state" not in {
column["name"]
for column in sa.inspect(connection).get_columns("transferpending")
}
finally:
if engine is not None:
engine.dispose()
with postgres_driver.connect(
host=host,
port=port,
dbname=database,
user=username,
password=password,
) as connection:
connection.autocommit = True
with connection.cursor() as cursor:
cursor.execute(
sql.SQL("DROP SCHEMA IF EXISTS {} CASCADE").format(
sql.Identifier(schema)
)
)
def _planning_input(path: str) -> TransferPlanningInput:
"""构造完整且可持久恢复的规划输入。"""
return TransferPlanningInput(
source_fileitem={"storage": "local", "path": path, "type": "file"},
meta={"name": "Movie", "year": 2026},
mediainfo={"title": "Movie", "tmdb_id": 42},
target_directory={"storage": "local", "path": "/library"},
target_storage="local",
target_path="/library/Movies",
requested_transfer_type="copy",
)
def _host_checkpoint(planning_input: TransferPlanningInput) -> TransferPlanCheckpoint:
"""构造完整宿主计划检查点。"""
return TransferPlanCheckpoint(
planning_input=planning_input,
target_storage="local",
root_target_path="/library",
final_target_path="/library/Movies/Movie.mkv",
resolved_transfer_type="copy",
items=(TransferPlanItem(
sequence=0,
source_fileitem=planning_input.source_fileitem,
target_storage="local",
target_path="/library/Movies/Movie.mkv",
),),
)
def _provider_checkpoint(
planning_input: TransferPlanningInput,
) -> TransferPlanCheckpoint:
"""构造完整 provider 待执行检查点。"""
invocation = TransferProviderInvocationSnapshot(
fileitem=planning_input.source_fileitem,
meta=planning_input.meta,
meta_kind="MetaVideo",
mediainfo=planning_input.mediainfo,
mediainfo_kind="MediaInfo",
)
return TransferPlanCheckpoint(
planning_input=planning_input,
target_storage="",
root_target_path="",
final_target_path="",
resolved_transfer_type="",
items=(),
resolved_meta=invocation.meta,
resolved_meta_kind=invocation.meta_kind,
resolved_mediainfo=invocation.mediainfo,
resolved_mediainfo_kind=invocation.mediainfo_kind,
legacy_transfer_providers=(TransferProviderReference(
plugin_id="provider-a",
plugin_name="Provider A",
),),
provider_invocation=invocation,
)
def _create_history_table(connection) -> None:
"""创建迁移链所需的最小整理历史表。"""
connection.execute(sa.text(
"CREATE TABLE transferhistory ("
"id INTEGER PRIMARY KEY, src VARCHAR, "
"src_storage VARCHAR NOT NULL, status BOOLEAN)"
))
def _run_upgrade_chain(monkeypatch, connection) -> list[object]:
"""从 3.0.13 顺序升级到 3.0.17 并返回迁移模块。"""
migrations = [
_bind_migration(monkeypatch, connection, module_name)
for module_name in (
ADMISSION_MIGRATION,
PLANNING_MIGRATION,
LEASE_MIGRATION,
EXECUTION_MIGRATION,
RECONCILIATION_MIGRATION,
)
]
for migration in migrations:
migration.upgrade()
return migrations
def test_full_legacy_chain_projects_after_downgrade_and_reupgrade(
monkeypatch,
) -> None:
"""旧登记经完整升级、人工判定、降级再升级后仍可被真实仓储投影。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
initial = _bind_migration(monkeypatch, connection, INITIAL_MIGRATION)
initial.upgrade()
_create_history_table(connection)
connection.execute(sa.text(
"INSERT INTO transferpending (id, storage, src_path, created_at) VALUES "
"(1, 'local', '/accepted.mkv', '2026-08-27 09:00:00'), "
"(2, 'local', '/planned.mkv', '2026-08-27 09:00:01'), "
"(3, 'local', '/provider.mkv', '2026-08-27 09:00:02')"
))
admission = _bind_migration(
monkeypatch,
connection,
ADMISSION_MIGRATION,
)
planning = _bind_migration(
monkeypatch,
connection,
PLANNING_MIGRATION,
)
lease = _bind_migration(monkeypatch, connection, LEASE_MIGRATION)
execution = _bind_migration(
monkeypatch,
connection,
EXECUTION_MIGRATION,
)
reconciliation = _bind_migration(
monkeypatch,
connection,
RECONCILIATION_MIGRATION,
)
admission.upgrade()
planning.upgrade()
lease.upgrade()
rows = connection.execute(sa.text(
"SELECT id, task_id, src_path, planning_input FROM transferpending"
)).mappings().all()
by_path = {row["src_path"]: row for row in rows}
for path, checkpoint in (
(
"/planned.mkv",
_host_checkpoint(_planning_input("/planned.mkv")),
),
(
"/provider.mkv",
_provider_checkpoint(_planning_input("/provider.mkv")),
),
):
planning_input = checkpoint.planning_input
connection.execute(sa.text(
"UPDATE transferpending SET state = 'manual_review', "
"input_version = 1, planning_input = :planning_input, "
"input_fingerprint = :input_fingerprint, checkpoint_version = 1, "
"checkpoint_payload = :checkpoint_payload, "
"planned_at = '2026-08-27 09:30:00', "
"lease_owner = 'old-worker', lease_token = 'old-token', "
"lease_expires_at = '2099-01-01 00:00:00.000000' "
"WHERE id = :id"
), {
"id": by_path[path]["id"],
"planning_input": json.dumps(planning_input.to_payload()),
"input_fingerprint": planning_input.fingerprint,
"checkpoint_payload": json.dumps(checkpoint.to_payload()),
})
connection.execute(sa.text(
"UPDATE transferpending SET state = 'manual_review', "
"lease_owner = 'old-worker', lease_token = 'old-token', "
"lease_expires_at = '2099-01-01 00:00:00.000000' WHERE id = 1"
))
execution.upgrade()
reconciliation.upgrade()
normalized = dict(connection.execute(sa.text(
"SELECT src_path, state FROM transferpending ORDER BY id"
)).all())
assert normalized == {
"/accepted.mkv": "accepted",
"/planned.mkv": "planned",
"/provider.mkv": "provider_pending",
}
assert connection.execute(sa.text(
"SELECT COUNT(*) FROM transferpending WHERE lease_token IS NOT NULL"
)).scalar_one() == 0
factory = sessionmaker(bind=engine, expire_on_commit=False)
execution_repository = TransactionalTransferExecutionRepository(
factory,
local_clock=lambda: datetime(2026, 8, 27, 10, 0, 0),
lease_clock=lambda: datetime(2026, 8, 27, 2, 0, 0, tzinfo=timezone.utc),
)
reviews = TransferManualReviewQuery(execution_repository).list(
page=1,
page_size=10,
)
assert reviews.total == 3
command = TransferExecutionCommand(execution_repository)
for review in reviews.items:
resolved = command.resolve_manual_review(
task_id=review.task_id,
operation_id=review.step.operation_id,
decision=TransferManualReviewDecision.NOT_APPLIED,
actor="migration-test",
reason="确认旧执行未发生",
result=TransferStepResult(payload={"confirmed": False}),
)
assert resolved.state is TransferExecutionState.RETRY_WAIT
admission_repository = TransactionalTransferAdmissionRepository(factory)
monkeypatch.setattr(
admission_repository,
"_now",
lambda: "2026-08-27 10:00:01",
)
monkeypatch.setattr(
admission_repository,
"_lease_now",
lambda: datetime(2026, 8, 27, 2, 0, 1, tzinfo=timezone.utc),
)
claimed_states = set()
for task_id in (row["task_id"] for row in rows):
claimed = admission_repository.claim_task(
task_id=task_id,
owner_id="migration-worker",
lease_seconds=60,
)
assert claimed is not None
claimed_states.add(claimed.state)
assert claimed_states == {"accepted", "planned", "provider_pending"}
with engine.begin() as connection:
for migration in (
reconciliation,
execution,
lease,
planning,
admission,
):
monkeypatch.setattr(
migration,
"op",
Operations(MigrationContext.configure(connection)),
)
migration.downgrade()
assert {
column["name"]
for column in sa.inspect(connection).get_columns("transferpending")
} == {"id", "storage", "src_path", "created_at"}
migrations = _run_upgrade_chain(monkeypatch, connection)
assert connection.execute(sa.text(
"SELECT COUNT(*) FROM transferpending"
)).scalar_one() == 3
assert connection.execute(sa.text(
"SELECT COUNT(*) FROM transferpending "
"WHERE state = 'accepted' AND execution_state = 'not_started' "
"AND lease_token IS NULL"
)).scalar_one() == 3
assert all(migration is not None for migration in migrations)
reupgraded_repository = TransactionalTransferAdmissionRepository(factory)
monkeypatch.setattr(
reupgraded_repository,
"_now",
lambda: "2026-08-27 10:01:01",
)
monkeypatch.setattr(
reupgraded_repository,
"_lease_now",
lambda: datetime(2026, 8, 27, 2, 1, 1, tzinfo=timezone.utc),
)
reupgraded = [
reupgraded_repository.claim_task(
task_id=row["task_id"],
owner_id="reupgraded-worker",
lease_seconds=60,
)
for row in rows
]
assert all(item is not None for item in reupgraded)
assert {item.state for item in reupgraded if item is not None} == {"accepted"}
engine.dispose()
+229 -1
View File
@@ -9,21 +9,33 @@ import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.application.chain.events import TransferResultSettlement
from app.application.transfer.execution import (
TransferExecutionCheckpoint,
TransferExecutionCommand,
TransferExecutionSnapshot,
TransferExecutionState,
TransferOperationObservation,
TransferOperationObservationState,
TransferStepIntent,
TransferStepResult,
)
from app.application.transfer.workflow import (
TransferAdmission,
TransferPlanCheckpoint,
TransferPlanItem,
TransferPlanningInput,
TransferTask,
)
from app.chain import transfer as transfer_chain_module
from app.chain.transfer import TransferChain
from app.db.adapters.chain import TransactionalChainDurableEventWriter
from app.db.adapters.transfer.admission import TransactionalTransferAdmissionRepository
from app.db.adapters.transfer.execution import TransactionalTransferExecutionRepository
from app.db.models.transferexecutionstep import TransferExecutionStep
from app.db.models.transferhistory import TransferHistory
from app.db.models.transferpending import TransferPending
from app.db.models.transfersettlementreceipt import TransferSettlementReceipt
from app.schemas.file import FileItem
from app.schemas.transfer import TransferInfo
@@ -173,6 +185,8 @@ def admission_store(tmp_path):
engine = create_engine(f"sqlite:///{tmp_path / 'settling-recovery.db'}")
TransferHistory.__table__.create(engine)
TransferPending.__table__.create(engine)
TransferExecutionStep.__table__.create(engine)
TransferSettlementReceipt.__table__.create(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
try:
yield TransactionalTransferAdmissionRepository(factory), factory
@@ -202,6 +216,7 @@ def _build_chain(admissions) -> TransferChain:
"""构造只允许执行 settling 终态恢复的 TransferChain 骨架。"""
chain = object.__new__(TransferChain)
chain._transfer_admissions = admissions
chain._transfer_executions = MagicMock()
chain._worker_owner_id = "recovery-owner"
chain._owned_leases = {}
chain._queued_lease_tokens = set()
@@ -251,6 +266,18 @@ def _recovered_task(
admission.lease_token,
time.monotonic() + 120,
)
chain._transfer_executions.get_snapshot.return_value = TransferExecutionSnapshot(
task_id=admission.task_id,
state=TransferExecutionState.SETTLING,
checkpoint=execution_checkpoint,
retry_generation=0,
retry_count=0,
retry_due_at=None,
settlement_revision=0,
terminal_history_id=None,
last_error=None,
steps=(),
)
return task
@@ -381,7 +408,7 @@ def test_replay_settling_uses_frozen_source_without_filesystem_probe(
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.abandon_unstarted.assert_not_called()
admissions.release_claim.assert_not_called()
chain._plan_checkpoint_and_execute.assert_not_called()
@@ -454,3 +481,204 @@ def test_writer_failure_releases_and_reclaims_same_settling_checkpoint(
assert second_admission.lease_token != first_admission.lease_token
chain._TransferChain__select_storage_oper.assert_not_called()
chain._plan_checkpoint_and_execute.assert_not_called()
def test_bound_checkpoint_is_not_restored_outside_settling() -> None:
"""旧终态检查点留在 retry_wait 时不得跳过步骤恢复直接再次结算。"""
path = "/downloads/retry-state.mkv"
planning_input = _planning_input(path)
plan_checkpoint = _plan_checkpoint(planning_input)
execution_checkpoint = _execution_checkpoint(path, success=False)
admission = TransferAdmission(
task_id="retry-task",
storage="local",
src_path=path,
state="planned",
created_at="2026-08-27 09:00:00",
updated_at="2026-08-27 09:00:00",
planning_input=planning_input,
checkpoint=plan_checkpoint,
lease_owner="recovery-owner",
lease_token="retry-token",
)
chain = _build_chain(MagicMock())
task = _recovered_task(chain, admission, execution_checkpoint)
chain._transfer_executions.get_snapshot.return_value = TransferExecutionSnapshot(
task_id=admission.task_id,
state=TransferExecutionState.RETRY_WAIT,
checkpoint=execution_checkpoint,
retry_generation=1,
retry_count=1,
retry_due_at="2026-08-27 09:00:00.000000",
settlement_revision=1,
terminal_history_id=41,
last_error="copy failed",
steps=(),
)
restored = chain._TransferChain__restore_settling_transfer_result(task)
assert restored is None
def test_failed_settlement_retry_replays_step_and_commits_new_receipt(
admission_store,
) -> None:
"""失败结算请求重试后应恢复 FAILED 步骤,并以新检查点完成下一版结算。"""
admissions, factory = admission_store
path = "/downloads/retry-success.mkv"
planning_input = _planning_input(path)
plan_checkpoint = TransferPlanCheckpoint(
planning_input=planning_input,
target_storage="local",
root_target_path="/library",
final_target_path="/library/retry-success.mkv",
resolved_transfer_type="copy",
items=(TransferPlanItem(
sequence=0,
source_fileitem=planning_input.source_fileitem,
target_storage="local",
target_path="/library/retry-success.mkv",
),),
need_notify=False,
)
admitted = admissions.admit(
storage="local",
src_path=path,
planning_input=planning_input,
)
first_claim = admissions.claim_task(
task_id=admitted.task_id,
owner_id="first-owner",
lease_seconds=120,
)
assert first_claim is not None
assert first_claim.lease_token is not None
admissions.checkpoint_plan(
task_id=admitted.task_id,
lease_token=first_claim.lease_token,
input_fingerprint=planning_input.fingerprint,
checkpoint=plan_checkpoint,
)
executions = TransactionalTransferExecutionRepository(factory)
command = TransferExecutionCommand(
executions,
attempt_token_factory=iter(("attempt-1", "attempt-2")).__next__,
)
plan_fingerprint = (
TransferChain._TransferChain__transfer_plan_fingerprint(plan_checkpoint)
)
intent = TransferStepIntent.create(
task_id=admitted.task_id,
checkpoint_fingerprint=plan_fingerprint,
ordinal=0,
phase="transfer",
kind="copy",
payload={"source": path, "target": "/library/retry-success.mkv"},
)
prepared = command.prepare(
task_id=admitted.task_id,
lease_token=first_claim.lease_token,
intent=intent,
)
started = command.begin(
task_id=admitted.task_id,
lease_token=first_claim.lease_token,
operation_id=prepared.operation_id,
)
exhausted = command.exhaust(
task_id=admitted.task_id,
lease_token=first_claim.lease_token,
step=started,
error="copy failed",
evidence=TransferStepResult(payload={"target_exists": False}),
)
assert exhausted.checkpoint is not None
writer = TransactionalChainDurableEventWriter(factory)
first_settlement = writer.transfer_result(
topic=None,
stage_history=lambda repository: repository.add_force(
src=path,
src_storage="local",
status=False,
errmsg="copy failed",
),
event_payload={},
publish=None,
settlement=TransferResultSettlement(
task_id=admitted.task_id,
lease_token=first_claim.lease_token,
execution_fingerprint=exhausted.checkpoint.fingerprint,
outcome="failed",
error="copy failed",
),
)
assert first_settlement is not None
retry = command.request_retry(
task_id=admitted.task_id,
reason="manual retry",
requested_by="test",
)
assert retry.accepted is True
chain = _build_chain(admissions)
chain._transfer_executions = executions
chain.put_to_queue = MagicMock(return_value=True)
chain._TransferChain__replay_pending()
replayed_task = chain.put_to_queue.call_args.args[0]
assert replayed_task.execution_checkpoint is None
assert replayed_task.lease_token is not None
runner = transfer_chain_module._DurableTransferStepRunner(
task_id=admitted.task_id,
lease_token=replayed_task.lease_token,
checkpoint_fingerprint=plan_fingerprint,
repository=executions,
)
resumed = []
step_result = runner.run(
phase="transfer",
kind="copy",
payload={"source": path, "target": "/library/retry-success.mkv"},
execute=lambda: resumed.append("executed") or TransferStepResult(
payload={"target_exists": True}
),
observe=lambda: TransferOperationObservation(
state=TransferOperationObservationState.NOT_APPLIED,
evidence=TransferStepResult(payload={"target_exists": False}),
),
)
assert step_result.payload == {"target_exists": True}
assert resumed == ["executed"]
new_checkpoint = runner.checkpoint(_transfer_result(path, success=True))
assert new_checkpoint.fingerprint != exhausted.checkpoint.fingerprint
second_settlement = writer.transfer_result(
topic=None,
stage_history=lambda repository: repository.add_force(
src=path,
src_storage="local",
status=True,
errmsg=None,
),
event_payload={},
publish=None,
settlement=TransferResultSettlement(
task_id=admitted.task_id,
lease_token=replayed_task.lease_token,
execution_fingerprint=new_checkpoint.fingerprint,
outcome="succeeded",
),
)
assert second_settlement is not None
with factory() as session:
receipts = session.scalars(
select(TransferSettlementReceipt).order_by(
TransferSettlementReceipt.settlement_revision
)
).all()
pending = session.scalar(select(TransferPending))
steps = session.scalars(select(TransferExecutionStep)).all()
assert [receipt.outcome for receipt in receipts] == ["failed", "succeeded"]
assert pending is None
assert steps == []
+54 -1
View File
@@ -1,5 +1,8 @@
import threading
from types import SimpleNamespace
from unittest.mock import Mock
from app.application.transfer.execution import TransferExecutionCheckpoint
from app.application.transfer.workflow import TransferTask
from app.chain.transfer import TransferChain
from app.domain.context import MediaInfo
@@ -76,6 +79,43 @@ def test_transfer_stops_when_automatic_category_has_no_tmdb_result(monkeypatch)
"""启用自动类别目录时,缺少 TMDB 分类必须在文件操作前明确失败。"""
chain = object.__new__(TransferChain)
chain.jobview = SimpleNamespace(try_remove_job=lambda _task: None)
chain._transfer_admissions = Mock()
chain._worker_owner_id = "category-owner"
chain._owned_leases = {
"task-before-category": ("lease-before-category", float("inf"))
}
chain._worker_state_lock = threading.RLock()
chain.durable_event_writer = Mock()
chain.runtime_config = SimpleNamespace(
scrape_follow_tmdb=True,
ai_agent_enable=True,
ai_agent_retry_transfer=True,
)
chain.queue_failed_transfer_notification = Mock()
chain._TransferChain__mark_torrent_completed_if_done = Mock()
record_transfer_failure = Mock()
add_transfer_fail = Mock()
monkeypatch.setattr(
"app.chain.transfer.record_transfer_failure",
record_transfer_failure,
)
monkeypatch.setattr("app.chain.transfer.add_transfer_fail", add_transfer_fail)
chain._transfer_admissions.checkpoint_plan.side_effect = (
lambda **kwargs: SimpleNamespace(checkpoint=kwargs["checkpoint"])
)
step_runner = Mock()
step_runner.checkpoint.side_effect = lambda transferinfo: (
TransferExecutionCheckpoint.create(
payload={
"outcome": "failed",
"transferinfo": transferinfo.model_dump(mode="json"),
},
operation_ids=("planning-reject",),
)
)
chain._TransferChain__build_durable_step_runner = Mock(
return_value=step_runner
)
monkeypatch.setattr(
"app.chain.transfer.get_chain_transfer_history_port",
lambda: SimpleNamespace(),
@@ -114,7 +154,12 @@ def test_transfer_stops_when_automatic_category_has_no_tmdb_result(monkeypatch)
library_category_folder=True,
),
library_category_folder=True,
preview=True,
preview=False,
)
task.bind_admission_task_id("task-before-category")
task.bind_execution_lease(
owner_id="category-owner",
lease_token="lease-before-category",
)
state, message = chain._TransferChain__handle_transfer(task)
@@ -123,3 +168,11 @@ def test_transfer_stops_when_automatic_category_has_no_tmdb_result(monkeypatch)
assert message == "未识别到 TMDB 辅助信息,无法按媒体类别整理"
assert task.mediainfo.media_source == MediaSource.AniList
assert task.mediainfo.media_id == "1234"
assert task.plan_checkpoint is not None
assert task.plan_checkpoint.rejection_error == message
assert task.execution_checkpoint is not None
chain._transfer_admissions.record_planning_failure.assert_not_called()
record_transfer_failure.assert_not_called()
add_transfer_fail.assert_not_called()
chain.queue_failed_transfer_notification.assert_not_called()
chain._TransferChain__mark_torrent_completed_if_done.assert_not_called()
+52 -67
View File
@@ -4,13 +4,17 @@ import asyncio
import queue
import threading
import time
from concurrent.futures import Future
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, MagicMock
import pytest
from app.application.transfer.workflow import TransferAdmission, TransferQueue, TransferTask
from app.application.transfer.workflow import (
TransferAdmission,
TransferPlanningInput,
TransferQueue,
TransferTask,
)
from app.chain.transfer import TransferChain
from app.foundation.singleton import Singleton
from app.runtime.config import global_vars
@@ -19,6 +23,15 @@ from app.schemas.transfer import TransferInfo
from app.startup.initializers import transfer as transfer_initializer
def _planning_input(fileitem: FileItem) -> TransferPlanningInput:
"""构造 worker 准入与 claim 投影使用的真实规划输入。"""
return TransferPlanningInput(
source_fileitem=fileitem.model_dump(mode="json"),
meta=None,
mediainfo=None,
)
def _build_chain(*, transfer_threads: int = 0) -> TransferChain:
"""构造只包含后台线程生命周期字段的 TransferChain 测试骨架。"""
chain = object.__new__(TransferChain)
@@ -51,6 +64,7 @@ def _build_chain(*, transfer_threads: int = 0) -> TransferChain:
state="accepted",
created_at="2026-08-27 10:00:00",
updated_at="2026-08-27 10:00:00",
planning_input=kwargs["planning_input"],
)
admissions.claim_task.side_effect = lambda **kwargs: TransferAdmission(
task_id=kwargs["task_id"],
@@ -59,13 +73,18 @@ def _build_chain(*, transfer_threads: int = 0) -> TransferChain:
state="accepted",
created_at="2026-08-27 10:00:00",
updated_at="2026-08-27 10:00:00",
planning_input=_planning_input(FileItem(
storage="local",
path="/downloads/test.mkv",
type="file",
)),
lease_owner=kwargs["owner_id"],
lease_token=f"lease-{kwargs['task_id']}",
lease_expires_at="2026-08-27 10:02:00.000000",
heartbeat_at="2026-08-27 10:00:00.000000",
attempt_count=1,
)
admissions.discard_claimed.return_value = 1
admissions.abandon_unstarted.return_value = 1
admissions.release_claim.return_value = True
chain._transfer_admissions = admissions
chain._TransferChain__ensure_lease_heartbeat_owner = MagicMock()
@@ -82,6 +101,7 @@ def _claimed_admission(task: TransferTask, task_id: str) -> TransferAdmission:
state="accepted",
created_at="2026-08-27 10:00:00",
updated_at="2026-08-27 10:00:00",
planning_input=_planning_input(task.fileitem),
lease_owner="worker-owner",
lease_token=f"lease-{task_id}",
lease_expires_at="2026-08-27 10:02:00.000000",
@@ -224,33 +244,29 @@ def test_close_workers_lock_wait_uses_the_same_timeout_budget() -> None:
assert chain.close_workers(timeout_seconds=1) is True
def test_close_keeps_timer_dependencies_when_workers_do_not_converge() -> None:
"""活跃整理线程超时后,通知和重试 owner 必须继续供线程使用。"""
def test_close_keeps_failure_notification_when_workers_do_not_converge() -> None:
"""活跃整理线程超时后,失败通知 owner 必须继续供线程使用。"""
chain = _build_chain()
chain.close_workers = MagicMock(return_value=False)
chain.failure_notification_aggregator = MagicMock()
chain.retry_scheduler = MagicMock(close=AsyncMock())
completed = asyncio.run(chain.close(timeout_seconds=0.01))
assert completed is False
chain.close_workers.assert_called_once_with(0.01)
chain.failure_notification_aggregator.close.assert_not_called()
chain.retry_scheduler.close.assert_not_awaited()
def test_close_releases_timer_dependencies_after_workers_converge() -> None:
"""worker 和回放退出后,整理链应继续刷新通知并关闭 AI 重试"""
def test_close_releases_failure_notification_after_workers_converge() -> None:
"""worker 和回放退出后,整理链应刷新并关闭失败通知 owner"""
chain = _build_chain()
chain.close_workers = MagicMock(return_value=True)
chain.failure_notification_aggregator = MagicMock()
chain.retry_scheduler = MagicMock(close=AsyncMock())
completed = asyncio.run(chain.close(timeout_seconds=0.01))
assert completed is True
chain.failure_notification_aggregator.close.assert_called_once_with()
chain.retry_scheduler.close.assert_awaited_once_with()
def test_stop_transfer_runtime_does_not_construct_chain(monkeypatch) -> None:
@@ -329,49 +345,6 @@ def test_constructor_failure_publishes_started_worker_to_cleanup(monkeypatch) ->
assert workers[0].is_alive() is False
def test_failed_retry_schedule_future_error_is_observed() -> None:
"""跨线程调度协程的延迟异常必须被取回并写入日志。"""
future: Future[None] = Future()
future.set_exception(RuntimeError("scheduler closed"))
with patch("app.chain.transfer.logger.error") as log_error:
TransferChain._observe_failed_retry_schedule(future)
log_error.assert_called_once()
assert "scheduler closed" in log_error.call_args.args[0]
def test_failed_retry_schedule_registers_future_observer(monkeypatch) -> None:
"""整理线程提交 AI 重试后应让 Future 持续连接到异常观察回调。"""
chain = _build_chain()
async def schedule_retry(_history_id: int, *, group_key: str) -> None:
"""提供不会实际执行的调度协程,供跨线程提交边界检查。"""
chain.retry_scheduler = MagicMock(schedule_retry=schedule_retry)
future = MagicMock(spec=Future)
event_loop = MagicMock()
event_loop.is_running.return_value = True
event_loop.is_closed.return_value = False
monkeypatch.setattr(global_vars, "CURRENT_EVENT_LOOP", event_loop)
def submit(coroutine, loop):
"""关闭测试协程并返回可检查的并发 Future。"""
assert loop is event_loop
coroutine.close()
return future
with patch(
"app.chain.transfer.asyncio.run_coroutine_threadsafe",
side_effect=submit,
):
chain._schedule_failed_transfer_retry(42, "media:test")
future.add_done_callback.assert_called_once()
callback = future.add_done_callback.call_args.args[0]
assert callback is TransferChain._observe_failed_retry_schedule
def test_worker_requeues_item_taken_during_shutdown(monkeypatch) -> None:
"""停止信号与 queue.get 竞态时,未开始处理的任务必须原样放回队列。"""
chain = _build_chain()
@@ -465,8 +438,8 @@ def test_worker_settles_progress_when_only_stop_sentinel_remains(monkeypatch) ->
assert list(chain._queue.queue) == [chain._QUEUE_STOP_SENTINEL]
def test_durable_task_identity_flows_from_queue_to_terminal_discard(monkeypatch) -> None:
"""准入生成的稳定身份必须随队列任务到 worker 终态并准确注销"""
def test_durable_task_identity_flows_to_unsettled_terminal_claim_release(monkeypatch) -> None:
"""终态无原子回执时稳定身份必须用于释放 claim,pending 保持可恢复"""
chain = _build_chain()
chain.runtime_config.transfer_task_timeout = 0
task = TransferTask(fileitem=FileItem(
@@ -486,12 +459,13 @@ def test_durable_task_identity_flows_from_queue_to_terminal_discard(monkeypatch)
state="accepted",
created_at="2026-08-27 10:00:00",
updated_at="2026-08-27 10:00:00",
planning_input=_planning_input(task.fileitem),
)
admissions.claim_task.return_value = _claimed_admission(
task,
"durable-task-id",
)
admissions.discard_claimed.side_effect = (
admissions.release_claim.side_effect = (
lambda **_kwargs: discarded.set() or 1
)
chain._transfer_admissions = admissions
@@ -534,10 +508,12 @@ def test_durable_task_identity_flows_from_queue_to_terminal_discard(monkeypatch)
owner_id="worker-owner",
lease_seconds=120,
)
admissions.discard_claimed.assert_called_once_with(
admissions.release_claim.assert_called_once_with(
task_id="durable-task-id",
lease_token="lease-durable-task-id",
error="整理终态未完成 durable 原子结算",
)
admissions.abandon_unstarted.assert_not_called()
def test_claimed_task_prevents_progress_settlement_before_active_registration() -> None:
@@ -646,7 +622,7 @@ def test_recovered_worker_reuses_claimed_token_without_second_claim(
chain._processed_num = 0
chain._fail_num = 0
chain._total_num = 0
chain._transfer_admissions.discard_claimed.return_value = 1
chain._transfer_admissions.release_claim.return_value = True
stop_event = threading.Event()
def complete_recovery(*, task, callback):
@@ -670,10 +646,12 @@ def test_recovered_worker_reuses_claimed_token_without_second_claim(
assert worker.is_alive() is False
chain._transfer_admissions.claim_task.assert_not_called()
chain._transfer_admissions.discard_claimed.assert_called_once_with(
chain._transfer_admissions.release_claim.assert_called_once_with(
task_id="recovered-task",
lease_token="lease-recovered-task",
error="整理终态未完成 durable 原子结算",
)
chain._transfer_admissions.abandon_unstarted.assert_not_called()
def test_heartbeat_refreshes_current_token_and_forgets_lost_lease() -> None:
@@ -761,7 +739,7 @@ def test_worker_reports_failed_settlement_without_skipping_queue_bookkeeping(
chain._processed_num = 0
chain._fail_num = 0
chain._total_num = 0
chain._transfer_admissions.discard_claimed.return_value = 0
chain._transfer_admissions.release_claim.return_value = False
chain._TransferChain__settle_transfer_progress_if_idle = MagicMock()
stop_event = threading.Event()
@@ -877,8 +855,10 @@ def test_worker_fenced_releases_lost_lease_and_completes_queue_bookkeeping(
assert chain._recovery_wakeup_event.is_set() is False
def test_success_callback_runs_only_after_terminal_cas_succeeds(monkeypatch) -> None:
"""终态 CAS 被拒绝时不得写成功历史、事件或通知。"""
def test_callback_without_terminal_settlement_releases_claim_and_counts_failure(
monkeypatch,
) -> None:
"""回调未给出原子结算回执时必须保留 pending、释放 claim 并计失败。"""
chain = _build_chain()
task = TransferTask(fileitem=FileItem(
storage="local",
@@ -896,7 +876,7 @@ def test_success_callback_runs_only_after_terminal_cas_succeeds(monkeypatch) ->
chain._processed_num = 0
chain._fail_num = 0
chain._total_num = 0
chain._transfer_admissions.discard_claimed.return_value = 0
chain._transfer_admissions.release_claim.return_value = False
chain._TransferChain__settle_transfer_progress_if_idle = MagicMock()
success_callback = MagicMock(return_value=(True, ""))
chain._TransferChain__default_callback = success_callback
@@ -924,8 +904,13 @@ def test_success_callback_runs_only_after_terminal_cas_succeeds(monkeypatch) ->
worker.join(timeout=1)
assert worker.is_alive() is False
success_callback.assert_not_called()
chain.jobview.fail_unfinished_task.assert_called_once_with(task)
success_callback.assert_called_once()
chain._transfer_admissions.release_claim.assert_called_once_with(
task_id="admitted-task",
lease_token="lease-admitted-task",
error="整理终态未完成 durable 原子结算",
)
chain._transfer_admissions.abandon_unstarted.assert_not_called()
assert chain._fail_num == 1
assert chain._queue.unfinished_tasks == 0